Files
MeWeb/src/MeWeb.Server/ServerApi.cs
T
thepraandClaude Opus 5.5 d408edea98
Build / Desktop, server and tools (push) Successful in 1m8s
House rules: the build checks them, and every refusal from the server answers 400, 403 or 404
The server no longer answers 409, 413 or 429: a busy probe, a share or upload that is too large and a request over
the limit now answer 400 with the same reason as before, and still say when to try again. The app reads the reason,
so nothing changes for it; versions up to 0.15.4 report a limited speed test as unmeasured until they update.
The app's commands can now be cancelled all the way down, and the house rules are written into PATTERNS.md and
checked on every build, Android included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
2026-11-10 05:32:04 +01:00

163 lines
6.7 KiB
C#

using System.Net.Sockets;
using System.Text.Json.Serialization.Metadata;
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<IResult> 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.Status400BadRequest);
if (!speed.Enter()) return Refused(context, new ViewRefusal { Reason = "busy", RetryAfterSeconds = 60 }, StatusCodes.Status400BadRequest);
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<IResult> Up(HttpContext context, CancellationToken ct)
{
var received = await SpeedService.Drain(context.Request.Body, ct);
return received < 0 ? Refused(context, new ViewRefusal { Reason = "size" }, StatusCodes.Status400BadRequest) : 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();
return Answer(context, probe.Issue(caller), MeWebJsonContext.Default.ViewProbeTicket);
}
static async Task<IResult> Run(string id, ProbeForm form, HttpContext context, ProbeService probe, CancellationToken ct)
{
var caller = Caller(context);
if (caller is null) return Results.BadRequest();
return Answer(context, await probe.Run(id, caller, form, ct), MeWebJsonContext.Default.ViewProbe);
}
static async Task<IResult> 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];
var read = default(int);
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.Status400BadRequest);
}
return Answer(context, shares.Create(caller, buffer.ToArray()), MeWebJsonContext.Default.ViewShared);
}
static IResult Unshare(string id, HttpContext context, ShareService shares) =>
Results.StatusCode(shares.Delete(id, context.Request.Headers[DeleteTokenHeader].ToString()));
static IResult Answer<T>(HttpContext context, WebResult<T> result, JsonTypeInfo<T> type) where T : class =>
result.IsInvalid ? Refused(context, result.Refusal, result.StatusCode) : Results.Json(result.Data, type);
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<IResult> 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<IResult> 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);
}
}