diff --git a/src/Server/Controllers/Api/v1/VersionController.cs b/src/Server/Controllers/Api/v1/VersionController.cs index a9d42c9..1a4ad2a 100644 --- a/src/Server/Controllers/Api/v1/VersionController.cs +++ b/src/Server/Controllers/Api/v1/VersionController.cs @@ -1,5 +1,8 @@ +using System.ComponentModel; +using Gommon; using Microsoft.AspNetCore.Mvc; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; using Ryujinx.Systems.Update.Server.Services.Forgejo; namespace Ryujinx.Systems.Update.Server.Controllers; @@ -25,25 +28,39 @@ public async Task> GetLatestStable( if (!arch.TryParseAsSupportedArchitecture(out var supportedArch)) return BadRequest($"Unknown architecture '{arch}'"); - if (await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is { } latest) + { + if (!Config.EnabledEndpoints.LatestQuery) + return Ok(new VersionResponse + { + Version = latest.Tag, + ArtifactUrl = "", + MaxConcurrency = Config.MaxConcurrentDownloads, + ReleaseUrlFormat = vcache.ReleaseUrlFormat + }); - if (!Config.EnabledEndpoints.LatestQuery) return Ok(new VersionResponse { Version = latest.Tag, - ArtifactUrl = "", + ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), MaxConcurrency = Config.MaxConcurrentDownloads, ReleaseUrlFormat = vcache.ReleaseUrlFormat }); - - return Ok(new VersionResponse - { - Version = latest.Tag, - ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), - MaxConcurrency = Config.MaxConcurrentDownloads, - ReleaseUrlFormat = vcache.ReleaseUrlFormat - }); + } + + return NotFound(); } [HttpGet($"{Constants.CanaryRoute}/{Constants.RouteName_Latest}")] @@ -62,26 +79,40 @@ public async Task> GetLatestCanary( if (!arch.TryParseAsSupportedArchitecture(out var supportedArch)) return BadRequest($"Unknown architecture '{arch}'"); + + Return result = await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } - if (await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)) is not { } latest) - return NotFound(); + if (result.IsSuccess && result.Unwrap() is { } latest) + { + if (!Config.EnabledEndpoints.LatestQuery) + return Ok(new VersionResponse + { + Version = latest.Tag, + ArtifactUrl = "", + MaxConcurrency = Config.MaxConcurrentDownloads, + ReleaseUrlFormat = vcache.ReleaseUrlFormat + }); - if (!Config.EnabledEndpoints.LatestQuery) return Ok(new VersionResponse { Version = latest.Tag, - ArtifactUrl = "", + ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), MaxConcurrency = Config.MaxConcurrentDownloads, ReleaseUrlFormat = vcache.ReleaseUrlFormat }); - - return Ok(new VersionResponse - { - Version = latest.Tag, - ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), - MaxConcurrency = Config.MaxConcurrentDownloads, - ReleaseUrlFormat = vcache.ReleaseUrlFormat - }); + } + + return NotFound(); } [HttpGet($"{Constants.StableRoute}/{{version}}")] @@ -110,7 +141,19 @@ public async Task> GetSpecificCanary( string version ) { - if (await vcache.GetReleaseAsync(c => c[version]) is { } cacheEntry) + Return result = await vcache.GetReleaseAsync(c => c[version]); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} cacheEntry) return Ok(cacheEntry); return NotFound(); diff --git a/src/Server/Controllers/DownloadController.cs b/src/Server/Controllers/DownloadController.cs index 2ca189d..6e6f3cf 100644 --- a/src/Server/Controllers/DownloadController.cs +++ b/src/Server/Controllers/DownloadController.cs @@ -1,5 +1,8 @@ +using System.ComponentModel; +using Gommon; using Microsoft.AspNetCore.Mvc; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; using Ryujinx.Systems.Update.Server.Services.Forgejo; namespace Ryujinx.Systems.Update.Server.Controllers; @@ -43,16 +46,26 @@ public async Task DownloadCustom( return BadRequest( $"Unknown release channel '{rc}'; valid are '{Constants.StableRoute}' and '{Constants.CanaryRoute}'"); - var release = await HttpContext.RequestServices + var result = await HttpContext.RequestServices .GetCacheFor(releaseChannel) .GetReleaseAsync(c => version is Constants.RouteName_Latest ? c.GetLatest(supportedPlatform, supportedArch) : c[version] ); - if (release is null) - return NotFound(); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} release) + return Redirect(release.GetUrlFor(supportedPlatform, supportedArch)); - return Redirect(release.GetUrlFor(supportedPlatform, supportedArch)); + return NotFound(); } [HttpGet] @@ -69,10 +82,22 @@ [FromServices] ILogger logger return Problem("This instance of Ryubing UpdateServer is not configured to support this endpoint.", statusCode: 418); - if (await vcache.GetReleaseAsync(c => c.Latest) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.Latest); - return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) + return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + + return NotFound(); } [HttpGet(Constants.CanaryRoute)] @@ -88,10 +113,22 @@ [FromServices] ILogger logger return Problem("This instance of Ryubing UpdateServer is not configured to support this endpoint.", statusCode: 418); - if (await vcache.GetReleaseAsync(c => c.Latest) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.Latest); - return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) + return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + + return NotFound(); } private ActionResult RedirectOrProblem(VersionCacheEntry cacheEntry, ILogger logger, diff --git a/src/Server/Controllers/LatestController.cs b/src/Server/Controllers/LatestController.cs index e8cc2b6..e6cfb5f 100644 --- a/src/Server/Controllers/LatestController.cs +++ b/src/Server/Controllers/LatestController.cs @@ -1,5 +1,8 @@ -using Microsoft.AspNetCore.Mvc; +using System.ComponentModel; +using Gommon; +using Microsoft.AspNetCore.Mvc; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; using Ryujinx.Systems.Update.Server.Services.Forgejo; namespace Ryujinx.Systems.Update.Server.Controllers; @@ -40,37 +43,64 @@ public async Task> GetLatestCustom( var vcache = HttpContext.RequestServices.GetCacheFor(releaseChannel); - if (await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)); - if (!Config.EnabledEndpoints.LatestQuery) + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is { } latest) + { + if (!Config.EnabledEndpoints.LatestQuery) + return Ok(new VersionResponse + { + Version = latest.Tag, + ArtifactUrl = "", + MaxConcurrency = Config.MaxConcurrentDownloads, + ReleaseUrlFormat = vcache.ReleaseUrlFormat + }); + return Ok(new VersionResponse { Version = latest.Tag, - ArtifactUrl = "", + ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), MaxConcurrency = Config.MaxConcurrentDownloads, ReleaseUrlFormat = vcache.ReleaseUrlFormat }); - - return Ok(new VersionResponse - { - Version = latest.Tag, - ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), - MaxConcurrency = Config.MaxConcurrentDownloads, - ReleaseUrlFormat = vcache.ReleaseUrlFormat - }); + } + + return NotFound(); } [HttpGet(Constants.StableRoute), HttpGet] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] [EndpointDescription("Redirect to the Forgejo release URL of the latest Stable Ryubing release.")] public async Task RedirectLatestStable( [FromKeyedServices("stableCache")] ForgejoVersionCache vcache) { - if (await vcache.GetReleaseAsync(c => c.Latest) is { } latest) - return Redirect(latest.ReleaseUrl); + Return result = await vcache.GetReleaseAsync(c => c.Latest); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) + return Redirect(latest.ReleaseUrl); + return NotFound(); } @@ -81,7 +111,19 @@ public async Task RedirectLatestStable( public async Task RedirectLatestCanary( [FromKeyedServices("canaryCache")] ForgejoVersionCache vcache) { - if (await vcache.GetReleaseAsync(c => c.Latest) is { } latest) + Return result = await vcache.GetReleaseAsync(c => c.Latest); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) return Redirect(latest.ReleaseUrl); return NotFound(); diff --git a/src/Server/Helpers/Results/ReleaseError.cs b/src/Server/Helpers/Results/ReleaseError.cs new file mode 100644 index 0000000..ed55424 --- /dev/null +++ b/src/Server/Helpers/Results/ReleaseError.cs @@ -0,0 +1,16 @@ +using Gommon; + +namespace Ryujinx.Systems.Update.Server.Helpers.Results; + +public readonly struct ReleaseError(ReleaseError.ReleaseErrorType error) : IErrorState +{ + public ReleaseErrorType Error { get; } = error; + + public override string ToString() => Error.ToString(); + + public enum ReleaseErrorType + { + NotInitialized, + RateLimited, + } +} \ No newline at end of file diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 93c458e..80f3465 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -3,6 +3,7 @@ using ForgejoApiClient.Api; using Gommon; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; namespace Ryujinx.Systems.Update.Server.Services.Forgejo; @@ -11,6 +12,9 @@ public class ForgejoVersionCache : SafeDictionary, IV private readonly ForgejoService _fj; private readonly ILogger _logger; private readonly PeriodicTimer? _refreshTimer; + private readonly PeriodicTimer? _downloadIntervalTimer; + private readonly int _downloadLimit; + private int _downloadAttempts; private Repository? _cachedProject; @@ -42,9 +46,41 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, _logger = logger; _forgejoEndpoint = config["Forgejo:Endpoint"]!; + + _downloadIntervalTimer = new(TimeSpan.FromMinutes(1)); + + if (config["Forgejo:DownloadLimitPerInterval"] is not { } downloadLimitStr) + { + logger.LogWarning( + "Config value 'Forgejo:DownloadLimitPerInterval' is missing. Defaulting 100 downloads per interval."); + _downloadLimit = 100; + } + else + { + if (int.TryParse(downloadLimitStr, out var downloadLimit)) + { + if (downloadLimit < 0) + { + logger.LogInformation( + "Config value 'Forgejo:DownloadLimitPerInterval' is a negative value. Disabling download limit."); + _downloadLimit = 0; + _downloadIntervalTimer = null; + } + else + _downloadLimit = downloadLimit; + } + else + { + logger.LogWarning( + "Config value 'Forgejo:DownloadLimitPerInterval' is not a valid integer. Defaulting 100 downloads per interval."); + _downloadLimit = 100; + } + } if (config["Forgejo:RefreshIntervalMinutes"] is not { } refreshIntervalStr) { + logger.LogWarning( + "Config value 'Forgejo:RefreshIntervalSeconds' is missing. Defaulting to 5 minutes."); _refreshTimer = new(TimeSpan.FromMinutes(5)); return; } @@ -63,14 +99,15 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, else { logger.LogWarning( - "Config value 'Forgejo:RefreshIntervalSeconds' was not a valid integer. Defaulting to 5 minutes."); + "Config value 'Forgejo:RefreshIntervalSeconds' is not a valid integer. Defaulting to 5 minutes."); _refreshTimer = new(TimeSpan.FromMinutes(5)); } } public string ReleaseUrlFormat => $"{_forgejoEndpoint.TrimEnd('/')}/{ProjectPath}/releases/tag/{{0}}"; - public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersions pinnedVersions) => + public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersions pinnedVersions) + { Executor.ExecuteBackgroundAsync(async () => { _deriveLatestManually = deriveLatestVersionManually; @@ -113,15 +150,29 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi await RefreshAsync(); } }); + + Executor.ExecuteBackgroundAsync(async () => + { + if (_downloadIntervalTimer is null) + { + return; + } + + while (await _downloadIntervalTimer.WaitForNextTickAsync()) + { + ResetDownloadLimit(); + } + }); + } public Task TakeLockAsync() => _semaphore.TakeAsync(); public VersionCacheEntry? Latest => this[_latestTag ?? string.Empty]; - public VersionCacheEntry? GetLatest(SupportedPlatform platform, SupportedArchitecture arch) + public Return GetLatest(SupportedPlatform platform, SupportedArchitecture arch) { if (!HasProjectInfo) - return null; + return Return.Failure(new ReleaseError(ReleaseError.ReleaseErrorType.NotInitialized)); if (_pinnedVersions.Find(platform, arch) is { } pinnedVersion && TryGetValue(pinnedVersion, out var pinnedLatest)) @@ -130,14 +181,28 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi return Latest; } - public async Task GetReleaseAsync(Func getter) + public async Task> GetReleaseAsync(Func> getter) { using (await TakeLockAsync()) { - return getter(this); + if (_downloadAttempts++ < _downloadLimit || _downloadIntervalTimer is null) + { + return getter(this); + } + + return Return.Failure(new ReleaseError(ReleaseError.ReleaseErrorType.RateLimited)); } } + private void ResetDownloadLimit() + { + int attempts = _downloadAttempts; + + _downloadAttempts = 0; + + _logger.LogInformation("Download attempt count reset for {project}. {attempts} download attempts in the last interval. Download limit for interval is: {limit}", ProjectName, attempts, _downloadLimit); + } + public async Task RefreshAsync() { _logger.LogInformation("Reloading version cache for {project}", ProjectName);