krona-staff/apps/KronaStaffTerminal/Services/UpdateService.cs
Ochenstarik 827d321a1a
Some checks failed
ci / verify (push) Has been cancelled
feat(terminal): add secure release updates and installer
2026-09-19 02:08:26 +07:00

261 lines
9.7 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.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services;
public sealed record AvailableUpdate(
Version Version,
string DisplayVersion,
string ReleaseName,
string ReleaseNotes,
Uri ReleasePage,
Uri InstallerUrl,
Uri ChecksumUrl,
string InstallerFileName);
public interface IUpdateSource
{
Task<AvailableUpdate?> FindUpdateAsync(TerminalSettings settings, CancellationToken cancellationToken);
Task<string> DownloadAndVerifyAsync(
AvailableUpdate update,
TerminalSettings settings,
IProgress<double>? progress,
CancellationToken cancellationToken);
}
public sealed class ForgejoUpdateSource : IUpdateSource
{
private const string InstallerPrefix = "KronaStaffTerminal-Setup-";
private static readonly HttpClient HttpClient = CreateHttpClient();
public async Task<AvailableUpdate?> FindUpdateAsync(
TerminalSettings settings,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
feedUri.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException(
"Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
}
using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken);
using var response = await HttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (response.StatusCode is HttpStatusCode.Unauthorized or
HttpStatusCode.Forbidden or
HttpStatusCode.NotFound)
{
throw new InvalidOperationException(
"Нет доступа к обновлениям. Укажите токен чтения приватного репозитория в настройках.");
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var release = await JsonSerializer.DeserializeAsync<ForgejoRelease>(
stream,
cancellationToken: cancellationToken)
?? throw new InvalidDataException("Forgejo вернул пустое описание релиза.");
if (!TryParseVersion(release.TagName, out var releaseVersion) ||
releaseVersion <= CurrentVersion)
{
return null;
}
var installer = release.Assets.FirstOrDefault(asset =>
asset.Name.StartsWith(InstallerPrefix, StringComparison.OrdinalIgnoreCase) &&
asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
var checksum = release.Assets.FirstOrDefault(asset =>
string.Equals(asset.Name, installer?.Name + ".sha256", StringComparison.OrdinalIgnoreCase));
if (installer is null ||
checksum is null ||
!Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) ||
!Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) ||
!Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage))
{
throw new InvalidDataException(
"В релизе нет установщика Windows или файла контрольной суммы.");
}
return new AvailableUpdate(
releaseVersion,
releaseVersion.ToString(3),
string.IsNullOrWhiteSpace(release.Name)
? $"Krona Staff {releaseVersion.ToString(3)}"
: release.Name,
release.Body ?? "",
releasePage,
installerUri,
checksumUri,
installer.Name);
}
public async Task<string> DownloadAndVerifyAsync(
AvailableUpdate update,
TerminalSettings settings,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var updateDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
"StaffTerminal",
"updates",
update.DisplayVersion);
Directory.CreateDirectory(updateDirectory);
var installerPath = Path.Combine(
updateDirectory,
Path.GetFileName(update.InstallerFileName));
var temporaryPath = installerPath + ".download";
using var checksumRequest = CreateRequest(
HttpMethod.Get,
update.ChecksumUrl,
settings.UpdateAccessToken);
using var checksumResponse = await HttpClient.SendAsync(
checksumRequest,
cancellationToken);
checksumResponse.EnsureSuccessStatusCode();
var checksumText = await checksumResponse.Content.ReadAsStringAsync(cancellationToken);
var expectedHash = checksumText
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
if (expectedHash is null ||
expectedHash.Length != 64 ||
expectedHash.Any(character => !Uri.IsHexDigit(character)))
{
throw new InvalidDataException(
"Файл контрольной суммы обновления повреждён.");
}
using var installerRequest = CreateRequest(
HttpMethod.Get,
update.InstallerUrl,
settings.UpdateAccessToken);
using var installerResponse = await HttpClient.SendAsync(
installerRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
installerResponse.EnsureSuccessStatusCode();
var totalBytes = installerResponse.Content.Headers.ContentLength;
await using (var source = await installerResponse.Content.ReadAsStreamAsync(cancellationToken))
await using (var destination = new FileStream(
temporaryPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
81920,
FileOptions.Asynchronous))
{
var buffer = new byte[81920];
long received = 0;
int count;
while ((count = await source.ReadAsync(buffer, cancellationToken)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
received += count;
if (totalBytes is > 0)
{
progress?.Report((double)received / totalBytes.Value);
}
}
await destination.FlushAsync(cancellationToken);
}
await using var downloaded = File.OpenRead(temporaryPath);
var actualHash = Convert.ToHexString(
await SHA256.HashDataAsync(downloaded, cancellationToken));
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expectedHash),
Convert.FromHexString(actualHash)))
{
File.Delete(temporaryPath);
throw new InvalidDataException(
"Контрольная сумма обновления не совпала. Установка отменена.");
}
File.Move(temporaryPath, installerPath, true);
return installerPath;
}
public static Version CurrentVersion =>
Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
public static void LaunchInstaller(string installerPath) =>
Process.Start(new ProcessStartInfo
{
FileName = installerPath,
UseShellExecute = true
});
private static HttpRequestMessage CreateRequest(
HttpMethod method,
Uri uri,
string token)
{
var request = new HttpRequestMessage(method, uri);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("token", token.Trim());
}
return request;
}
private static HttpClient CreateHttpClient()
{
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2");
return client;
}
private static bool TryParseVersion(string? value, out Version version)
{
var normalized = (value ?? "")
.Trim()
.TrimStart('v', 'V')
.Split('-', '+')[0];
if (Version.TryParse(normalized, out var parsed))
{
version = parsed;
return true;
}
version = new Version(0, 0, 0);
return false;
}
private sealed class ForgejoRelease
{
[JsonPropertyName("tag_name")]
public string TagName { get; set; } = "";
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = "";
[JsonPropertyName("assets")]
public List<ForgejoAsset> Assets { get; set; } = [];
}
private sealed class ForgejoAsset
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = "";
}
}