mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 15:23:12 +00:00
The rejection message now lists every type the media allowlist accepts, and a test fails if the message and the allowlist drift apart.
149 lines
6.8 KiB
C#
149 lines
6.8 KiB
C#
namespace SeaHaven.Services.Helpers
|
|
{
|
|
/// <summary>
|
|
/// Client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC),
|
|
/// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work
|
|
/// order, across the dispatcher media modal, the completion-doc media tab and the vendor
|
|
/// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read
|
|
/// from the MP4/MOV movie header (<see cref="VideoDurationProbe"/>); the browser pre-checks
|
|
/// it and the server enforces it, and unreadable metadata never blocks an upload.
|
|
/// </summary>
|
|
public static class WorkOrderMediaContract
|
|
{
|
|
public const long MaxPhotoBytes = 10_000_000;
|
|
public const long MaxVideoBytes = 100_000_000;
|
|
public const long MaxDocumentBytes = 50_000_000;
|
|
public const int MaxPhotosPerWorkOrder = 10;
|
|
public const int MaxVideosPerWorkOrder = 3;
|
|
public const int MaxVideoDurationSeconds = 90;
|
|
|
|
/// <summary>
|
|
/// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits
|
|
/// above <see cref="MaxVideoBytes"/> plus multipart overhead so an oversize file reaches the
|
|
/// per-kind check and gets its generic message instead of a framework 413. The EB nginx
|
|
/// proxy (.platform/nginx/conf.d/01_upload_body_size.conf) must stay above this value.
|
|
/// </summary>
|
|
public const long MaxUploadRequestBytes = 110_000_000;
|
|
|
|
public enum UploadKind
|
|
{
|
|
Photo,
|
|
Video,
|
|
Document,
|
|
Unknown
|
|
}
|
|
|
|
private static readonly Dictionary<string, UploadKind> KindByContentType =
|
|
new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["image/jpeg"] = UploadKind.Photo,
|
|
["image/jpg"] = UploadKind.Photo,
|
|
["image/png"] = UploadKind.Photo,
|
|
["image/heic"] = UploadKind.Photo,
|
|
["video/mp4"] = UploadKind.Video,
|
|
["video/quicktime"] = UploadKind.Video,
|
|
["application/pdf"] = UploadKind.Document,
|
|
["application/msword"] = UploadKind.Document,
|
|
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
|
|
UploadKind.Document,
|
|
};
|
|
|
|
private static readonly Dictionary<string, UploadKind> KindByExtension =
|
|
new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[".jpg"] = UploadKind.Photo,
|
|
[".jpeg"] = UploadKind.Photo,
|
|
[".png"] = UploadKind.Photo,
|
|
[".heic"] = UploadKind.Photo,
|
|
[".mp4"] = UploadKind.Video,
|
|
[".mov"] = UploadKind.Video,
|
|
[".pdf"] = UploadKind.Document,
|
|
[".doc"] = UploadKind.Document,
|
|
[".docx"] = UploadKind.Document,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Classifies an upload. Pass the validated (resolved) content type: it decides whenever
|
|
/// it is an allowlisted type, so a misleading file name cannot move a file into a larger
|
|
/// size class. The extension only decides when no allowlisted type is known (for example
|
|
/// counting stored attachment URLs).
|
|
/// </summary>
|
|
public static UploadKind ResolveKind(string? contentType, string? fileName)
|
|
{
|
|
var type = (contentType ?? string.Empty).Trim();
|
|
if (KindByContentType.TryGetValue(type, out var byType))
|
|
return byType;
|
|
|
|
var extension = Path.GetExtension(fileName ?? string.Empty);
|
|
return KindByExtension.TryGetValue(extension, out var byExtension)
|
|
? byExtension
|
|
: UploadKind.Unknown;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-work-order count check across both upload surfaces: dispatcher attachments
|
|
/// (classified by stored URL) and vendor-portal documents (classified by validated
|
|
/// content type). Returns the stable rejection message, or null when there is room.
|
|
/// </summary>
|
|
public static string? ValidateCount(
|
|
UploadKind kind,
|
|
IEnumerable<string> attachmentUrls,
|
|
IEnumerable<string> vendorContentTypes)
|
|
{
|
|
if (kind != UploadKind.Photo && kind != UploadKind.Video)
|
|
return null;
|
|
|
|
var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes);
|
|
|
|
if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder)
|
|
return "A work order can have at most 10 photos.";
|
|
if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder)
|
|
return "A work order can have at most 3 videos.";
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Photos and videos on a work order: dispatcher attachments (kind from the stored URL)
|
|
/// plus current vendor-portal documents (kind from the validated content type).
|
|
/// </summary>
|
|
public static (int Photos, int Videos) CountKinds(
|
|
IEnumerable<string> attachmentUrls,
|
|
IEnumerable<string> vendorContentTypes)
|
|
{
|
|
var kinds = attachmentUrls.Select(url => ResolveKind(null, url))
|
|
.Concat(vendorContentTypes.Select(type => ResolveKind(type, null)))
|
|
.ToList();
|
|
return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 90-second video limit, read from the MP4/QuickTime movie header. A video whose
|
|
/// duration cannot be read is not blocked. Returns the stable message or null.
|
|
/// </summary>
|
|
public static string? ValidateVideoDuration(Stream? content)
|
|
{
|
|
var seconds = VideoDurationProbe.TryReadDurationSeconds(content);
|
|
return seconds > MaxVideoDurationSeconds
|
|
? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter."
|
|
: null;
|
|
}
|
|
|
|
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
|
|
public static string? ValidateSize(long length, UploadKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
UploadKind.Photo when length > MaxPhotoBytes => "Photos must be 10 MB or smaller.",
|
|
UploadKind.Video when length > MaxVideoBytes => "Videos must be 100 MB or smaller.",
|
|
UploadKind.Document when length > MaxDocumentBytes =>
|
|
"Documents must be 50 MB or smaller.",
|
|
UploadKind.Unknown when length > MaxVideoBytes =>
|
|
"Files must be 100 MB or smaller.",
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
public static string? ValidateSize(string? contentType, string? fileName, long length)
|
|
=> ValidateSize(length, ResolveKind(contentType, fileName));
|
|
}
|
|
}
|