mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-02 03:53:24 +00:00
Adds MediaCounts (limits, current photos/videos, and the counts a new completion version would see) so the portal can refuse an 11th photo or 4th video before uploading it. Uses the same count and replacement rule the upload check enforces.
149 lines
6.8 KiB
C#
149 lines
6.8 KiB
C#
namespace SeaHaven.Services.Helpers
|
|
{
|
|
/// <summary>
|
|
/// SH-116 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 =>
|
|
"Videos must be 100 MB or smaller.",
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
public static string? ValidateSize(string? contentType, string? fileName, long length)
|
|
=> ValidateSize(length, ResolveKind(contentType, fileName));
|
|
}
|
|
}
|