using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Server;
///
/// A caching mirror of the desktop app's Velopack feed, so the workstations of one shop check
/// for updates against their nest server instead of each calling GitHub. One refresh makes a
/// single GitHub API request (the release list), reads the feeds attached to the chosen releases
/// and downloads each channel's newest full package once, verified against the feed's size and
/// SHA-256, into .
///
///
/// Channels: stable serves the newest full release; dev serves whichever is higher
/// of that and the newest pre-release with a feed (the rolling development build). A channel is
/// mirrored only while clients ask for it (within ), so a server whose
/// workstations never take development builds never downloads one. Feeds are served with the
/// package file names Velopack wrote, unchanged: clients download {feed URL}/{FileName}.
///
public sealed class UpdateMirror
{
public const string Stable = "stable";
public const string Dev = "dev";
public const string FeedFileName = "releases.win.json";
public const string DefaultRepository = "ajisaacs/OpenNest";
/// How long a channel stays mirrored after the last client asked for it.
public static readonly TimeSpan WantedFor = TimeSpan.FromDays(7);
private static readonly string[] Channels = { Stable, Dev };
private static readonly JsonSerializerOptions FeedOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
private readonly string _repository;
private readonly Func _clock;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private readonly object _stateLock = new();
private readonly Dictionary _feeds = new(StringComparer.Ordinal);
private readonly Dictionary _wanted = new(StringComparer.Ordinal);
private DateTimeOffset? _lastChecked;
private string? _lastError;
public UpdateMirror(string directoryPath, HttpClient http, string repository = DefaultRepository, Func? clock = null)
{
DirectoryPath = directoryPath;
_http = http;
_repository = repository;
_clock = clock ?? (() => DateTimeOffset.UtcNow);
Directory.CreateDirectory(PackagesPath);
LoadSavedFeeds();
}
/// Raised when a client asks for a channel that holds no feed yet, so the next refresh need not wait.
public event Action? RefreshRequested;
public string DirectoryPath { get; }
private string PackagesPath => Path.Combine(DirectoryPath, "packages");
public static bool IsChannel(string channel) => Array.IndexOf(Channels, channel) >= 0;
///
/// The channel's feed for a client, or null while nothing is mirrored for it (the route answers
/// 503 and the client falls back to GitHub). Every call marks the channel as wanted.
///
public byte[]? GetFeed(string channel)
{
bool missing;
byte[]? feed;
lock (_stateLock)
{
_wanted[channel] = _clock();
feed = _feeds.TryGetValue(channel, out var current) ? current.Json : null;
missing = feed is null;
}
if (missing)
RefreshRequested?.Invoke();
return feed;
}
/// The path of a package a channel's feed currently names, or null for any other file name.
public string? GetPackagePath(string channel, string fileName)
{
lock (_stateLock)
{
if (!_feeds.TryGetValue(channel, out var feed) || feed.FileName != fileName)
return null;
}
var path = Path.Combine(PackagesPath, fileName);
return File.Exists(path) ? path : null;
}
public UpdateMirrorStatus GetStatus()
{
lock (_stateLock)
{
return new UpdateMirrorStatus
{
Stable = _feeds.TryGetValue(Stable, out var stable) ? stable.Version : null,
Dev = _feeds.TryGetValue(Dev, out var dev) ? dev.Version : null,
LastChecked = _lastChecked,
LastError = _lastError,
};
}
}
///
/// Brings every wanted channel up to date with GitHub. Failures are recorded in
/// and leave the feeds already mirrored in place.
///
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
var wanted = WantedChannels();
if (wanted.Count > 0)
await RefreshChannelsAsync(wanted, cancellationToken);
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = null;
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = ex.Message;
}
}
finally
{
_refreshLock.Release();
}
}
private List WantedChannels()
{
var now = _clock();
lock (_stateLock)
return Channels.Where(c => _wanted.TryGetValue(c, out var at) && now - at <= WantedFor).ToList();
}
private async Task RefreshChannelsAsync(List channels, CancellationToken cancellationToken)
{
var releases = await GetJsonAsync>(
$"https://api.github.com/repos/{_repository}/releases?per_page=10", cancellationToken) ?? new();
var published = releases.Where(r => !r.Draft && FeedAsset(r) is not null).ToList();
// The API lists the newest releases first.
var stable = published.FirstOrDefault(r => !r.Prerelease);
var preRelease = published.FirstOrDefault(r => r.Prerelease);
var feeds = new Dictionary();
foreach (var channel in channels)
{
var sources = channel == Stable ? new[] { stable } : new[] { stable, preRelease };
Candidate? best = null;
foreach (var release in sources.OfType())
{
if (!feeds.TryGetValue(release, out var candidate))
feeds[release] = candidate = await ReadCandidateAsync(release, cancellationToken);
if (candidate is not null && (best is null || SemanticVersion.Compare(candidate.Asset.Version, best.Asset.Version) > 0))
best = candidate;
}
if (best is not null)
await PublishAsync(channel, best, cancellationToken);
}
DeleteUnreferencedPackages();
}
private async Task ReadCandidateAsync(GitHubRelease release, CancellationToken cancellationToken)
{
var feed = await GetJsonAsync(FeedAsset(release)!.DownloadUrl, cancellationToken);
var full = feed?.Assets?
.Where(a => a.Type == "Full" && IsPlainFileName(a.FileName) && SemanticVersion.IsValid(a.Version))
.OrderByDescending(a => a.Version, SemanticVersion.Comparer)
.FirstOrDefault();
var package = full is null ? null : release.Assets.FirstOrDefault(a => a.Name == full.FileName);
return package is null ? null : new Candidate(full!, package.DownloadUrl);
}
private async Task PublishAsync(string channel, Candidate candidate, CancellationToken cancellationToken)
{
var asset = candidate.Asset;
var packagePath = Path.Combine(PackagesPath, asset.FileName);
if (!File.Exists(packagePath))
await DownloadPackageAsync(candidate, packagePath, cancellationToken);
// Serve only the chosen package, as the feed Velopack wrote describes it.
var json = JsonSerializer.SerializeToUtf8Bytes(new { Assets = new[] { asset } });
var feedPath = Path.Combine(DirectoryPath, channel, FeedFileName);
Directory.CreateDirectory(Path.GetDirectoryName(feedPath)!);
WriteAtomically(feedPath, json);
lock (_stateLock)
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
}
private async Task DownloadPackageAsync(Candidate candidate, string packagePath, CancellationToken cancellationToken)
{
var partial = packagePath + ".partial";
try
{
using (var response = await _http.GetAsync(candidate.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var target = File.Create(partial);
await source.CopyToAsync(target, cancellationToken);
}
var asset = candidate.Asset;
var length = new FileInfo(partial).Length;
if (asset.Size > 0 && length != asset.Size)
throw new InvalidDataException($"{asset.FileName} is {length} bytes; its feed says {asset.Size}.");
if (!string.IsNullOrEmpty(asset.SHA256))
{
await using var stream = File.OpenRead(partial);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken));
if (!hash.Equals(asset.SHA256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"{asset.FileName} does not match its feed's SHA-256.");
}
File.Move(partial, packagePath, overwrite: true);
}
finally
{
if (File.Exists(partial))
File.Delete(partial);
}
}
private void DeleteUnreferencedPackages()
{
HashSet keep;
lock (_stateLock)
keep = _feeds.Values.Select(f => f.FileName).ToHashSet(StringComparer.Ordinal);
foreach (var path in Directory.EnumerateFiles(PackagesPath))
{
if (keep.Contains(Path.GetFileName(path)))
continue;
try { File.Delete(path); }
catch (IOException) { } // Still being served; the next refresh retries.
catch (UnauthorizedAccessException) { }
}
}
private void LoadSavedFeeds()
{
foreach (var channel in Channels)
{
var path = Path.Combine(DirectoryPath, channel, FeedFileName);
if (!File.Exists(path))
continue;
try
{
var json = File.ReadAllBytes(path);
var asset = JsonSerializer.Deserialize(json, FeedOptions)?.Assets?.SingleOrDefault();
if (asset is null || !IsPlainFileName(asset.FileName) || !File.Exists(Path.Combine(PackagesPath, asset.FileName)))
continue;
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
// A restart keeps serving what clients were already taking.
_wanted[channel] = _clock();
}
catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException)
{
// An unreadable feed is rebuilt by the next refresh.
}
}
}
private async Task GetJsonAsync(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("OpenNest-Server");
if (url.StartsWith("https://api.github.com/", StringComparison.Ordinal))
request.Headers.Accept.ParseAdd("application/vnd.github+json");
using var response = await _http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonSerializer.DeserializeAsync(stream, FeedOptions, cancellationToken);
}
private static GitHubAsset? FeedAsset(GitHubRelease release) =>
release.Assets.FirstOrDefault(a => a.Name == FeedFileName);
// Feed file names become paths under the packages directory and URL segments.
private static bool IsPlainFileName(string? name) =>
!string.IsNullOrEmpty(name) && name == Path.GetFileName(name) && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
&& name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase);
private static void WriteAtomically(string path, byte[] contents)
{
var temporary = path + ".tmp";
File.WriteAllBytes(temporary, contents);
File.Move(temporary, path, overwrite: true);
}
private sealed record ChannelFeed(byte[] Json, string FileName, string Version);
private sealed record Candidate(VelopackFeedAsset Asset, string DownloadUrl);
private sealed class GitHubRelease
{
[JsonPropertyName("draft")] public bool Draft { get; set; }
[JsonPropertyName("prerelease")] public bool Prerelease { get; set; }
[JsonPropertyName("assets")] public List Assets { get; set; } = new();
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")] public string DownloadUrl { get; set; } = "";
}
private sealed class VelopackFeed
{
public List? Assets { get; set; }
}
}
/// One entry of a Velopack releases.{channel}.json; properties keep Velopack's names.
public sealed class VelopackFeedAsset
{
public string PackageId { get; set; } = "";
public string Version { get; set; } = "";
public string Type { get; set; } = "";
public string FileName { get; set; } = "";
public string? SHA1 { get; set; }
public string? SHA256 { get; set; }
public long Size { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesMarkdown { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesHTML { get; set; }
}
/// GET /api/updates: the versions each channel serves and the last refresh's outcome.
public sealed class UpdateMirrorStatus
{
public string? Stable { get; init; }
public string? Dev { get; init; }
public DateTimeOffset? LastChecked { get; init; }
public string? LastError { get; init; }
}
/// SemVer 2.0 precedence for feed versions such as 0.7.0-dev.2020; build metadata is ignored.
public static class SemanticVersion
{
public static readonly IComparer Comparer = Comparer.Create(Compare);
public static bool IsValid(string? version) => version is not null && TryParse(version, out _, out _);
public static int Compare(string? left, string? right)
{
if (!TryParse(left, out var leftCore, out var leftPre))
return TryParse(right, out _, out _) ? -1 : 0;
if (!TryParse(right, out var rightCore, out var rightPre))
return 1;
for (var i = 0; i < 3; i++)
{
var core = leftCore[i].CompareTo(rightCore[i]);
if (core != 0)
return core;
}
// A release outranks its pre-releases.
if (leftPre.Length == 0 || rightPre.Length == 0)
return (leftPre.Length == 0).CompareTo(rightPre.Length == 0);
for (var i = 0; i < System.Math.Min(leftPre.Length, rightPre.Length); i++)
{
var leftNumeric = long.TryParse(leftPre[i], out var leftNumber);
var rightNumeric = long.TryParse(rightPre[i], out var rightNumber);
var part = (leftNumeric, rightNumeric) switch
{
(true, true) => leftNumber.CompareTo(rightNumber),
(true, false) => -1,
(false, true) => 1,
_ => string.CompareOrdinal(leftPre[i], rightPre[i]),
};
if (part != 0)
return part;
}
return leftPre.Length.CompareTo(rightPre.Length);
}
private static bool TryParse(string? version, out long[] core, out string[] preRelease)
{
core = Array.Empty();
preRelease = Array.Empty();
if (string.IsNullOrEmpty(version))
return false;
var withoutMetadata = version.Split('+')[0];
var dash = withoutMetadata.IndexOf('-');
var numbers = (dash < 0 ? withoutMetadata : withoutMetadata[..dash]).Split('.');
if (numbers.Length != 3)
return false;
core = new long[3];
for (var i = 0; i < 3; i++)
{
if (!long.TryParse(numbers[i], out core[i]) || core[i] < 0)
return false;
}
preRelease = dash < 0 ? Array.Empty() : withoutMetadata[(dash + 1)..].Split('.');
return preRelease.All(p => p.Length > 0);
}
}