Files
Core_Be/Helpers/FacePhotoValidator.cs
T
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

158 lines
6.1 KiB
C#

using Fitness_core_BE.Exceptions;
namespace Fitness_core_BE.Helpers
{
/// <summary>
/// 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.
/// </summary>
public static class FacePhotoValidator
{
/// <summary>Face-library ceiling. Well under the ~3 MB ISAPI transport limit.</summary>
public const int MaxBytes = 200 * 1024;
/// <summary>Below this the image is too small to hold a usable face.</summary>
public const int MinDimension = 160;
/// <summary>Terminals reject oversized frames outright.</summary>
public const int MaxDimension = 1920;
public sealed record FacePhotoInfo(byte[] Bytes, int Width, int Height);
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Walks the JPEG marker segments to the start-of-frame, which carries the real
/// dimensions. Returns null if the stream is truncated or malformed.
/// </summary>
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;
}
}
}