XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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<IReadOnlyList<StoredToolPackage>> ListAsync(
        bool publishedOnly,
        CancellationToken cancellationToken = default)
    {
        var packages = new List<StoredToolPackage>();
        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<StoredToolPackage>(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<StoredToolPackage?> FindAsync(
        string toolId,
        string version,
        bool publishedOnly,
        CancellationToken cancellationToken = default)
    {
        var file = await FindFileAsync(toolId, version, publishedOnly, cancellationToken);
        return file?.Package;
    }

    public async Task<StoredToolPackageFile?> 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<StoredToolPackage>(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<StoredToolPackage> 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<StoredToolPackage> 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<StoredToolPackage> 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<StoredToolPackage> 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<StoredToolPackage> 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();
}