using System.Net.Sockets; using MeWeb.Core.Knowledge; using MeWeb.Shared; namespace MeWeb.Server; public static class ServerApi { public static WebApplication MapMeWebApi(this WebApplication app) { app.MapGet(Endpoints.Server.GET_Info, Info); app.MapGet(Endpoints.Server.GET_Ip, Ip); app.MapGet(Endpoints.Downloads.GET_Page, Page); app.MapGet(Endpoints.Downloads.GET_Privacy, Privacy); app.MapGet(Endpoints.Downloads.GET_Platform, Download); app.MapPost(Endpoints.Probe.POST_Ticket, Ticket); app.MapPost(Endpoints.Probe.POST_Run, Run); app.MapPost(Endpoints.Share.POST_Create, Share); app.MapDelete(Endpoints.Share.DELETE_One, Unshare); app.MapGet(Endpoints.Speed.GET_Down, Down); app.MapPost(Endpoints.Speed.POST_Up, Up); app.MapGet(Endpoints.Knowledge.GET_Manifest, KnowledgeManifest); app.MapGet(Endpoints.Knowledge.GET_File, KnowledgeFile); return app; } static IResult KnowledgeManifest(KnowledgeFeeder feeder) => feeder.Manifest() is { } manifest ? Results.Json(manifest, MeWebJsonContext.Default.ViewKnowledge) : Results.NotFound(); static IResult KnowledgeFile(string name, KnowledgeFeeder feeder) { if (!KnowledgeFeeder.Published.Contains(name)) return Results.NotFound(); var path = Path.Combine(feeder.Folder, "current", name); if (!File.Exists(path)) return Results.NotFound(); var type = Path.GetExtension(name) switch { ".json" => "application/json", ".txt" => "text/plain; charset=utf-8", _ => "application/octet-stream", }; return Results.File(path, type, name); } static async Task Down(int? bytes, HttpContext context, SpeedService speed, CancellationToken ct) { var caller = Caller(context); if (caller is null) return Results.BadRequest(); if (!speed.Allow(caller)) return Refused(context, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = 3600 }, StatusCodes.Status429TooManyRequests); if (!speed.Enter()) return Refused(context, new ViewRefusal { Reason = "busy", RetryAfterSeconds = 60 }, StatusCodes.Status429TooManyRequests); try { var size = Math.Clamp(bytes ?? 1024 * 1024, 1024, Endpoints.Speed.MaxDownBytes); await SpeedService.Stream(context.Response, size, ct); return Results.Empty; } finally { speed.Leave(); } } static async Task Up(HttpContext context, CancellationToken ct) { var received = await SpeedService.Drain(context.Request.Body, ct); return received < 0 ? Results.StatusCode(StatusCodes.Status413PayloadTooLarge) : Results.NoContent(); } public const string DeleteTokenHeader = "X-Delete-Token"; static IResult Ticket(HttpContext context, ProbeService probe) { var caller = Caller(context); if (caller is null) return Results.BadRequest(); var (ticket, refusal, status) = probe.Issue(caller); return ticket is not null ? Results.Json(ticket, MeWebJsonContext.Default.ViewProbeTicket) : Refused(context, refusal, status); } static async Task Run(string id, ProbeForm form, HttpContext context, ProbeService probe, CancellationToken ct) { var caller = Caller(context); if (caller is null) return Results.BadRequest(); var (result, refusal, status) = await probe.Run(id, caller, form, ct); return result is not null ? Results.Json(result, MeWebJsonContext.Default.ViewProbe) : Refused(context, refusal, status); } static async Task Share(HttpContext context, ShareService shares, CancellationToken ct) { var caller = Caller(context); if (caller is null) return Results.BadRequest(); using var buffer = new MemoryStream(); var chunk = new byte[8192]; int read; while ((read = await context.Request.Body.ReadAsync(chunk, ct)) > 0) { buffer.Write(chunk, 0, read); if (buffer.Length > ShareService.MaxBytes) return Refused(context, new ViewRefusal { Reason = "size" }, StatusCodes.Status413PayloadTooLarge); } var (shared, refusal, status) = shares.Create(caller, buffer.ToArray()); return shared is not null ? Results.Json(shared, MeWebJsonContext.Default.ViewShared) : Refused(context, refusal, status); } static IResult Unshare(string id, HttpContext context, ShareService shares) => Results.StatusCode(shares.Delete(id, context.Request.Headers[DeleteTokenHeader].ToString())); static IResult Refused(HttpContext context, ViewRefusal refusal, int status) { if (refusal.RetryAfterSeconds > 0) context.Response.Headers.RetryAfter = refusal.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); return Results.Json(refusal, MeWebJsonContext.Default.ViewRefusal, statusCode: status); } static System.Net.IPAddress Caller(HttpContext context) { var address = context.Connection.RemoteIpAddress; return address is null ? null : address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; } static ViewInfo Info() => new() { Version = BuildInfo.Version, Commit = BuildInfo.Commit, BuiltAt = BuildInfo.BuiltAt }; static IResult Ip(HttpContext context, KnowledgeFeeder knowledge) { var address = context.Connection.RemoteIpAddress; if (address is null) return Results.BadRequest(); if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); var family = address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4"; var block = knowledge.BlockFor(address); return Results.Ok(new ViewIp { Address = address.ToString(), Family = family, Block = string.IsNullOrEmpty(block?.Name) ? null : block.Name, BlockCountry = block?.Country, BlockStatus = block is { Status: not BlockStatus.Unknown } ? block.Status.ToString() : null, }); } static async Task Page(HttpContext context, ReleaseFeed feed, CancellationToken ct) { var language = Shell.Language(context); var release = await feed.Latest(ct); context.Response.Headers.CacheControl = "no-cache"; context.Response.Headers.ContentLanguage = language; context.Response.Headers.Vary = "Accept-Language"; return Results.Content(DownloadPage.Render(release, language), "text/html; charset=utf-8"); } static IResult Privacy(HttpContext context) { var language = Shell.Language(context); context.Response.Headers.ContentLanguage = language; context.Response.Headers.Vary = "Accept-Language"; return Results.Content(PrivacyPage.Render(language), "text/html; charset=utf-8"); } static async Task Download(string platform, ReleaseFeed feed, CancellationToken ct) { if (!ReleaseFeed.Platforms.Any(p => p.Platform == platform)) return Results.NotFound(); var asset = ReleaseFeed.Asset(await feed.Latest(ct), platform); return asset?.Url is null ? Results.Redirect(Endpoints.Gitea.Releases) : Results.Redirect(asset.Url); } }