Files
QMAX/server/QMax.Api/Infrastructure/Storage/AttachmentStorageService.cs
T

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"
};
}
}