using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; using XFEToolBox.Core.Tools; using XFEToolBox.Server.Core.Exceptions; using XFEToolBox.Server.Core.Models; using XFEToolBox.Server.Core.Options; using XFEToolBox.Server.Core.Utilities; namespace XFEToolBox.Server.Core.Services; public sealed partial class FileSystemToolPackageRepository : IToolPackageRepository { private const string MetadataFileName = "metadata.json"; private const string PackageFileName = "package.xfetool"; private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, PropertyNameCaseInsensitive = true }; private readonly ToolPackageValidator _validator; private readonly ToolPackageValidationOptions _validationOptions; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly string _storageRoot; private readonly string _incomingRoot; public FileSystemToolPackageRepository( ToolPackageValidator validator, ToolPackageValidationOptions validationOptions, ToolPackageStorageOptions storageOptions) { _validator = validator; _validationOptions = validationOptions; _storageRoot = Path.GetFullPath(storageOptions.StorageRoot); _incomingRoot = Path.Combine(_storageRoot, ".incoming"); Directory.CreateDirectory(_incomingRoot); } public async Task> ListAsync( bool publishedOnly, CancellationToken cancellationToken = default) { var packages = new List(); foreach (var metadataPath in Directory.EnumerateFiles(_storageRoot, MetadataFileName, SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); try { await using var stream = new FileStream(metadataPath, FileMode.Open, FileAccess.Read, FileShare.Read); var metadata = await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, cancellationToken); if (metadata is not null && (!publishedOnly || metadata.Published)) packages.Add(metadata); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { Console.WriteLine($"[WARN]忽略无法读取的工具包元数据 {metadataPath}:{exception.Message}"); } } return packages; } public async Task FindAsync( string toolId, string version, bool publishedOnly, CancellationToken cancellationToken = default) { var file = await FindFileAsync(toolId, version, publishedOnly, cancellationToken); return file?.Package; } public async Task FindFileAsync( string toolId, string version, bool publishedOnly, CancellationToken cancellationToken = default) { if (!IsValidToolId(toolId) || !SemanticVersionComparer.IsValid(version)) return null; var versionRoot = GetVersionRoot(toolId, version); var metadataPath = Path.Combine(versionRoot, MetadataFileName); var packagePath = Path.Combine(versionRoot, PackageFileName); if (!File.Exists(metadataPath) || !File.Exists(packagePath)) return null; try { await using var stream = new FileStream(metadataPath, FileMode.Open, FileAccess.Read, FileShare.Read); var metadata = await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, cancellationToken); if (metadata is null || publishedOnly && !metadata.Published) return null; return new StoredToolPackageFile { Package = metadata, FullPath = packagePath }; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { Console.WriteLine($"[WARN]读取工具包 {toolId} {version} 失败:{exception.Message}"); return null; } } public async Task SaveAsync( Stream packageStream, bool published, bool overwrite, CancellationToken cancellationToken = default) => await SaveCoreAsync( packageStream, published, overwrite, reviewStatus: published ? ToolPackageReviewStatus.Approved : ToolPackageReviewStatus.Pending, submittedByUserId: null, submittedByUserName: null, cancellationToken); public async Task SaveSubmissionAsync( Stream packageStream, string submittedByUserId, string submittedByUserName, CancellationToken cancellationToken = default) => await SaveCoreAsync( packageStream, published: false, overwrite: false, ToolPackageReviewStatus.Pending, submittedByUserId, submittedByUserName, cancellationToken); private async Task SaveCoreAsync( Stream packageStream, bool published, bool overwrite, ToolPackageReviewStatus reviewStatus, string? submittedByUserId, string? submittedByUserName, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(packageStream); var incomingPath = Path.Combine(_incomingRoot, $"{Guid.NewGuid():N}.xfetool"); try { var (packageSize, sha256) = await CopyIncomingPackageAsync(packageStream, incomingPath, cancellationToken); ToolPackageInspection inspection; await using (var inspectionStream = new FileStream( incomingPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { inspection = _validator.Inspect(inspectionStream); } var metadata = new StoredToolPackage { Manifest = inspection.Manifest, Sha256 = sha256, IconDataUrl = inspection.IconDataUrl, PackageSize = packageSize, UploadedAtUtc = DateTimeOffset.UtcNow, Published = published, ReviewStatus = reviewStatus, SubmittedByUserId = submittedByUserId, SubmittedByUserName = submittedByUserName }; await _writeLock.WaitAsync(cancellationToken); try { var versionRoot = GetVersionRoot(metadata.Manifest.Id, metadata.Manifest.Version); var packagePath = Path.Combine(versionRoot, PackageFileName); var metadataPath = Path.Combine(versionRoot, MetadataFileName); if (File.Exists(packagePath) || File.Exists(metadataPath)) throw CreateVersionConflictException(metadata); Directory.CreateDirectory(versionRoot); try { // 版本号是不可变的发布标识。即使旧客户端传入 overwrite=true, // 服务端也绝不允许覆盖已经发布过的同一版本。 File.Move(incomingPath, packagePath, overwrite: false); } catch (IOException) when (File.Exists(packagePath)) { throw CreateVersionConflictException(metadata); } await WriteMetadataAtomicallyAsync(metadataPath, metadata, cancellationToken); } finally { _writeLock.Release(); } return metadata; } finally { if (File.Exists(incomingPath)) File.Delete(incomingPath); } } private static ToolPackageConflictException CreateVersionConflictException(StoredToolPackage package) => new($"服务器已存在工具 {package.Manifest.Id} 的 {package.Manifest.Version} 版本,不允许覆盖发布。请修改 manifest.json 中的 version 后重试。"); public async Task SetPublishedAsync( string toolId, string version, bool published, CancellationToken cancellationToken = default) { await _writeLock.WaitAsync(cancellationToken); try { var current = await FindAsync(toolId, version, publishedOnly: false, cancellationToken) ?? throw new ToolPackageNotFoundException($"找不到工具 {toolId} 的 {version} 版本。"); var changed = CopyWithReview( current, published ? ToolPackageReviewStatus.Approved : ToolPackageReviewStatus.Rejected, published, current.ReviewedByUserId, current.ReviewedByUserName, current.ReviewMessage); await WriteMetadataAtomicallyAsync( Path.Combine(GetVersionRoot(toolId, version), MetadataFileName), changed, cancellationToken); return changed; } finally { _writeLock.Release(); } } public async Task SetReviewStatusAsync( string toolId, string version, ToolPackageReviewStatus reviewStatus, string reviewedByUserId, string reviewedByUserName, string? reviewMessage = null, CancellationToken cancellationToken = default) { if (reviewStatus == ToolPackageReviewStatus.Pending) throw new ArgumentOutOfRangeException(nameof(reviewStatus), "管理员审核结果只能是通过或拒绝。"); await _writeLock.WaitAsync(cancellationToken); try { var current = await FindAsync(toolId, version, publishedOnly: false, cancellationToken) ?? throw new ToolPackageNotFoundException($"找不到工具 {toolId} 的 {version} 版本。"); var changed = CopyWithReview( current, reviewStatus, reviewStatus == ToolPackageReviewStatus.Approved, reviewedByUserId, reviewedByUserName, reviewMessage); await WriteMetadataAtomicallyAsync( Path.Combine(GetVersionRoot(toolId, version), MetadataFileName), changed, cancellationToken); return changed; } finally { _writeLock.Release(); } } private static StoredToolPackage CopyWithReview( StoredToolPackage current, ToolPackageReviewStatus reviewStatus, bool published, string? reviewedByUserId, string? reviewedByUserName, string? reviewMessage) => new() { Manifest = current.Manifest, Sha256 = current.Sha256, IconDataUrl = current.IconDataUrl, PackageSize = current.PackageSize, UploadedAtUtc = current.UploadedAtUtc, Published = published, ReviewStatus = reviewStatus, SubmittedByUserId = current.SubmittedByUserId, SubmittedByUserName = current.SubmittedByUserName, ReviewedByUserId = reviewedByUserId, ReviewedByUserName = reviewedByUserName, ReviewedAtUtc = DateTimeOffset.UtcNow, ReviewMessage = reviewMessage }; private async Task<(long Size, string Sha256)> CopyIncomingPackageAsync( Stream source, string destinationPath, CancellationToken cancellationToken) { await using var destination = new FileStream( destinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); var buffer = new byte[81920]; long total = 0; int read; while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0) { total = checked(total + read); if (total > _validationOptions.MaxPackageBytes) throw new ToolPackageValidationException($"工具包超过 {_validationOptions.MaxPackageBytes} 字节的限制。"); await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken); hash.AppendData(buffer, 0, read); } await destination.FlushAsync(cancellationToken); return (total, Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant()); } private static async Task WriteMetadataAtomicallyAsync( string metadataPath, StoredToolPackage metadata, CancellationToken cancellationToken) { var temporaryPath = metadataPath + $".{Guid.NewGuid():N}.tmp"; try { await using (var stream = new FileStream( temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 16384, FileOptions.Asynchronous | FileOptions.WriteThrough)) { await JsonSerializer.SerializeAsync(stream, metadata, s_jsonOptions, cancellationToken); await stream.FlushAsync(cancellationToken); } File.Move(temporaryPath, metadataPath, overwrite: true); } finally { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } private string GetVersionRoot(string toolId, string version) => Path.Combine(_storageRoot, toolId, version); private static bool IsValidToolId(string value) => !string.IsNullOrWhiteSpace(value) && value.Length <= 64 && ToolIdRegex().IsMatch(value); [GeneratedRegex("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$")] private static partial Regex ToolIdRegex(); }