changed.修改了PLONDS上传逻辑

This commit is contained in:
lincube
2026-06-01 16:53:23 +08:00
parent a2ac302ee7
commit 131043fe37
17 changed files with 1370 additions and 593 deletions

View File

@@ -0,0 +1,10 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsPublishOptions(
string ReleaseTag,
string Repository,
string ManifestPath,
string ChangedZipPath,
string WorkDir,
string S3KeyPrefix,
PlondsS3ClientOptions S3);

View File

@@ -0,0 +1,13 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsPublishResult(
string ReleaseTag,
string Version,
string VersionPrefix,
string ManifestKey,
string ManifestUrl,
string ChangedZipKey,
string ChangedZipUrl,
string ChangedFolderKey,
string ChangedFolderUrl,
int ChangedFileCount);

View File

@@ -0,0 +1,141 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsPublisher
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public async Task<PlondsPublishResult> PublishAsync(PlondsPublishOptions options, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
var releaseTag = Require(options.ReleaseTag, nameof(options.ReleaseTag));
var repository = Require(options.Repository, nameof(options.Repository));
var manifestPath = Path.GetFullPath(Require(options.ManifestPath, nameof(options.ManifestPath)));
var changedZipPath = Path.GetFullPath(Require(options.ChangedZipPath, nameof(options.ChangedZipPath)));
var workDir = Path.GetFullPath(Require(options.WorkDir, nameof(options.WorkDir)));
var version = releaseTag.TrimStart('v', 'V');
var prefix = NormalizePrefix(options.S3KeyPrefix);
var versionPrefix = $"{prefix}/{version}";
var changedFolderName = $"{version}-changed";
var changedExtractRoot = Path.Combine(workDir, changedFolderName);
if (!File.Exists(manifestPath))
{
throw new FileNotFoundException("PLONDS manifest not found.", manifestPath);
}
if (!File.Exists(changedZipPath))
{
throw new FileNotFoundException("PLONDS changed.zip not found.", changedZipPath);
}
var manifest = LoadManifest(manifestPath);
PayloadUtilities.EnsureCleanDirectory(changedExtractRoot);
ZipFile.ExtractToDirectory(changedZipPath, changedExtractRoot, overwriteFiles: true);
var manifestKey = $"{versionPrefix}/PLONDS.json";
var changedZipKey = $"{versionPrefix}/changed.zip";
var changedFolderKey = $"{versionPrefix}/{changedFolderName}";
using var s3 = new PlondsS3Client(options.S3);
var changedFileCount = 0;
foreach (var filePath in Directory.EnumerateFiles(changedExtractRoot, "*", SearchOption.AllDirectories).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var relativePath = PayloadUtilities.NormalizeRelativePath(Path.GetRelativePath(changedExtractRoot, filePath));
var objectKey = $"{changedFolderKey}/{relativePath}";
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filePath, objectKey, ResolveContentType(filePath)), cancellationToken).ConfigureAwait(false);
changedFileCount++;
}
await s3.UploadFileAsync(new PlondsS3ObjectUpload(changedZipPath, changedZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
var updatedManifest = manifest with
{
Downloads = new PlondsDownloadInfo(
ReleaseTag: releaseTag,
GitHub: new PlondsGitHubDownloadInfo(
ReleaseUrl: $"https://github.com/{repository}/releases/tag/{releaseTag}",
ManifestUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/PLONDS.json",
ChangedZipUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/changed.zip"),
S3: new PlondsS3DownloadInfo(
Bucket: options.S3.Bucket,
Prefix: versionPrefix,
ManifestKey: manifestKey,
ManifestUrl: s3.BuildPublicUrl(manifestKey),
ChangedZipKey: changedZipKey,
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
ChangedFolderKey: changedFolderKey,
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey)))
};
File.WriteAllText(manifestPath, JsonSerializer.Serialize(updatedManifest, JsonOptions), new UTF8Encoding(false));
await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, manifestKey, "application/json"), cancellationToken).ConfigureAwait(false);
await s3.EnsureObjectExistsAsync(manifestKey, cancellationToken).ConfigureAwait(false);
await s3.EnsureObjectExistsAsync(changedZipKey, cancellationToken).ConfigureAwait(false);
return new PlondsPublishResult(
ReleaseTag: releaseTag,
Version: version,
VersionPrefix: versionPrefix,
ManifestKey: manifestKey,
ManifestUrl: s3.BuildPublicUrl(manifestKey),
ChangedZipKey: changedZipKey,
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
ChangedFolderKey: changedFolderKey,
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey),
ChangedFileCount: changedFileCount);
}
private static PlondsManifest LoadManifest(string manifestPath)
{
var json = File.ReadAllText(manifestPath);
return JsonSerializer.Deserialize<PlondsManifest>(json, JsonOptions)
?? throw new InvalidOperationException("PLONDS manifest is empty or invalid.");
}
private static string NormalizePrefix(string value)
{
var normalized = Require(value, nameof(value)).Replace('\\', '/').Trim('/');
if (normalized.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException($"Invalid S3 key prefix: {value}", nameof(value));
}
return normalized;
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path).ToLowerInvariant() switch
{
".json" => "application/json",
".zip" => "application/zip",
".dll" => "application/octet-stream",
".exe" => "application/octet-stream",
".pdb" => "application/octet-stream",
".deps" => "application/json",
".runtimeconfig" => "application/json",
".txt" => "text/plain",
".xml" => "application/xml",
_ => "application/octet-stream"
};
}
private static string Require(string value, string name)
{
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException($"{name} is required.", name)
: value.Trim();
}
}

