304 lines
12 KiB
C#
304 lines
12 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.Extensions.Options;
|
|
using QMax.Api.Configuration;
|
|
using QMax.Api.Data.Entities;
|
|
|
|
namespace QMax.Api.Infrastructure.Storage;
|
|
|
|
public sealed record StoredAttachment(
|
|
string OriginalFileName,
|
|
string StorageFileName,
|
|
string ContentType,
|
|
long FileSizeBytes,
|
|
string Sha256,
|
|
AttachmentKind Kind);
|
|
|
|
public interface IAttachmentStorageService
|
|
{
|
|
Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken);
|
|
Task<StoredAttachment> SaveRemoteAsync(
|
|
string fileName,
|
|
string? contentType,
|
|
Stream stream,
|
|
long? expectedLength,
|
|
AttachmentKind? preferredKind,
|
|
CancellationToken cancellationToken);
|
|
string GetPath(string storageFileName);
|
|
}
|
|
|
|
public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IAttachmentStorageService
|
|
{
|
|
private readonly QMaxOptions _options = options.Value;
|
|
|
|
public async Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken)
|
|
{
|
|
if (file.Length <= 0)
|
|
{
|
|
throw new InvalidOperationException("Empty files are not allowed.");
|
|
}
|
|
|
|
if (file.Length > _options.MaxUploadBytes)
|
|
{
|
|
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
|
}
|
|
|
|
Directory.CreateDirectory(_options.StoragePath);
|
|
|
|
var extension = Path.GetExtension(file.FileName);
|
|
if (extension.Length > 16)
|
|
{
|
|
extension = "";
|
|
}
|
|
|
|
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
|
var finalPath = GetPath(storageName);
|
|
var partPath = finalPath + ".part";
|
|
|
|
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
|
|
{
|
|
await file.CopyToAsync(target, cancellationToken);
|
|
}
|
|
|
|
var info = new FileInfo(partPath);
|
|
if (info.Length != file.Length)
|
|
{
|
|
File.Delete(partPath);
|
|
throw new IOException("Uploaded file size verification failed.");
|
|
}
|
|
|
|
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
|
File.Move(partPath, finalPath, false);
|
|
|
|
return new StoredAttachment(
|
|
Path.GetFileName(file.FileName),
|
|
storageName,
|
|
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
|
file.Length,
|
|
hash,
|
|
GuessKind(file.ContentType, extension));
|
|
}
|
|
|
|
public async Task<StoredAttachment> SaveRemoteAsync(
|
|
string fileName,
|
|
string? contentType,
|
|
Stream stream,
|
|
long? expectedLength,
|
|
AttachmentKind? preferredKind,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (expectedLength is > 0 && expectedLength > _options.MaxUploadBytes)
|
|
{
|
|
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
|
}
|
|
|
|
Directory.CreateDirectory(_options.StoragePath);
|
|
|
|
var safeOriginalName = NormalizeRemoteFileName(fileName, contentType, preferredKind);
|
|
var extension = Path.GetExtension(safeOriginalName);
|
|
if (extension.Length > 16)
|
|
{
|
|
extension = "";
|
|
}
|
|
|
|
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
|
var finalPath = GetPath(storageName);
|
|
var partPath = finalPath + ".part";
|
|
|
|
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
|
|
{
|
|
await stream.CopyToAsync(target, cancellationToken);
|
|
}
|
|
|
|
var info = new FileInfo(partPath);
|
|
if (info.Length <= 0)
|
|
{
|
|
File.Delete(partPath);
|
|
throw new IOException("Downloaded MAX media is empty.");
|
|
}
|
|
|
|
if (expectedLength is > 0 && info.Length != expectedLength)
|
|
{
|
|
File.Delete(partPath);
|
|
throw new IOException("Downloaded MAX media size verification failed.");
|
|
}
|
|
|
|
if (info.Length > _options.MaxUploadBytes)
|
|
{
|
|
File.Delete(partPath);
|
|
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
|
}
|
|
|
|
var declaredContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
|
var resolvedKind = preferredKind ?? GuessKind(declaredContentType, extension);
|
|
var resolvedContentType = NormalizeContentType(declaredContentType, safeOriginalName, resolvedKind);
|
|
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
|
File.Move(partPath, finalPath, false);
|
|
|
|
return new StoredAttachment(
|
|
safeOriginalName,
|
|
storageName,
|
|
resolvedContentType,
|
|
info.Length,
|
|
hash,
|
|
resolvedKind);
|
|
}
|
|
|
|
public string GetPath(string storageFileName)
|
|
{
|
|
var safeName = Path.GetFileName(storageFileName);
|
|
return Path.Combine(_options.StoragePath, safeName);
|
|
}
|
|
|
|
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
|
|
{
|
|
await using var stream = File.OpenRead(path);
|
|
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
public static AttachmentKind GuessKind(string? contentType, string extension)
|
|
{
|
|
var content = contentType?.ToLowerInvariant() ?? "";
|
|
var ext = extension.ToLowerInvariant();
|
|
if (content.Contains("vcard", StringComparison.Ordinal) || ext == ".vcf") return AttachmentKind.Contact;
|
|
if (content.StartsWith("image/gif") || ext == ".gif") return AttachmentKind.Gif;
|
|
if (content.StartsWith("image/")) return AttachmentKind.Image;
|
|
if (content.StartsWith("video/")) return AttachmentKind.Video;
|
|
if (content.StartsWith("audio/") || ext is ".ogg" or ".opus" or ".m4a" or ".aac" or ".mp3" or ".wav" or ".flac") return AttachmentKind.VoiceNote;
|
|
return AttachmentKind.File;
|
|
}
|
|
|
|
public static string NormalizeContentType(string? contentType, string? fileName, AttachmentKind kind)
|
|
{
|
|
var declared = contentType?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(declared) &&
|
|
!declared.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase) &&
|
|
!declared.Equals("binary/octet-stream", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return declared;
|
|
}
|
|
|
|
var extension = Path.GetExtension(fileName ?? "").ToLowerInvariant();
|
|
var inferred = extension switch
|
|
{
|
|
".m4a" => "audio/mp4",
|
|
".ogg" or ".oga" => "audio/ogg",
|
|
".opus" => "audio/opus",
|
|
".aac" => "audio/aac",
|
|
".mp3" => "audio/mpeg",
|
|
".wav" => "audio/wav",
|
|
".flac" => "audio/flac",
|
|
_ => null
|
|
};
|
|
|
|
return inferred ?? (kind == AttachmentKind.VoiceNote ? "audio/ogg" : declared ?? "application/octet-stream");
|
|
}
|
|
|
|
public static string NormalizeRemoteFileName(string? fileName, string? contentType, AttachmentKind? preferredKind)
|
|
{
|
|
var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim());
|
|
var extension = Path.GetExtension(original);
|
|
if (extension.Length > 16)
|
|
{
|
|
extension = "";
|
|
}
|
|
|
|
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
|
var kind = preferredKind ?? GuessKind(resolvedContentType, extension);
|
|
var fallbackExtension = string.IsNullOrWhiteSpace(extension)
|
|
? DefaultExtension(resolvedContentType, kind)
|
|
: extension.ToLowerInvariant();
|
|
|
|
return IsUnhelpfulRemoteFileName(original, kind, extension)
|
|
? $"max-{KindSlug(kind)}{fallbackExtension}"
|
|
: original;
|
|
}
|
|
|
|
private static bool IsUnhelpfulRemoteFileName(string value, AttachmentKind kind, string extension)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var normalized = value.Trim().ToLowerInvariant();
|
|
var stem = Path.GetFileNameWithoutExtension(normalized).Trim();
|
|
if (stem is
|
|
"photo" or
|
|
"image" or
|
|
"video" or
|
|
"audio" or
|
|
"voice" or
|
|
"gif" or
|
|
"media" or
|
|
"contact" or
|
|
"\u0444\u043e\u0442\u043e" or
|
|
"\u0432\u0438\u0434\u0435\u043e" or
|
|
"\u0430\u0443\u0434\u0438\u043e" or
|
|
"\u0433\u043e\u043b\u043e\u0441" or
|
|
"\u043a\u043e\u043d\u0442\u0430\u043a\u0442")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (normalized.Contains("\u0431\u0440\u0430\u0443\u0437\u0435\u0440", StringComparison.Ordinal) &&
|
|
normalized.Contains("\u043d\u0435", StringComparison.Ordinal) &&
|
|
normalized.Contains("\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (normalized.Contains("your browser", StringComparison.Ordinal) ||
|
|
normalized.Contains("not supported", StringComparison.Ordinal) ||
|
|
normalized.Contains("[object object]", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(extension) &&
|
|
kind is AttachmentKind.Image or AttachmentKind.Gif or AttachmentKind.Video or AttachmentKind.VoiceNote or AttachmentKind.Sticker;
|
|
}
|
|
|
|
private static string DefaultExtension(string contentType, AttachmentKind kind)
|
|
{
|
|
var content = contentType.ToLowerInvariant();
|
|
if (content.Contains("webp", StringComparison.Ordinal)) return ".webp";
|
|
if (content.Contains("jpeg", StringComparison.Ordinal) || content.Contains("jpg", StringComparison.Ordinal)) return ".jpg";
|
|
if (content.Contains("png", StringComparison.Ordinal)) return ".png";
|
|
if (content.Contains("gif", StringComparison.Ordinal)) return ".gif";
|
|
if (content.Contains("mp4", StringComparison.Ordinal)) return kind == AttachmentKind.VoiceNote ? ".m4a" : ".mp4";
|
|
if (content.Contains("webm", StringComparison.Ordinal)) return ".webm";
|
|
if (content.Contains("ogg", StringComparison.Ordinal) || content.Contains("opus", StringComparison.Ordinal)) return ".ogg";
|
|
if (content.Contains("mpeg", StringComparison.Ordinal)) return ".mp3";
|
|
if (content.Contains("wav", StringComparison.Ordinal)) return ".wav";
|
|
if (content.Contains("pdf", StringComparison.Ordinal)) return ".pdf";
|
|
if (content.Contains("vcard", StringComparison.Ordinal)) return ".vcf";
|
|
|
|
return kind switch
|
|
{
|
|
AttachmentKind.Image => ".jpg",
|
|
AttachmentKind.Gif => ".gif",
|
|
AttachmentKind.Video => ".mp4",
|
|
AttachmentKind.VoiceNote => ".ogg",
|
|
AttachmentKind.Sticker => ".webp",
|
|
AttachmentKind.Contact => ".vcf",
|
|
_ => ""
|
|
};
|
|
}
|
|
|
|
private static string KindSlug(AttachmentKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
AttachmentKind.Image => "image",
|
|
AttachmentKind.Gif => "gif",
|
|
AttachmentKind.Video => "video",
|
|
AttachmentKind.VoiceNote => "voice",
|
|
AttachmentKind.Sticker => "sticker",
|
|
AttachmentKind.Contact => "contact",
|
|
AttachmentKind.File => "file",
|
|
_ => "media"
|
|
};
|
|
}
|
|
}
|