using System;
using System.IO;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.Server.Helpers;
///
/// Filesystem counterpart of : writes
/// the wire-format JSON payload next to the user's other files,
/// under signatures/, and updates the user's disk quota.
///
/// This is the JSON counterpart of the legacy
/// ReceiveProSignatureAsync method, which stored
/// sign-{billingCode}-{signType}-{estimateId}.png blobs.
/// We don't reuse that helper because (a) the wire format is no
/// longer a binary image, (b) there's no billingCode on
/// a freshly signed estimate in our model, and (c) the legacy
/// helper takes an whereas our pipeline
/// decodes a JSON body upstream of the controller and passes
/// in directly.
///
public static class EstimateSignatureFileHelper
{
///
/// Sub-directory under the user's root where signature
/// payloads live. Kept short to leave room in PATH_MAX on
/// legacy filesystems; the rest of the filename is
/// sign-{type}-{estimateId}-{utcTicks}.json.
///
public const string SignaturesSubdir = "signatures";
///
/// Format string for signature file names. Public so the
/// migration and the admin tools can list by pattern.
///
public static string FileNameFormat(SignatureType type, long estimateId, long utcTicks)
=> $"sign-{type.ToString().ToLowerInvariant()}-{estimateId}-{utcTicks}.json";
///
/// Persist a signature wire payload to disk. Returns the
/// file info (relative path under the user's root) suitable
/// for storing in ; the
/// caller is responsible for the database write.
///
/// Signed-in user. Their
/// Identity.Name locates the disk root via
/// .
///
/// Estimate this signature
/// attaches to. Used in the file name for human inspection
/// and to support multiple versions over time.
/// Provider or client signature.
/// Decoded wire payload (strokes +
/// coordinateMax + capturedAtUtc). Already validated
/// upstream.
/// Cancellation token forwarded to
/// the file write.
public static async Task ReceiveEstimateSignatureAsync(
this ClaimsPrincipal user,
long estimateId,
SignatureType type,
SignaturePadPayload payload,
CancellationToken token = default)
{
if (user is null) throw new ArgumentNullException(nameof(user));
if (payload is null) throw new ArgumentNullException(nameof(payload));
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
// Ensure the user has a /signatures/ sub-directory we can
// write to. EnsureDestinationDirectory throws on invalid
// paths and creates the directory on the way; the
// SignaturesSubdir constant is a server-controlled value
// (not user-derived), so we skip the IsValidYavscPath
// check that ReceiveUserFile performs on user-supplied
// subpaths.
var root = user.EnsureDestinationDirectory(SignaturesSubdir);
var fileName = FileNameFormat(type, estimateId, DateTime.UtcNow.Ticks);
var fullPath = Path.Combine(root, fileName);
var envelope = new
{
format = "yavsc.signature/v1",
coordinateMax = payload.CoordinateMax,
capturedAtUtc = payload.CapturedAtUtc,
estimateId,
type = type.ToString(),
// Identity.Name is the username; we keep the wire
// payload keyed on the username rather than the
// numeric/guid Id so disk-side human inspection
// (e.g. cat sign-pro-1234-...json) is self-evident.
signerName = user.Identity?.Name,
strokes = payload.Strokes,
strokeCount = CountStrokes(payload.Strokes),
};
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8, token).ConfigureAwait(false);
// Quota update is the controller's responsibility: the
// helper has no DbContext access, and a ClaimsPrincipal
// is not an ApplicationUser. The controller looks up
// the user by Identity.Name and bumps DiskUsage after
// a successful database write.
return new FileReceivedInfo(root, fileName);
}
private static int CountStrokes(int[] strokes)
{
int n = 0;
for (int i = 0; i < strokes.Length;)
{
int k = strokes[i];
if (k <= 0) break;
n++;
i += 1 + 2 * k;
}
return n;
}
}
///
/// Wire payload accepted by the signature endpoint and
/// persisted by .
/// Mirrors PostIt.Models.SignaturePadData's JSON shape
/// (without the disk-only envelope fields) so the two sides
/// stay trivially compatible.
///
public class SignaturePadPayload
{
///
/// Normalised coordinate upper bound. Must be
/// PostIt.Models.SignaturePadData.CoordinateMax
/// (10_000) today; declared as a property so a future
/// resolution change can be replayed against the same
/// wire format.
///
public int CoordinateMax { get; set; } = 10_000;
///
/// Client-reported capture time. The server may ignore
/// this for ordering (UTC now is the truth) but keeps it
/// for round-trip display.
///
public DateTime CapturedAtUtc { get; set; }
///
/// Wire strokes. See
/// PostIt.Models.SignaturePadData for the format.
///
public int[] Strokes { get; set; } = Array.Empty();
}