first commit
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user