using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using MS.Microservice.Core.IO;
using MS.Microservice.Lab.Application.Tools;
using MS.Microservice.Lab.Infrastructure.Labs;
namespace MS.Microservice.Lab.Controller;
public sealed class DemoFileForm
{
/// 要上传的非空文件,最多 20 MiB;按原始字节保存。
[Required]
public IFormFile? File { get; set; }
}
public sealed class DuplicateFilesRequest
{
/// 先上传文件得到的当前用户 key,提交 2–64 项。
[Required, MinLength(2), MaxLength(64)]
public string[] Keys { get; set; } = [];
}
public sealed class ZipFilesForm
{
/// 要打包的文件,1–32 份、实际合计最多 20 MiB;使用不重复的叶子文件名。
[Required, MinLength(1), MaxLength(32)]
public List Files { get; set; } = [];
}
public sealed record DemoStoredFile(string Key, long Length, DateTimeOffset LastModifiedUtc, string DownloadUrl)
{
/// 上传时返回的叶子文件名,供调用方保存;存储层不持久化此名称。
public string? OriginalFileName { get; init; }
}
public sealed record DemoDuplicateGroup(long Length, string Sha256, IReadOnlyList Keys);
public sealed record DemoExtractedFile(string RelativePath, DemoStoredFile File);
public sealed record DemoExtractionResult(IReadOnlyList Files, IReadOnlyList EmptyDirectories);
public sealed record DemoFileHash(string Key, string Sha256);
[LabOnly]
[Route("api/lab/tools/files")]
[Tags("工具示例:文件、摘要与 ZIP")]
public sealed class FileToolsController(ToolDemoFiles files, IWebHostEnvironment environment, IConfiguration configuration)
: ToolDemoControllerBase(files)
{
private readonly DemoFileStore _store = new(environment, configuration);
/// 将上传内容保存为当前用户的私有文件,返回下载地址。
/// multipart 上传 File,例如 notes.txt。保存使用 Core 的不覆盖写入和随机 key,不复用已有图片上传入口;本接口接受一般文件,不验证其业务格式。保留数据跨进程重启存在,宿主负责磁盘容量和保留期;可用 DELETE 主动清理。
[HttpPost, Consumes("multipart/form-data"), Produces("application/json")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge, "application/problem+json")]
public async Task Upload([FromForm] DemoFileForm form)
{
var storage = _store.ForOwner(Owner);
var bytes = await Files.ReadAsync(form.File, Token);
var key = StoredFileKey.Create(DemoFileStore.Extension(form.File!.FileName));
using var input = new MemoryStream(bytes, writable: false);
await storage.WriteNewAsync(key, input, Token);
var result = Describe((await storage.GetMetadataAsync(key, Token))!) with
{
OriginalFileName = Path.GetFileName(form.File!.FileName.Replace('\\', '/'))
};
return Created(result.DownloadUrl, result);
}
/// 读取当前用户文件的大小和最后修改时间。
/// key 使用上传或 ZIP 解压返回的值。其他用户的 key 与不存在的文件均返回 404;结果是查询时元数据,不提供文件系统快照。
[HttpGet("{key}"), Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Metadata(string key)
{
var result = await _store.ForOwner(Owner).GetMetadataAsync(key, Token);
return result is null ? NotFound() : Ok(Describe(result));
}
/// 下载当前用户保存的文件内容。
/// 返回 application/octet-stream 附件,ASP.NET Core 输出并释放文件流。打开后检查 64 MiB 输出上限。只接受 canonical key,不接受目录或服务器路径。
[HttpGet("{key}/content"), Produces("application/octet-stream")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task DownloadContent(string key)
{
try
{
var input = await _store.ForOwner(Owner).OpenReadAsync(key, Token);
try
{
if (input.Length > ToolDemoFiles.MaximumOutputBytes)
throw new ToolUploadException(413, "演示结果超过 64 MiB,请缩小输入或选段。");
return File(input, "application/octet-stream", key);
}
catch
{
await input.DisposeAsync();
throw;
}
}
catch (Exception error) when (error is FileNotFoundException or DirectoryNotFoundException) { return NotFound(); }
}
/// 删除当前用户保存的文件,缺失时同样成功。
/// 只能删除本人目录下的 canonical key。删除不影响已经打开的读取句柄;取消不会撤销已完成的删除。
[HttpDelete("{key}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task Delete(string key)
{
await _store.ForOwner(Owner).DeleteAsync(key, Token);
return NoContent();
}
/// 计算当前用户文件的 SHA-256 内容摘要。
/// 先上传文件,再传 key。返回 64 位大写十六进制摘要;它不提供文件真实性认证,也不删除内容。
[HttpGet("{key}/sha256"), Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Hash(string key)
{
try
{
await using var input = await _store.ForOwner(Owner).OpenReadAsync(key, Token);
return Ok(new DemoFileHash(key, await FileHashHelper.ComputeSha256Async(input, Token)));
}
catch (Exception error) when (error is FileNotFoundException or DirectoryNotFoundException) { return NotFound(); }
}
/// 在指定的本人文件中发现相同长度和 SHA-256 的重复候选。
/// 先上传至少两份文件,将其 key 放入 keys。只检查本次显式列表,重复 key 会去重;返回至少两个不同文件组成的组,不逐字节确认或自动删除。调用期间应保持文件稳定,缺失文件返回 404。
[HttpPost("duplicates"), Consumes("application/json"), Produces("application/json")]
[RequestSizeLimit(16_384)]
[ProducesResponseType>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Duplicates(DuplicateFilesRequest request)
{
try
{
var paths = request.Keys.Select(key => _store.PathFor(Owner, key)).ToArray();
var groups = await FileHashHelper.FindDuplicatesAsync(paths, Token);
return Ok(groups.Select(group => new DemoDuplicateGroup(group.Length, group.Sha256,
group.Paths.Select(Path.GetFileName).Select(name => name!).ToArray())).ToArray());
}
catch (Exception error) when (error is FileNotFoundException or DirectoryNotFoundException) { return NotFound(); }
}
/// 将上传的普通文件打包为一个可下载 ZIP。
/// multipart 的 Files 可重复添加。归档使用各文件的叶子名称,不接收服务器目录;名称必须唯一,路径和不安全文件名会失败。最多 32 份且实际合计 20 MiB,原始上传不进入持久文件列表。
[HttpPost("zip/create"), Consumes("multipart/form-data"), Produces("application/zip")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge, "application/problem+json")]
public async Task CreateZip([FromForm] ZipFilesForm form)
{
_ = Owner;
using var workspace = Files.CreateWorkspace();
var source = workspace.NewPath("contents");
Directory.CreateDirectory(source);
var names = new HashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
long total = 0;
foreach (var file in form.Files)
{
var name = Path.GetFileName(file.FileName.Replace('\\', '/'));
if (string.IsNullOrWhiteSpace(name) || name is "." or ".." || !names.Add(name))
throw new ArgumentException("ZIP 中的叶子文件名必须有效且不重复。");
var bytes = await Files.ReadAsync(file, Token);
total += bytes.Length;
if (total > ToolDemoFiles.MaximumFileBytes) throw new ToolUploadException(413, "打包文件实际合计最多 20 MiB。");
await System.IO.File.WriteAllBytesAsync(Path.Combine(source, name), bytes, Token);
}
var output = workspace.NewPath("archive.zip");
await using (var destination = new FileStream(output, FileMode.CreateNew, FileAccess.Write))
await ZipArchiveHelper.CreateZipAsync(source, destination, cancellationToken: Token);
return Download(await workspace.ReadResultAsync(output, Token), "application/zip", "archive.zip");
}
/// 安全解压 ZIP,返回本人可下载文件及空目录清单。
/// 上传 File 的扩展名须为 .zip。最多 128 个条目、单文件及实际解压合计最多 20 MiB;路径穿越、链接、CRC 错误和冲突条目会失败。文件用新 key 保存,可按 downloadUrl 下载或 DELETE 清理;relativePath 只作原归档名称展示,不作为磁盘访问参数。
[HttpPost("zip/extract"), Consumes("multipart/form-data"), Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, "application/problem+json")]
public async Task ExtractZip([FromForm] DemoFileForm form)
{
var storage = _store.ForOwner(Owner);
using var workspace = Files.CreateWorkspace();
var archive = await workspace.SaveAsync(form.File, [".zip"], Token);
var extracted = workspace.NewPath("extracted");
await using (var input = System.IO.File.OpenRead(archive))
await ZipArchiveHelper.ExtractZipAsync(input, extracted,
new() { MaxEntries = 128, MaxEntryBytes = ToolDemoFiles.MaximumFileBytes, MaxTotalBytes = ToolDemoFiles.MaximumFileBytes }, Token);
var results = new List();
var created = new List();
try
{
foreach (var path in Directory.EnumerateFiles(extracted, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal))
{
var key = StoredFileKey.Create(DemoFileStore.Extension(path));
await using var content = System.IO.File.OpenRead(path);
await storage.WriteNewAsync(key, content, Token);
created.Add(key);
results.Add(new(Path.GetRelativePath(extracted, path).Replace('\\', '/'),
Describe((await storage.GetMetadataAsync(key, Token))!)));
}
var empty = Directory.EnumerateDirectories(extracted, "*", SearchOption.AllDirectories)
.Where(path => !Directory.EnumerateFileSystemEntries(path).Any())
.Select(path => Path.GetRelativePath(extracted, path).Replace('\\', '/')).Order(StringComparer.Ordinal).ToArray();
return Ok(new DemoExtractionResult(results.AsReadOnly(), empty));
}
catch (Exception primary)
{
var failures = new List { primary };
foreach (var key in created)
{
try { await storage.DeleteAsync(key, CancellationToken.None); }
catch (Exception cleanup) { failures.Add(cleanup); }
}
if (failures.Count > 1) throw new AggregateException("解压文件发布失败且清理未完成。", failures);
throw;
}
}
private static DemoStoredFile Describe(StoredFileMetadata value) => new(value.Key, value.Length, value.LastModifiedUtc,
"/api/lab/tools/files/" + value.Key + "/content");
}
internal sealed class DemoFileStore
{
internal string Root { get; }
internal DemoFileStore(IWebHostEnvironment environment, IConfiguration configuration)
{
var configured = ToolDemoDefaults.Value(configuration, "StorageDirectory") ?? ".tools/tool-demo-files";
Root = Path.GetFullPath(Path.Combine(environment.ContentRootPath, configured));
var webRoot = Path.GetFullPath(environment.WebRootPath ?? Path.Combine(environment.ContentRootPath, "wwwroot"));
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (string.Equals(Path.TrimEndingDirectorySeparator(Root), Path.TrimEndingDirectorySeparator(webRoot), comparison)
|| Root.StartsWith(Path.TrimEndingDirectorySeparator(webRoot) + Path.DirectorySeparatorChar, comparison))
throw new ToolDependencyException("ToolDemos:StorageDirectory(必须位于 webroot 之外)");
}
internal static string OwnerTag(string owner)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
if (owner.Length > 256) throw new ArgumentException("用户标识过长。");
return Convert.ToHexStringLower(SHA256.HashData(new UTF8Encoding(false, true).GetBytes(owner)));
}
internal LocalFileStorage ForOwner(string owner) => new(Path.Combine(Root, "files", OwnerTag(owner)));
internal string PathFor(string owner, string key)
{
StoredFileKey.Validate(key);
return Path.Combine(Root, "files", OwnerTag(owner), key);
}
internal static string Extension(string name)
{
var extension = Path.GetExtension(name);
return extension.Length is >= 2 and <= 10 && extension.AsSpan(1).IndexOfAnyExcept("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") < 0
? extension : ".bin";
}
}