diff --git a/YD_Event.Application/AppUpdate/AppUpdateController.cs b/YD_Event.Application/AppUpdate/AppUpdateController.cs index 0619932..6b8d8b7 100644 --- a/YD_Event.Application/AppUpdate/AppUpdateController.cs +++ b/YD_Event.Application/AppUpdate/AppUpdateController.cs @@ -1,6 +1,8 @@ using System.Security.Cryptography; +using Microsoft.Extensions.Options; using YD_Event.Application.Auth; using YD_Event.Application.Common; +using YD_Event.Core.Options; namespace YD_Event.Application.AppUpdate; @@ -16,15 +18,18 @@ public class AppUpdateController : IDynamicApiController private readonly AuthManager _auth; private readonly IIdGenerator _ids; private readonly FileService _files; + private readonly IOptionsMonitor _ossOptions; private readonly string _channel; public AppUpdateController(ISqlSugarRepository releases, - AuthManager auth, IIdGenerator ids, FileService files) + AuthManager auth, IIdGenerator ids, FileService files, + IOptionsMonitor ossOptions) { _releases = releases; _auth = auth; _ids = ids; _files = files; + _ossOptions = ossOptions; _channel = Environment.GetEnvironmentVariable("APP_UPDATE_CHANNEL")?.Trim().ToLowerInvariant() ?? "production"; } @@ -41,11 +46,43 @@ public class AppUpdateController : IDynamicApiController available = true, release.Id, release.PackageName, release.VersionCode, release.VersionName, release.NotesZh, release.NotesEn, release.IsMandatory, release.FileSize, release.Sha256, - release.CertificateSha256, downloadUrl = $"/api/app-updates/download/{release.Id}", + release.CertificateSha256, downloadUrl = DownloadUrlFor(release), release.PublishedTime }; } + /// OSS 已启用时直连阿里云/CDN;配置缺失或不安全时仍使用受控下载接口。 + private string DownloadUrlFor(JudgeAppRelease release) + { + var fallback = $"/api/app-updates/download/{release.Id}"; + // Direct delivery is opt-in: a private bucket without CDN/public-read + // must continue using the existing server-side OSS stream. + var cdn = Environment.GetEnvironmentVariable("APP_UPDATE_DIRECT_DOWNLOAD_BASE_URL"); + if (string.IsNullOrWhiteSpace(cdn)) return fallback; + var oss = _ossOptions.CurrentValue; + if (!oss.Enable || string.IsNullOrWhiteSpace(oss.Domain) || + !Uri.TryCreate(oss.Domain.TrimEnd('/') + "/", UriKind.Absolute, out var ossBase) || + ossBase.Scheme != Uri.UriSchemeHttps || + !Uri.TryCreate(release.FileUrl, UriKind.Absolute, out var fileUri) || + fileUri.Scheme != Uri.UriSchemeHttps || + !fileUri.AbsoluteUri.StartsWith(ossBase.AbsoluteUri, StringComparison.OrdinalIgnoreCase)) + return fallback; + + // FileUrl comes from the configured OSS domain at upload time. Never turn + // an arbitrary stored URL into an untrusted redirect/download host. + var objectKey = fileUri.AbsoluteUri[ossBase.AbsoluteUri.Length..]; + if (!objectKey.StartsWith($"app-releases/{_channel}/", StringComparison.Ordinal) || + objectKey.Contains("..", StringComparison.Ordinal) || + !objectKey.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)) + return fallback; + + if (!Uri.TryCreate(cdn.TrimEnd('/') + "/", UriKind.Absolute, out var cdnBase) || + cdnBase.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(cdnBase.UserInfo) || + !string.IsNullOrEmpty(cdnBase.Query) || !string.IsNullOrEmpty(cdnBase.Fragment)) + return fallback; + return new Uri(cdnBase, objectKey).AbsoluteUri; + } + /// 只允许下载当前环境已发布的版本,沿用现有文件服务流式返回。 [HttpGet("download/{id:long}")] [AllowAnonymous]