using Fitness_core_BE.Exceptions; namespace Fitness_core_BE.Helpers { /// /// Server-side guard for face photos headed to a Hikvision face library. /// /// The browser already resizes and re-encodes before upload, so this does not decode or /// re-encode -- it validates. That keeps the project free of an imaging package (and of /// ImageSharp's commercial-licence requirement) while still refusing anything a terminal /// would reject, including calls that bypass the web client entirely. /// /// Limits follow the face-library constraints rather than the ISAPI transport ceiling: /// a 2.5 MB photo uploads happily and then fails enrolment, which is the worst outcome /// because it looks like it worked. /// public static class FacePhotoValidator { /// Face-library ceiling. Well under the ~3 MB ISAPI transport limit. public const int MaxBytes = 200 * 1024; /// Below this the image is too small to hold a usable face. public const int MinDimension = 160; /// Terminals reject oversized frames outright. public const int MaxDimension = 1920; public sealed record FacePhotoInfo(byte[] Bytes, int Width, int Height); /// /// Decodes the base64 payload (with or without a data: prefix) and enforces the /// Hikvision face-library rules. Throws BadRequestException with a message the /// front end can show directly. /// public static FacePhotoInfo ValidateBase64(string? base64) { if (string.IsNullOrWhiteSpace(base64)) throw new BadRequestException("A face photo is required."); // Accept "data:image/jpeg;base64,..." as well as a bare payload. var payload = base64; var comma = payload.IndexOf(','); if (payload.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma > 0) payload = payload[(comma + 1)..]; byte[] bytes; try { bytes = Convert.FromBase64String(payload.Trim()); } catch (FormatException) { throw new BadRequestException("The face photo is not valid base64 data."); } if (bytes.Length == 0) throw new BadRequestException("The face photo is empty."); if (bytes.Length > MaxBytes) throw new BadRequestException( $"The face photo is {bytes.Length / 1024} KB. The access terminal accepts " + $"at most {MaxBytes / 1024} KB -- retake or choose a smaller photo."); // Trust the bytes, not the file name: JPEG starts FF D8 FF and ends FF D9. if (!IsJpeg(bytes)) throw new BadRequestException( "The face photo must be a JPEG image. PNG, HEIC and other formats are " + "not accepted by the access terminal."); var size = ReadJpegSize(bytes) ?? throw new BadRequestException("The face photo is not a readable JPEG image."); var (width, height) = size; if (width < MinDimension || height < MinDimension) throw new BadRequestException( $"The face photo is {width}x{height}. It must be at least " + $"{MinDimension}x{MinDimension} pixels."); if (width > MaxDimension || height > MaxDimension) throw new BadRequestException( $"The face photo is {width}x{height}. It must be no larger than " + $"{MaxDimension}x{MaxDimension} pixels."); return new FacePhotoInfo(bytes, width, height); } private static bool IsJpeg(byte[] bytes) => bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; /// /// Walks the JPEG marker segments to the start-of-frame, which carries the real /// dimensions. Returns null if the stream is truncated or malformed. /// private static (int Width, int Height)? ReadJpegSize(byte[] bytes) { // Skip the SOI marker. var i = 2; while (i + 3 < bytes.Length) { if (bytes[i] != 0xFF) { i++; // Resynchronise past fill bytes / entropy data. continue; } var marker = bytes[i + 1]; // Standalone markers carry no length payload. if (marker == 0xFF) { i++; continue; } if (marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { i += 2; continue; } // Start of scan -- image data begins, no SOF found. if (marker == 0xDA || marker == 0xD9) return null; if (i + 3 >= bytes.Length) return null; var segmentLength = (bytes[i + 2] << 8) | bytes[i + 3]; if (segmentLength < 2) return null; // SOF0/1/2/3/5/6/7/9/10/11/13/14/15 hold the frame header. DHT (C4), JPGA (C8) // and DAC (CC) sit in the same range but are not frame headers. var isStartOfFrame = marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC; if (isStartOfFrame) { // [len:2][precision:1][height:2][width:2] if (i + 9 >= bytes.Length) return null; var height = (bytes[i + 5] << 8) | bytes[i + 6]; var width = (bytes[i + 7] << 8) | bytes[i + 8]; if (width <= 0 || height <= 0) return null; return (width, height); } i += 2 + segmentLength; } return null; } } }