158 lines
6.1 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|