View File

@@ -0,0 +1,260 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
namespace Plonds.Core.Publishing;
public sealed class PlondsS3Client : IDisposable
{
private const string ServiceName = "s3";
private const string EmptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
private readonly PlondsS3ClientOptions options;
private readonly HttpClient httpClient;
private readonly bool ownsHttpClient;
public PlondsS3Client(PlondsS3ClientOptions options, HttpClient? httpClient = null)
{
ArgumentNullException.ThrowIfNull(options);
this.options = options with
{
Endpoint = NormalizeEndpoint(options.Endpoint),
Region = Require(options.Region, nameof(options.Region)),
Bucket = Require(options.Bucket, nameof(options.Bucket)),
AccessKey = Require(options.AccessKey, nameof(options.AccessKey)),
SecretKey = Require(options.SecretKey, nameof(options.SecretKey)),
PublicBaseUrl = Require(options.PublicBaseUrl, nameof(options.PublicBaseUrl)).TrimEnd('/'),
PublicBaseKeyPrefix = NormalizeOptionalKeyPrefix(options.PublicBaseKeyPrefix)
};
this.httpClient = httpClient ?? new HttpClient();
ownsHttpClient = httpClient is null;
}
public async Task UploadFileAsync(PlondsS3ObjectUpload upload, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(upload);
var sourcePath = Path.GetFullPath(upload.SourcePath);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("S3 upload source file not found.", sourcePath);
}
var key = NormalizeKey(upload.Key);
var payloadHash = PayloadUtilities.ComputeSha256(sourcePath);
var contentLength = new FileInfo(sourcePath).Length;
var now = DateTimeOffset.UtcNow;
var requestUri = BuildObjectUri(key);
using var content = new StreamContent(File.OpenRead(sourcePath));
content.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(upload.ContentType)
? "application/octet-stream"
: upload.ContentType);
content.Headers.ContentLength = contentLength;
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
{
Content = content
};
SignRequest(request, key, payloadHash, now);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"S3 upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
}
}
public async Task EnsureObjectExistsAsync(string key, CancellationToken cancellationToken = default)
{
var normalizedKey = NormalizeKey(key);
var now = DateTimeOffset.UtcNow;
var requestUri = BuildObjectUri(normalizedKey);
using var request = new HttpRequestMessage(HttpMethod.Head, requestUri);
SignRequest(request, normalizedKey, EmptyPayloadHash, now);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"S3 object verification failed for {normalizedKey}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
}
}
public string BuildPublicUrl(string key)
{
var normalizedKey = NormalizeKey(key);
if (!string.IsNullOrWhiteSpace(options.PublicBaseKeyPrefix) &&
(string.Equals(normalizedKey, options.PublicBaseKeyPrefix, StringComparison.OrdinalIgnoreCase) ||
normalizedKey.StartsWith($"{options.PublicBaseKeyPrefix}/", StringComparison.OrdinalIgnoreCase)))
{
normalizedKey = normalizedKey[options.PublicBaseKeyPrefix.Length..].TrimStart('/');
}
return $"{options.PublicBaseUrl}/{normalizedKey}";
}
public void Dispose()
{
if (ownsHttpClient)
{
httpClient.Dispose();
}
}
private void SignRequest(HttpRequestMessage request, string key, string payloadHash, DateTimeOffset now)
{
var amzDate = now.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
var dateStamp = now.UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
var credentialScope = $"{dateStamp}/{options.Region}/{ServiceName}/aws4_request";
var canonicalUri = BuildCanonicalUri(key);
var host = request.RequestUri?.IsDefaultPort == true
? request.RequestUri.Host
: request.RequestUri?.Authority;
if (string.IsNullOrWhiteSpace(host))
{
throw new InvalidOperationException("Cannot sign an S3 request without a host.");
}
request.Headers.Host = host;
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", payloadHash);
var canonicalHeaders = new StringBuilder();
canonicalHeaders.Append("host:").Append(host).Append('\n');
canonicalHeaders.Append("x-amz-content-sha256:").Append(payloadHash).Append('\n');
canonicalHeaders.Append("x-amz-date:").Append(amzDate).Append('\n');
var signedHeaders = "host;x-amz-content-sha256;x-amz-date";
var canonicalRequest = string.Join('\n',
[
request.Method.Method,
canonicalUri,
string.Empty,
canonicalHeaders.ToString(),
signedHeaders,
payloadHash
]);
var stringToSign = string.Join('\n',
[
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
Sha256Hex(canonicalRequest)
]);
var signingKey = GetSignatureKey(options.SecretKey, dateStamp, options.Region, ServiceName);
var signature = HmacSha256Hex(signingKey, stringToSign);
var authorization = $"AWS4-HMAC-SHA256 Credential={options.AccessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
request.Headers.TryAddWithoutValidation("Authorization", authorization);
}
private Uri BuildObjectUri(string key)
{
var bucketPrefix = Uri.EscapeDataString(options.Bucket).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
var path = $"{options.Endpoint.AbsolutePath.TrimEnd('/')}/{bucketPrefix}/{BuildCanonicalKey(key)}";
var builder = new UriBuilder(options.Endpoint)
{
Path = path
};
return builder.Uri;
}
private string BuildCanonicalUri(string key)
{
var bucketPrefix = Uri.EscapeDataString(options.Bucket).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
return $"{options.Endpoint.AbsolutePath.TrimEnd('/')}/{bucketPrefix}/{BuildCanonicalKey(key)}";
}
private static string BuildCanonicalKey(string key)
{
return string.Join("/", NormalizeKey(key)
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Select(Uri.EscapeDataString));
}
private static string NormalizeKey(string value)
{
var normalized = value.Replace('\\', '/').Trim('/');
if (string.IsNullOrWhiteSpace(normalized) || normalized.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException($"Invalid S3 object key: {value}", nameof(value));
}
return normalized;
}
private static string NormalizeOptionalKeyPrefix(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return NormalizeKey(value);
}
private static Uri NormalizeEndpoint(Uri endpoint)
{
if (!endpoint.IsAbsoluteUri)
{
throw new ArgumentException("S3 endpoint must be an absolute URI.", nameof(endpoint));
}
var builder = new UriBuilder(endpoint)
{
Path = endpoint.AbsolutePath.TrimEnd('/')
};
return builder.Uri;
}
private static string Require(string value, string name)
{
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException($"{name} is required.", name)
: value.Trim();
}
private static string Sha256Hex(string value)
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
private static byte[] HmacSha256(byte[] key, string data)
{
return HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
}
private static string HmacSha256Hex(byte[] key, string data)
{
return Convert.ToHexString(HmacSha256(key, data)).ToLowerInvariant();
}
private static byte[] GetSignatureKey(string key, string dateStamp, string regionName, string serviceName)
{
var kDate = HmacSha256(Encoding.UTF8.GetBytes($"AWS4{key}"), dateStamp);
var kRegion = HmacSha256(kDate, regionName);
var kService = HmacSha256(kRegion, serviceName);
return HmacSha256(kService, "aws4_request");
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
}

View File

@@ -0,0 +1,10 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsS3ClientOptions(
Uri Endpoint,
string Region,
string Bucket,
string AccessKey,
string SecretKey,
string PublicBaseUrl,
string PublicBaseKeyPrefix = "");

View File

@@ -0,0 +1,6 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsS3ObjectUpload(
string SourcePath,
string Key,
string ContentType);