Phases two and three ship together, because their code grew in the same files. With consent the app asks the router to open 80, 443 and a high port for ten minutes (UPnP, NAT-PMP or PCP), and the server, holding a single-use ticket bound to the caller's own address, connects back and must get an HMAC only the app can give; the mappings are removed after the run and remembered until then, so a crash cannot leave one open. Hairpin is tried from inside. Two STUN servers, the outside one first, tell easy from hard NAT. Mail gets port 25 to Gmail and Outlook, the reverse name and five blocklists through the user's own resolver, network-wide UCEPROTECT listings counted as a caution. DNS, speed against the server (four at a time, twelve an hour per address) and the device follow, and six uses (website, home from outside, files, game server, fediverse, mail) close the run as possible, possible with a named workaround, or can't tell. Findings can be shared after reading the exact JSON: a file per submission under the unit's state directory, ten a day per address, deletable by the token the app keeps, swept after twelve months; /privacy says all of it in English and Italian. QR codes lead to the downloads. Every language carries every string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
134 lines
5.6 KiB
C#
134 lines
5.6 KiB
C#
using System.Net.Sockets;
|
|
|
|
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);
|
|
return app;
|
|
}
|
|
|
|
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.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<IResult> 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<IResult> 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<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];
|
|
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)
|
|
{
|
|
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";
|
|
return Results.Ok(new ViewIp { Address = address.ToString(), Family = family });
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|