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(); }