208 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using System.Security.Cryptography;
using YD_Event.Application.Auth;
using YD_Event.Application.Common;
namespace YD_Event.Application.AppUpdate;
/// <summary>内部 Android 裁判端 APK 发布与升级检查。</summary>
[ApiDescriptionSettings("裁判端升级")]
[Route("api/app-updates")]
[Authorize]
public class AppUpdateController : IDynamicApiController
{
private const string PackageName = "com.yd.event.yd_pattern_scoring_system";
private const long MaxApkSize = 200L * 1024 * 1024;
private readonly ISqlSugarRepository<JudgeAppRelease> _releases;
private readonly AuthManager _auth;
private readonly IIdGenerator _ids;
private readonly FileService _files;
private readonly string _channel;
public AppUpdateController(ISqlSugarRepository<JudgeAppRelease> releases,
AuthManager auth, IIdGenerator ids, FileService files)
{
_releases = releases;
_auth = auth;
_ids = ids;
_files = files;
_channel = Environment.GetEnvironmentVariable("APP_UPDATE_CHANNEL")?.Trim().ToLowerInvariant() ?? "production";
}
/// <summary>登录前即可查询当前环境的最新 APK。</summary>
[HttpGet("latest")]
[AllowAnonymous]
public async Task<object> Latest()
{
var release = await _releases.AsQueryable()
.Where(x => x.Channel == _channel && x.ReleaseStatus == 1 && x.PackageName == PackageName)
.OrderByDescending(x => x.VersionCode).FirstAsync();
return release == null ? new { available = false } : new
{
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.PublishedTime
};
}
/// <summary>只允许下载当前环境已发布的版本,文件由现有对象存储分发。</summary>
[HttpGet("download/{id:long}")]
[AllowAnonymous]
public async Task<IActionResult> Download(long id)
{
var release = await _releases.GetFirstAsync(x => x.Id == id && x.Channel == _channel &&
x.PackageName == PackageName && x.ReleaseStatus == 1);
if (release == null || string.IsNullOrWhiteSpace(release.FileUrl)) return new NotFoundResult();
return new RedirectResult(release.FileUrl);
}
[HttpGet("admin/releases")]
public async Task<object> Releases()
{
EnsureAdmin();
return await _releases.AsQueryable().Where(x => x.Channel == _channel)
.OrderByDescending(x => x.VersionCode).ToListAsync();
}
[HttpPost("admin/upload")]
[DisplayName("上传裁判端 APK 草稿")]
[RequestSizeLimit(MaxApkSize + 1024 * 1024)]
[RequestFormLimits(MultipartBodyLengthLimit = MaxApkSize + 1024 * 1024)]
public async Task<JudgeAppRelease> Upload([FromForm] IFormFile file,
[FromForm] string notesZh, [FromForm] string? notesEn = null,
[FromForm] bool isMandatory = false)
{
EnsureAdmin();
if (file == null || file.Length < 1024 || file.Length > MaxApkSize ||
!string.Equals(Path.GetExtension(file.FileName), ".apk", StringComparison.OrdinalIgnoreCase))
throw Oops.Bah("仅允许上传 1KB 至 200MB 的 APK");
if (string.IsNullOrWhiteSpace(notesZh)) throw Oops.Bah("请填写中文更新说明");
var temp = Path.Combine(Path.GetTempPath(), $"yd-app-release-{Guid.NewGuid():N}.apk");
try
{
await using (var output = System.IO.File.Create(temp))
await using (var source = file.OpenReadStream())
await source.CopyToAsync(output);
await using (var input = System.IO.File.OpenRead(temp))
{
var magic = new byte[4];
if (await input.ReadAsync(magic) != 4 || magic[0] != 0x50 || magic[1] != 0x4B || magic[2] != 0x03 || magic[3] != 0x04)
throw Oops.Bah("文件不是有效的 APK/ZIP");
}
var metadata = await InspectApk(temp);
if (metadata.PackageName != PackageName || metadata.VersionCode <= 0)
throw Oops.Bah("APK 包名或版本号不正确");
var expected = Environment.GetEnvironmentVariable("APP_RELEASE_CERT_SHA256")?.Trim().ToUpperInvariant();
if (expected == null || !Regex.IsMatch(expected, "^[0-9A-F]{64}$"))
throw Oops.Bah("服务器未配置正式签名指纹,禁止发布");
if (metadata.CertificateSha256 != expected) throw Oops.Bah("APK 签名与正式签名不一致");
var existing = await _releases.GetFirstAsync(x => x.Channel == _channel && x.PackageName == PackageName && x.VersionCode == metadata.VersionCode);
if (existing != null) throw Oops.Bah("该版本号已存在,请使用新的递增版本号");
await using var hashStream = System.IO.File.OpenRead(temp);
var sha = Convert.ToHexString(await SHA256.HashDataAsync(hashStream));
await using var uploadStream = System.IO.File.OpenRead(temp);
var release = new JudgeAppRelease
{
Id = _ids.NextId(), Channel = _channel, PackageName = PackageName,
VersionCode = metadata.VersionCode, VersionName = metadata.VersionName,
NotesZh = notesZh?.Trim() ?? string.Empty, NotesEn = notesEn?.Trim() ?? string.Empty,
IsMandatory = isMandatory, FileSize = uploadStream.Length,
Sha256 = sha, CertificateSha256 = metadata.CertificateSha256,
FileUrl = await _files.UploadFileStreamAsync($"app-releases/{_channel}/{Guid.NewGuid():N}.apk", uploadStream),
ReleaseStatus = 0, CreatedUserId = _auth.UserId, CreatedTime = DateTime.UtcNow
};
await _releases.InsertAsync(release);
return release;
}
finally { if (System.IO.File.Exists(temp)) System.IO.File.Delete(temp); }
}
[HttpPost("admin/{id:long}/publish")]
public async Task<JudgeAppRelease> Publish(long id)
{
EnsureAdmin();
var release = await FindOwn(id);
if (release.ReleaseStatus != 0) throw Oops.Bah("只有草稿可以发布");
if (_channel == "production" &&
(!Uri.TryCreate(release.FileUrl, UriKind.Absolute, out var fileUri) || fileUri.Scheme != Uri.UriSchemeHttps))
throw Oops.Bah("正式环境的 APK 下载地址必须为 HTTPS");
var latest = await _releases.AsQueryable()
.Where(x => x.Channel == _channel && x.PackageName == PackageName && x.ReleaseStatus != 0)
.OrderByDescending(x => x.VersionCode).FirstAsync();
if (latest != null && release.VersionCode <= latest.VersionCode)
throw Oops.Bah("版本号必须高于当前已发布版本");
release.ReleaseStatus = 1;
release.PublishedUserId = _auth.UserId;
release.PublishedTime = DateTime.UtcNow;
await _releases.UpdateAsync(release);
return release;
}
[HttpPost("admin/{id:long}/withdraw")]
public async Task<JudgeAppRelease> Withdraw(long id)
{
EnsureAdmin();
var release = await FindOwn(id);
if (release.ReleaseStatus != 1) throw Oops.Bah("只有已发布版本可以撤回");
release.ReleaseStatus = 2;
release.WithdrawnUserId = _auth.UserId;
release.WithdrawnTime = DateTime.UtcNow;
await _releases.UpdateAsync(release);
return release;
}
private void EnsureAdmin()
{
if (!_auth.IsSuperAdmin) throw Oops.Bah("仅超级管理员可管理裁判端版本");
}
private async Task<JudgeAppRelease> FindOwn(long id)
{
var release = await _releases.GetFirstAsync(x => x.Id == id && x.Channel == _channel);
return release ?? throw Oops.Bah("版本不存在");
}
private static async Task<(string PackageName, int VersionCode, string VersionName, string CertificateSha256)> InspectApk(string path)
{
var directory = Environment.GetEnvironmentVariable("ANDROID_BUILD_TOOLS_DIR");
if (string.IsNullOrWhiteSpace(directory)) throw Oops.Bah("服务器未配置 Android build-tools,禁止盲目发布");
var aapt = Path.Combine(directory, OperatingSystem.IsWindows() ? "aapt.exe" : "aapt");
var signer = Path.Combine(directory, OperatingSystem.IsWindows() ? "apksigner.bat" : "apksigner");
var badging = await RunTool(aapt, "dump", "badging", path);
var match = Regex.Match(badging, "package: name='([^']+)' versionCode='([0-9]+)' versionName='([^']*)'");
if (!match.Success) throw Oops.Bah("无法读取 APK 包名及版本");
var result = await RunTool(signer, "verify", "--print-certs", path);
var digest = Regex.Match(result, "certificate SHA-256 digest: ([0-9a-fA-F]{64})");
if (!digest.Success) throw Oops.Bah("无法验证 APK 签名");
return (match.Groups[1].Value, int.Parse(match.Groups[2].Value), match.Groups[3].Value, digest.Groups[1].Value.ToUpperInvariant());
}
private static async Task<string> RunTool(string executable, params string[] arguments)
{
if (!System.IO.File.Exists(executable)) throw Oops.Bah("Android build-tools 不完整");
ProcessStartInfo start;
if (OperatingSystem.IsWindows() && executable.EndsWith(".bat", StringComparison.OrdinalIgnoreCase))
{
start = new ProcessStartInfo("cmd.exe") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
start.ArgumentList.Add("/d");
start.ArgumentList.Add("/c");
start.ArgumentList.Add("\"" + executable + "\" " + string.Join(" ", arguments.Select(x => "\"" + x.Replace("\"", "") + "\"")));
}
else
{
start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
foreach (var argument in arguments) start.ArgumentList.Add(argument);
}
using var process = Process.Start(start) ?? throw Oops.Bah("无法启动 APK 检查工具");
var output = process.StandardOutput.ReadToEndAsync();
var error = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try { await process.WaitForExitAsync(timeout.Token); }
catch (OperationCanceledException) { process.Kill(entireProcessTree: true); throw Oops.Bah("APK 检查超时"); }
if (process.ExitCode != 0) throw Oops.Bah("APK 检查失败:" + (await error).Trim());
return await output;
}
}