2026-10-15 01:14:44 +02:00
|
|
|
using System.Net.Sockets;
|
|
|
|
|
|
2026-10-30 22:32:49 +01:00
|
|
|
using MeWeb.Core.Knowledge;
|
2026-10-15 01:14:44 +02:00
|
|
|
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);
|
2026-10-15 22:49:40 +02:00
|
|
|
app.MapGet(Endpoints.Downloads.GET_Page, Page);
|
|
|
|
|
app.MapGet(Endpoints.Downloads.GET_Privacy, Privacy);
|
|
|
|
|
app.MapGet(Endpoints.Downloads.GET_Platform, Download);
|
2026-10-15 05:41:44 +02:00
|
|
|
app.MapPost(Endpoints.Probe.POST_Ticket, Ticket);
|
|
|
|
|
app.MapPost(Endpoints.Probe.POST_Run, Run);
|
2026-10-15 22:49:40 +02:00
|
|
|
app.MapPost(Endpoints.Share.POST_Create, Share);
|
|
|
|
|
app.MapDelete(Endpoints.Share.DELETE_One, Unshare);
|
2026-10-15 05:41:44 +02:00
|
|
|
app.MapGet(Endpoints.Speed.GET_Down, Down);
|
|
|
|
|
app.MapPost(Endpoints.Speed.POST_Up, Up);
|
2026-10-15 22:49:40 +02:00
|
|
|
app.MapGet(Endpoints.Knowledge.GET_Manifest, KnowledgeManifest);
|
|
|
|
|
app.MapGet(Endpoints.Knowledge.GET_File, KnowledgeFile);
|
2026-10-15 01:14:44 +02:00
|
|
|
return app;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 11:11:24 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 05:41:44 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 01:14:44 +02:00
|
|
|
static ViewInfo Info() => new() { Version = BuildInfo.Version, Commit = BuildInfo.Commit, BuiltAt = BuildInfo.BuiltAt };
|
|
|
|
|
|
2026-10-16 18:10:19 +02:00
|
|
|
static IResult Ip(HttpContext context, KnowledgeFeeder knowledge)
|
2026-10-15 01:14:44 +02:00
|
|
|
{
|
|
|
|
|
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";
|
2026-10-16 18:10:19 +02:00
|
|
|
var block = knowledge.BlockFor(address);
|
2026-10-30 22:32:49 +01:00
|
|
|
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,
|
|
|
|
|
});
|
2026-10-15 01:14:44 +02:00
|
|
|
}
|
2026-10-15 04:39:03 +02:00
|
|
|
|
|
|
|
|
static async Task<IResult> Page(HttpContext context, ReleaseFeed feed, CancellationToken ct)
|
|
|
|
|
{
|
2026-10-15 05:41:44 +02:00
|
|
|
var language = Shell.Language(context);
|
2026-10-15 04:39:03 +02:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 05:41:44 +02:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 04:39:03 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-10-15 01:14:44 +02:00
|
|
|
}
|