mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
f0e4679887
Players sign in to the website, and admins to the admin panel, with an OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the provider identity (issuer + subject) to a DorkNet account with that account's password; after that the provider sign-in is enough. - Cookie sessions per host, keys persisted in the database so redeploys keep people signed in; unlink, deletion or a ban ends the session. - Link attempts limited to 5 failures per 15 minutes per identity and per username. - Admin panel: "Sign in with ..." exchanges the session for the usual admin tokens via GET /api/admin/v1/sso (IsAdmin still required). - Downloads: optional DorkNet:Downloads:RequireSignIn serves the manifest and files only to signed-in players; the Downloads and Quest pages offer sign-in instead. - Website: sign-in in the header, /link and /account pages. The game client's login is unchanged.
77 lines
4.0 KiB
C#
77 lines
4.0 KiB
C#
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using DorkNet.Server.Services;
|
|
|
|
namespace DorkNet.Server.Controllers.Site;
|
|
|
|
/// <summary>
|
|
/// The client builds CI publishes, for the site's Downloads page and the
|
|
/// Quest page's "latest build" install. Files are streamed from the downloads
|
|
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
|
|
/// installer can read an .xapk's zip directory and entries without fetching
|
|
/// the whole file. Only files the manifest lists are served.
|
|
///
|
|
/// The builds contain Rec Room's own game files, so the routes answer only on
|
|
/// the site's own host (the apex) and 404 on every other host the server
|
|
/// accepts: the game's API/CDN subdomains are typically left open for the
|
|
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
|
|
/// </summary>
|
|
[ApiController]
|
|
[AllowAnonymous]
|
|
[Route("api/site/v1/downloads")]
|
|
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
|
|
{
|
|
private bool Available => downloads.IsConfigured
|
|
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
|
|
/// signed-in, linked player may read the builds.</summary>
|
|
private async Task<bool> SignedInOrOpenAsync() =>
|
|
!downloads.RequireSignIn
|
|
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
|
|
|
|
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
|
/// (snake_case keys), or 404 when downloads aren't set up or the request
|
|
/// isn't for the site's host.</summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetManifest(CancellationToken ct)
|
|
{
|
|
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
|
|
if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" });
|
|
var manifest = await downloads.GetManifestAsync(ct);
|
|
Response.Headers.CacheControl = "no-cache";
|
|
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
|
|
.ToJsonString(), "application/json");
|
|
}
|
|
|
|
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
|
|
/// streamed; honours Range (206).</summary>
|
|
[HttpGet("files/{**path}")]
|
|
public async Task GetFile(string path, CancellationToken ct)
|
|
{
|
|
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
|
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
|
|
var manifest = await downloads.GetManifestAsync(ct);
|
|
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
|
|
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
|
|
|
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
|
|
Response.StatusCode = (int)upstream.StatusCode;
|
|
if (!upstream.IsSuccessStatusCode) return;
|
|
|
|
var content = upstream.Content.Headers;
|
|
if (content.ContentLength is long length) Response.ContentLength = length;
|
|
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
|
|
Response.Headers.AcceptRanges = "bytes";
|
|
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
|
|
Response.Headers.CacheControl = "private, no-cache";
|
|
Response.ContentType = "application/octet-stream";
|
|
var name = (string?)entry["name"] ?? Path.GetFileName(path);
|
|
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
|
|
|
|
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
|
|
await body.CopyToAsync(Response.Body, 1 << 16, ct);
|
|
}
|
|
}
|