From d408edea9817376297b1e01e7d3e92b0c8b4c9d8 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 10 Nov 2026 05:32:04 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd --- AGENT.md | 4 +- Directory.Build.props | 3 + Directory.Packages.props | 3 + PATTERNS.md | 54 ++++++++++++++++-- nuget.config | 16 ++++++ src/MeWeb.Android/AndroidPlatform.cs | 2 +- src/MeWeb.Android/MainActivity.cs | 8 +-- src/MeWeb.App/ViewModels/MainViewModel.cs | 55 ++++++------------- .../ViewModels/SubmissionViewModel.cs | 4 +- src/MeWeb.App/Views/MainView.axaml.cs | 2 +- src/MeWeb.Core/Checks/Reach.cs | 34 ++++++------ src/MeWeb.Core/Checks/ServerProbe.cs | 27 +++++---- src/MeWeb.Core/Checks/Verdicts.cs | 8 +-- src/MeWeb.Core/Knowledge/RegistryTable.cs | 13 ++--- src/MeWeb.Core/Knowledge/Sources.cs | 10 ++-- src/MeWeb.Core/Net/Answerer.cs | 16 +++--- src/MeWeb.Core/Net/LocalNetwork.cs | 2 +- src/MeWeb.Core/Net/SpeedCheck.cs | 9 +-- src/MeWeb.Core/Router/Gateway.cs | 2 +- src/MeWeb.Core/Router/Leftovers.cs | 2 +- src/MeWeb.Server/DownloadPage.cs | 2 +- src/MeWeb.Server/ObservedNotes.cs | 2 +- src/MeWeb.Server/ProbeService.cs | 32 ++++++----- src/MeWeb.Server/ServerApi.cs | 23 ++++---- src/MeWeb.Server/ShareService.cs | 12 ++-- src/MeWeb.Server/SpeedService.cs | 2 +- src/MeWeb.Shared/NumbersExtensions.cs | 10 ++++ src/MeWeb.Shared/WebResult.cs | 23 ++++++++ 28 files changed, 235 insertions(+), 145 deletions(-) create mode 100644 nuget.config create mode 100644 src/MeWeb.Shared/NumbersExtensions.cs create mode 100644 src/MeWeb.Shared/WebResult.cs diff --git a/AGENT.md b/AGENT.md index 956c017..0dbe11d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -2,7 +2,7 @@ Read this first. **`PATTERNS.md` is canonical for how code is written** (cite "PATTERNS #n"); **`CLAUDE.md` is the quick reference** (commands, map, pitfalls). When a tool in the repo enforces a rule (the compiler, `.editorconfig`, -`tools/lint.cs`), the tool is the source of truth. +the house-rules analyzer), the tool is the source of truth. ## What MeWeb is @@ -19,7 +19,7 @@ Free software under AGPL-3.0, published by Arasaka di Hvozdikov Yevheniy. - **No comments in source** — none, of any kind (#42). - **No `private`**, no `_` prefixes, **`Nullable` disabled**, **2-space indent**, **`var` everywhere** (#92). - **Primary constructors**, `IOptionsMonitor`, `CancellationToken` last and forwarded (#1, #87, #88). -- **Routes only in `src/MeWeb.Shared/Endpoints.cs`** (#9); DTOs are classes named `View*`, `Upsert*`, `*Form` (#34). +- **Routes only in `src/MeWeb.Shared/Endpoints.cs`** (#9); DTOs are classes named `View*`, `*Request`, `*Form` (#34). - **UI text:** resx keys only, sentence case, every language present, drafts labelled (#39, #50, #125). - **Every outbound host is declared** in the app's "What this app contacts" screen (#120). diff --git a/Directory.Build.props b/Directory.Build.props index 44aab95..1b9227e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,5 +13,8 @@ false false true + MeWeb.Shared + NumbersExtensions,Texts + $(WarningsAsErrors);HR0001;HR0002;HR0003;HR0004;HR0005;HR0006;HR0007;HR0008;HR0009;HR0010;HR0011;HR0101;HR0102;HR0103;HR0104;HR0105;HR0106;HR0201 diff --git a/Directory.Packages.props b/Directory.Packages.props index 62d6813..8b7b7f6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,4 +19,7 @@ + + + diff --git a/PATTERNS.md b/PATTERNS.md index 2dbe318..a2d644f 100644 --- a/PATTERNS.md +++ b/PATTERNS.md @@ -1,7 +1,7 @@ # PATTERNS.md — MeWeb house rulings -The canonical standards doc. **On any conflict about how to write code, a ruling here wins**; cite it in review as -"PATTERNS #n". `AGENT.md` is the operational contract, `CLAUDE.md` the quick reference and pitfalls. +The canonical standards doc. **On any conflict about how to write code, a ruling here wins**, except over the owner's +house rules (next section), which win over everything; cite a ruling in review as "PATTERNS #n". `AGENT.md` is the operational contract, `CLAUDE.md` the quick reference and pitfalls. Numbering is shared with the house lineage (SimpleSchool, passolo, odysseus, zigzag.academy): **#42, #51, #86–#93 mean the same thing in every repo**, and the text below follows passolo's wording wherever the subject is the same. @@ -10,6 +10,49 @@ only MeWeb needs start at **#120**. Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third-party trees, the licence text. +## House rules + +The owner's rules for every .NET project (`~/RiderProjects/HouseRules/RULES.md`, repo `Arasaka/HouseRules`) apply +here. `Arasaka.HouseRules.Analyzers` (a `GlobalPackageReference` in `Directory.Packages.props`) reports the checkable +ones as `HRxxxx` warnings, they are listed in `WarningsAsErrors`, and `tools/ci.sh` builds with `-warnaserror` too. +MeWeb builds with none and carries no suppression. Applied to MeWeb: + +- **Coding style:** + - `var` for every local, `var x = default(T)` when there is no value yet (HR0001, #92). + - `IOptionsMonitor.CurrentValue` (HR0002, #87). + - Primary constructors, using the parameters directly (HR0004, #1). + - Work a constructor used to do becomes a field initializer through a static helper (`RegistryWriter.Reserve`). + - A collection built from instance methods is a `field ??=` property (`MainViewModel.Cards`, `Shared`). + - A `CancellationToken` last on every method that returns a task, passed down (HR0003, #88). + - Relay commands take one. + - Where an API takes none, the wait takes it (`SetTextAsync(link).WaitAsync(ct)`). + - Best-effort and fire-and-forget work passes `CancellationToken.None` on purpose. +- **DTOs:** + - `View*` responses, `*Request` create/update requests, `*Form` other input (HR0010 over `MeWeb.Shared`, #34). + - `Show*` booleans and `*Formatted` strings for display (#44). + - Lists are `= []` (HR0006, #75), and strings have no `= string.Empty` default (HR0005, #89). +- **Server:** + - Routes live only in `Endpoints` (#9). + - A service that can refuse returns `WebResult` (`MeWeb.Shared`). `Invalidate(reason)` answers 400; 403 and + 404 are passed explicitly. The endpoint answers through `Answer(context, result, type)`: the data on success, + otherwise a `ViewRefusal` with its `Reason`, plus `Retry-After` when `RetryAfterSeconds` is set. + - Services take the caller's address from the endpoint, never `IHttpContextAccessor` (HR0008, #86). + - **Status codes** (owner, 2026-11-10): + - 400 for every refusal: bad ports, shape, size, busy, too many. + - 403 for a caller the probe will not serve (`not-public`, `other-address`) and a delete token that does not match. + - 404 for an unknown ticket or submission. + - Nothing answers 409, 413, 429 or any 5XX (HR0007), and MeWeb has no exception. + - Clients read `ViewRefusal.Reason`, never the status. Installed apps up to 0.15.4 read 429 on the speed download, + so until they update a limited speed test shows as "couldn't measure" rather than "limited". +- **Client:** `ServerProbe` returns the same `WebResult` and never throws (#27). A transport failure is the refusal + `unreachable`, and a failure without a body is `http-`. +- **Numbers:** formatted only by `NumbersExtensions` (`MeWeb.Shared`) and `Texts` (`MeWeb.App`) (HR0011). + - `NumbersExtensions` holds the invariant values that fill a localized template: `ToOneDecimal`, `ToWholeMegabytes`. + - `Texts` holds what the App formats in the reader's culture: money and dates. +- **JSON:** one source-generated context per assembly (#84). +- **Not applicable:** the `.razor` rules (HR0101–HR0106, HR0201), since the App is Avalonia. Their counterparts are + #27, #43, #44 and #93. `Localizer.Instance[key]` already returns a string (#91). + --- ## A. Dependency injection and services @@ -26,7 +69,8 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third ## B. Web boundary -5. **`WebResult` envelope + early return** on every server endpoint that can refuse; the happy path stays flat. +5. **`WebResult` + early return** in every server service that can refuse, answered through `Answer`; the happy + path stays flat (see House rules). 6. **Pure or internal code may return tuples** (`(value, error)`), never across the web boundary. 7. **`try/catch` placement: few at the top, few at the bottom, none in the middle.** Top: host startup, background loops. Bottom: sockets, DNS, UPnP/PCP, file IO, platform APIs — the IO edges where a check meets the network. @@ -53,7 +97,7 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third 55. n/a — no model output. 63. **An inventory that lives in a check rots; keep one map and assert it against reality.** 64. **A sweep asserts what must hold and records what is a judgement call.** -65. **Prefer a source lint to a runtime check when the bug is visible in the source** (`tools/lint.cs`). +65. **Prefer a source lint to a runtime check when the bug is visible in the source** (the house-rules analyzer). 66. **A check that has always failed is a decision nobody made** — fix it or delete it. 67. **Two paths that resolve the same thing must degrade the same way** (the v4 and v6 echo, the system resolver and the server-side blocklist fallback). @@ -117,7 +161,7 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third 32. **Subscription discipline:** every `+=` has its `-=` in the matching unload/dispose; timers are `PeriodicTimer` with a cancellation source cancelled on leave. 33. n/a. -34. **DTO naming by direction:** `View*` (response), `Upsert*` (create/update), `*Form` (any other input). Contracts +34. **DTO naming by direction:** `View*` (response), `*Request` (create/update), `*Form` (any other input). Contracts grouped by area in `MeWeb.Shared/Contracts.cs`. 35. n/a. 36. **Path safety** for anything served from disk by name. 37. **Layered gating: the UI hides; the server refuses.** 57. n/a. diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..6f123d5 --- /dev/null +++ b/nuget.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/MeWeb.Android/AndroidPlatform.cs b/src/MeWeb.Android/AndroidPlatform.cs index a38ba29..ed154a0 100644 --- a/src/MeWeb.Android/AndroidPlatform.cs +++ b/src/MeWeb.Android/AndroidPlatform.cs @@ -118,7 +118,7 @@ public class AndroidPlatform : SystemPlatform if (activity is null) return false; try { - return await activity.Ask(permission).WaitAsync(TimeSpan.FromMinutes(2), ct); + return await activity.Ask(permission, ct).WaitAsync(TimeSpan.FromMinutes(2), ct); } catch (TimeoutException) { diff --git a/src/MeWeb.Android/MainActivity.cs b/src/MeWeb.Android/MainActivity.cs index 63017d3..1ffc751 100644 --- a/src/MeWeb.Android/MainActivity.cs +++ b/src/MeWeb.Android/MainActivity.cs @@ -32,23 +32,23 @@ public class MainActivity : AvaloniaMainActivity base.OnDestroy(); } - public Task Ask(string permission) + public Task Ask(string permission, CancellationToken ct) { var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - int code; + var code = default(int); lock (Waiting) { code = nextRequest++; Waiting[code] = answer; } RunOnUiThread(() => RequestPermissions([permission], code)); - return answer.Task; + return answer.Task.WaitAsync(ct); } public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Permission[] grantResults) { base.OnRequestPermissionsResult(requestCode, permissions, grantResults); - TaskCompletionSource answer; + var answer = default(TaskCompletionSource); lock (Waiting) { if (!Waiting.Remove(requestCode, out answer)) return; diff --git a/src/MeWeb.App/ViewModels/MainViewModel.cs b/src/MeWeb.App/ViewModels/MainViewModel.cs index 457b98d..23dde78 100644 --- a/src/MeWeb.App/ViewModels/MainViewModel.cs +++ b/src/MeWeb.App/ViewModels/MainViewModel.cs @@ -28,7 +28,7 @@ public enum Reachability Offline, } -public partial class MainViewModel : ObservableObject +public partial class MainViewModel(ServerProbe server, CheckRunner runner, KnowledgeBase knowledge, HistoryStore history, IPlatform platform, Reach reach, Leftovers leftovers, Submissions submissions) : ObservableObject { public const string CorrectionsUrl = "https://git.thepra.dev/Arasaka/MeWeb/issues"; public const string SourceUrl = "https://git.thepra.dev/Arasaka/MeWeb"; @@ -38,14 +38,6 @@ public partial class MainViewModel : ObservableObject CheckId.Speed, CheckId.Online, CheckId.Addresses, CheckId.Reach, CheckId.History, ]; - readonly ServerProbe server; - readonly CheckRunner runner; - readonly KnowledgeBase knowledge; - readonly HistoryStore history; - readonly IPlatform platform; - readonly Reach reach; - readonly Leftovers leftovers; - readonly Submissions submissions; RunReport lastRun; int checks; bool sent; @@ -53,21 +45,7 @@ public partial class MainViewModel : ObservableObject bool instant; bool storySeen = AppSettings.Read().StorySeen; - public MainViewModel(ServerProbe server, CheckRunner runner, KnowledgeBase knowledge, HistoryStore history, IPlatform platform, Reach reach, Leftovers leftovers, Submissions submissions) - { - this.server = server; - this.runner = runner; - this.knowledge = knowledge; - this.history = history; - this.platform = platform; - this.reach = reach; - this.leftovers = leftovers; - this.submissions = submissions; - Cards = new ObservableCollection(CardOrder.Select(id => new CardViewModel(id, OpenLink))); - Shared = new ObservableCollection(submissions.Read().Select(s => new SubmissionViewModel(s, Unshare))); - } - - public ObservableCollection Shared { get; } + public ObservableCollection Shared => field ??= new(submissions.Read().Select(s => new SubmissionViewModel(s, Unshare))); public ObservableCollection Uses { get; } = []; @@ -123,7 +101,7 @@ public partial class MainViewModel : ObservableObject public IReadOnlyList Languages { get; } = Services.Languages.All; - public ObservableCollection Cards { get; } + public ObservableCollection Cards => field ??= new(CardOrder.Select(id => new CardViewModel(id, OpenLink))); public ObservableCollection Path { get; } = []; @@ -210,10 +188,10 @@ public partial class MainViewModel : ObservableObject } [RelayCommand] - async Task Leave() + async Task Leave(CancellationToken ct) { Leaving = true; - await Task.Delay(2200); + await Task.Delay(2200, ct); if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) desktop.Shutdown(); else platform.Quit(); } @@ -341,12 +319,13 @@ public partial class MainViewModel : ObservableObject IsSending = true; try { - var (shared, refusal) = await server.Share(SharePreview, ct); - if (shared is null) + var result = await server.Share(SharePreview, ct); + if (result.Data is null) { - ShareResult = Localizer.Instance[refusal?.Reason == "too-many" ? "ShareTooMany" : "ShareFailed"]; + ShareResult = Localizer.Instance[result.Refusal?.Reason == "too-many" ? "ShareTooMany" : "ShareFailed"]; return; } + var shared = result.Data; var submission = new Submission { Id = shared.Id, Token = shared.Token, KeepUntil = shared.KeepUntil, SentAt = DateTimeOffset.UtcNow }; submissions.Add(submission); Shared.Add(new SubmissionViewModel(submission, Unshare)); @@ -362,9 +341,9 @@ public partial class MainViewModel : ObservableObject } } - async Task Unshare(SubmissionViewModel item) + async Task Unshare(SubmissionViewModel item, CancellationToken ct) { - if (!await server.Unshare(item.Id, item.Token, CancellationToken.None)) + if (!await server.Unshare(item.Id, item.Token, ct)) { item.Failed = true; return; @@ -375,16 +354,16 @@ public partial class MainViewModel : ObservableObject } [RelayCommand] - async Task CopyLink() + async Task CopyLink(CancellationToken ct) { var link = Endpoints.Base + Endpoints.Downloads.GET_Page; if (platform.ShareLink(link, Localizer.Instance["AppName"])) return; if (Clipboard is null) return; - await Clipboard.SetTextAsync(link); + await Clipboard.SetTextAsync(link).WaitAsync(ct); LinkCopied = true; } - public async Task CleanLeftovers() => await leftovers.Clean(CancellationToken.None); + public async Task CleanLeftovers(CancellationToken ct) => await leftovers.Clean(ct); public void Replay(RunReport run) { @@ -617,10 +596,10 @@ public partial class MainViewModel : ObservableObject return; } pending[node] = outcome; - _ = Reveal(); + _ = Reveal(CancellationToken.None); } - async Task Reveal() + async Task Reveal(CancellationToken ct) { if (revealing) return; revealing = true; @@ -630,7 +609,7 @@ public partial class MainViewModel : ObservableObject { var next = Path.FirstOrDefault(n => !n.IsLit); if (next is null || !pending.TryGetValue(next, out var outcome)) break; - await Task.Delay(320); + await Task.Delay(320, ct); next.State = outcome; pending.Remove(next); } diff --git a/src/MeWeb.App/ViewModels/SubmissionViewModel.cs b/src/MeWeb.App/ViewModels/SubmissionViewModel.cs index a149172..d904bf7 100644 --- a/src/MeWeb.App/ViewModels/SubmissionViewModel.cs +++ b/src/MeWeb.App/ViewModels/SubmissionViewModel.cs @@ -6,7 +6,7 @@ using MeWeb.Core.History; namespace MeWeb.App.ViewModels; -public partial class SubmissionViewModel(Submission submission, Func delete) : ObservableObject +public partial class SubmissionViewModel(Submission submission, Func delete) : ObservableObject { public string Id => submission.Id; public string Token => submission.Token; @@ -18,7 +18,7 @@ public partial class SubmissionViewModel(Submission submission, Func delete(this); + Task Delete(CancellationToken ct) => delete(this, ct); public void Refresh() => OnPropertyChanged(string.Empty); } diff --git a/src/MeWeb.App/Views/MainView.axaml.cs b/src/MeWeb.App/Views/MainView.axaml.cs index bc8d68b..671c8c3 100644 --- a/src/MeWeb.App/Views/MainView.axaml.cs +++ b/src/MeWeb.App/Views/MainView.axaml.cs @@ -33,7 +33,7 @@ public partial class MainView : UserControl model.Launcher = top?.Launcher; model.Clipboard = top?.Clipboard; if (top is not null) top.BackRequested += OnBack; - _ = model.CleanLeftovers(); + _ = model.CleanLeftovers(CancellationToken.None); model.AvailableWidth = Bounds.Width; model.StartCommand.Execute(null); if (model.StorySeen) Dispatcher.UIThread.Post(ShowCheck, DispatcherPriority.Background); diff --git a/src/MeWeb.Core/Checks/Reach.cs b/src/MeWeb.Core/Checks/Reach.cs index 3ff2d7d..68e26c4 100644 --- a/src/MeWeb.Core/Checks/Reach.cs +++ b/src/MeWeb.Core/Checks/Reach.cs @@ -49,14 +49,14 @@ public class Reach(ServerProbe server, Leftovers leftovers) var report = new ReachReport { Choice = choice }; if (choice == ReachChoice.Skip) return report; - var ticket4 = run.PublicV4 is null ? (null, null) : await server.Ticket(false, ct); - var ticket6 = run.PublicV6 is null ? (null, null) : await server.Ticket(true, ct); + var ticket4 = run.PublicV4 is null ? new WebResult() : await server.Ticket(false, ct); + var ticket6 = run.PublicV6 is null ? new WebResult() : await server.Ticket(true, ct); report.V4Refusal = ticket4.Refusal?.Reason; report.V6Refusal = ticket6.Refusal?.Reason; report.RetryAfterSeconds = Math.Max(ticket4.Refusal?.RetryAfterSeconds ?? 0, ticket6.Refusal?.RetryAfterSeconds ?? 0); - if (ticket4.Ticket is null && ticket6.Ticket is null) return report; + if (ticket4.Data is null && ticket6.Data is null) return report; - Answerer started; + var started = default(Answerer); try { started = Answerer.Start(); @@ -71,13 +71,13 @@ public class Reach(ServerProbe server, Leftovers leftovers) } await using var answerer = started; report.ListenPort = answerer.Port; - if (ticket4.Ticket is not null) answerer.Accept(ticket4.Ticket.Nonce, Convert.FromBase64String(ticket4.Ticket.Key)); - if (ticket6.Ticket is not null) answerer.Accept(ticket6.Ticket.Nonce, Convert.FromBase64String(ticket6.Ticket.Key)); + if (ticket4.Data is not null) answerer.Accept(ticket4.Data.Nonce, Convert.FromBase64String(ticket4.Data.Key)); + if (ticket6.Data is not null) answerer.Accept(ticket6.Data.Nonce, Convert.FromBase64String(ticket6.Data.Key)); try { var local = run.Local?.LocalV4; - if (choice == ReachChoice.OpenPorts && ticket4.Ticket is not null && run.Router?.AnyAnswer == true && local is not null) + if (choice == ReachChoice.OpenPorts && ticket4.Data is not null && run.Router?.AnyAnswer == true && local is not null) { foreach (var external in Wellknown.Append(answerer.Port)) { @@ -92,18 +92,18 @@ public class Reach(ServerProbe server, Leftovers leftovers) : [.. Wellknown, answerer.Port]; ports4 = ports4.Where(p => p is 80 or 443 or (>= 49152 and <= 65535)).Distinct().Take(3).ToList(); - var knock4 = ticket4.Ticket is null ? Task.FromResult<(ViewProbe, ViewRefusal)>((null, null)) : server.Knock(false, ticket4.Ticket.Id, ports4, ct); - var knock6 = ticket6.Ticket is null ? Task.FromResult<(ViewProbe, ViewRefusal)>((null, null)) : server.Knock(true, ticket6.Ticket.Id, [answerer.Port], ct); - var (probe4, refusal4) = await knock4; - var (probe6, refusal6) = await knock6; - report.V4 = probe4; - report.V6 = probe6; - report.V4Refusal ??= refusal4?.Reason; - report.V6Refusal ??= refusal6?.Reason; + var knock4 = ticket4.Data is null ? Task.FromResult(new WebResult()) : server.Knock(false, ticket4.Data.Id, ports4, ct); + var knock6 = ticket6.Data is null ? Task.FromResult(new WebResult()) : server.Knock(true, ticket6.Data.Id, [answerer.Port], ct); + var probe4 = await knock4; + var probe6 = await knock6; + report.V4 = probe4.Data; + report.V6 = probe6.Data; + report.V4Refusal ??= probe4.Refusal?.Reason; + report.V6Refusal ??= probe6.Refusal?.Reason; var own = report.Mappings.FirstOrDefault(m => m.Mapping?.InternalPort == answerer.Port && m.ExternalPort == answerer.Port)?.Mapping; - if (own is not null && ticket4.Ticket is not null && Addresses.Parse(run.PublicV4) is { } publicV4) - report.Hairpin = await Knock(publicV4, own.ExternalPort, ticket4.Ticket, ct); + if (own is not null && ticket4.Data is not null && Addresses.Parse(run.PublicV4) is { } publicV4) + report.Hairpin = await Knock(publicV4, own.ExternalPort, ticket4.Data, ct); } finally { diff --git a/src/MeWeb.Core/Checks/ServerProbe.cs b/src/MeWeb.Core/Checks/ServerProbe.cs index 32b57cd..86827ef 100644 --- a/src/MeWeb.Core/Checks/ServerProbe.cs +++ b/src/MeWeb.Core/Checks/ServerProbe.cs @@ -55,23 +55,23 @@ public class ServerProbe(HttpClient https, HttpClient plain) } } - public async Task<(ViewProbeTicket Ticket, ViewRefusal Refusal)> Ticket(bool v6, CancellationToken ct) + public async Task> Ticket(bool v6, CancellationToken ct) { var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4; - return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_Ticket, null, MeWebJsonContext.Default.ViewProbeTicket, ct); + return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_Ticket, null, MeWebJsonContext.Default.ViewProbeTicket, Patience, ct); } - public async Task<(ViewProbe Probe, ViewRefusal Refusal)> Knock(bool v6, string id, List ports, CancellationToken ct) + public async Task> Knock(bool v6, string id, List ports, CancellationToken ct) { var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4; var form = JsonContent.Create(new ProbeForm { Ports = ports }, MeWebJsonContext.Default.ProbeForm); - return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_M_Run(id), form, MeWebJsonContext.Default.ViewProbe, ct, TimeSpan.FromSeconds(30)); + return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_M_Run(id), form, MeWebJsonContext.Default.ViewProbe, TimeSpan.FromSeconds(30), ct); } - public async Task<(ViewShared Shared, ViewRefusal Refusal)> Share(string json, CancellationToken ct) + public async Task> Share(string json, CancellationToken ct) { var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); - return await Send(HttpMethod.Post, Endpoints.Base + Endpoints.Share.POST_Create, content, MeWebJsonContext.Default.ViewShared, ct); + return await Send(HttpMethod.Post, Endpoints.Base + Endpoints.Share.POST_Create, content, MeWebJsonContext.Default.ViewShared, Patience, ct); } public async Task Unshare(string id, string token, CancellationToken ct) @@ -93,23 +93,28 @@ public class ServerProbe(HttpClient https, HttpClient plain) public const string DeleteTokenHeader = "X-Delete-Token"; - async Task<(T Value, ViewRefusal Refusal)> Send(HttpMethod method, string url, HttpContent content, System.Text.Json.Serialization.Metadata.JsonTypeInfo type, CancellationToken ct, TimeSpan? patience = null) where T : class + async Task> Send(HttpMethod method, string url, HttpContent content, System.Text.Json.Serialization.Metadata.JsonTypeInfo type, TimeSpan patience, CancellationToken ct) where T : class { + var result = new WebResult(); try { using var request = new HttpRequestMessage(method, url) { Content = content }; using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeout.CancelAfter(patience ?? Patience); + timeout.CancelAfter(patience); using var response = await https.SendAsync(request, timeout.Token); - if (response.IsSuccessStatusCode) return (await response.Content.ReadFromJsonAsync(type, timeout.Token), null); + if (response.IsSuccessStatusCode) + { + result.Data = await response.Content.ReadFromJsonAsync(type, timeout.Token); + return result; + } var refusal = response.Content.Headers.ContentType?.MediaType == "application/json" ? await response.Content.ReadFromJsonAsync(MeWebJsonContext.Default.ViewRefusal, timeout.Token) : null; - return (null, refusal ?? new ViewRefusal { Reason = $"http-{(int)response.StatusCode}" }); + return result.Invalidate(refusal ?? new ViewRefusal { Reason = $"http-{(int)response.StatusCode}" }, (int)response.StatusCode); } catch (Exception) when (!ct.IsCancellationRequested) { - return (null, new ViewRefusal { Reason = "unreachable" }); + return result.Invalidate("unreachable"); } } diff --git a/src/MeWeb.Core/Checks/Verdicts.cs b/src/MeWeb.Core/Checks/Verdicts.cs index 2970547..b5d77ac 100644 --- a/src/MeWeb.Core/Checks/Verdicts.cs +++ b/src/MeWeb.Core/Checks/Verdicts.cs @@ -409,9 +409,9 @@ public static class Verdicts } finding.Outcome = Outcome.Info; finding.Summary = "SpeedResult"; - finding.Args = [speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture), speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture)]; - finding.WithKey("FactDownload", "ValueMbpsDecimal", speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture)); - finding.WithKey("FactUpload", "ValueMbpsDecimal", speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture)); + finding.Args = [speed.DownMbps.ToOneDecimal(), speed.UpMbps.ToOneDecimal()]; + finding.WithKey("FactDownload", "ValueMbpsDecimal", speed.DownMbps.ToOneDecimal()); + finding.WithKey("FactUpload", "ValueMbpsDecimal", speed.UpMbps.ToOneDecimal()); finding.WithKey("FactSample", "ValueSample", (speed.DownBytes / 1048576).ToString(CultureInfo.InvariantCulture), (speed.UpBytes / 1048576).ToString(CultureInfo.InvariantCulture)); if (speed.RoundTripMs > 0) finding.WithKey("FactRoundTrip", "ValueMilliseconds", speed.RoundTripMs.ToString(CultureInfo.InvariantCulture)); return finding; @@ -430,7 +430,7 @@ public static class Verdicts }; finding.With("FactSystem", device.System); finding.WithKey("FactCores", "ValueCount", device.Cores.ToString(CultureInfo.InvariantCulture)); - finding.WithKey("FactMemory", "ValueGb", Math.Round(device.MemoryMb / 1024.0, 1).ToString("0.#", CultureInfo.InvariantCulture)); + finding.WithKey("FactMemory", "ValueGb", Math.Round(device.MemoryMb / 1024.0, 1).ToOneDecimal()); if (device.FreeDiskGb > 0) finding.WithKey("FactDisk", "ValueGbFree", device.FreeDiskGb.ToString(CultureInfo.InvariantCulture)); if (device.OnBattery is not null) finding.WithKey("FactBattery", device.OnBattery == true ? "ValueYes" : "ValueNo"); return finding; diff --git a/src/MeWeb.Core/Knowledge/RegistryTable.cs b/src/MeWeb.Core/Knowledge/RegistryTable.cs index 1c867f8..c9340aa 100644 --- a/src/MeWeb.Core/Knowledge/RegistryTable.cs +++ b/src/MeWeb.Core/Knowledge/RegistryTable.cs @@ -179,22 +179,21 @@ public sealed class RegistryTable : IDisposable } } -public sealed class RegistryWriter : IDisposable +public sealed class RegistryWriter(Stream output, DateTimeOffset date) : IDisposable { const int ChunkEntries = 16384; - readonly Stream output; - readonly DateTimeOffset date; + readonly long header = Reserve(output); readonly List<(UInt128 Start, RegistryBlock Block)> pending = []; readonly List<(UInt128 Start, long Length)>[] index = [[], []]; int family; int count; - public RegistryWriter(Stream output, DateTimeOffset date) + static long Reserve(Stream output) { - this.output = output; - this.date = date; + var at = output.Position; output.Write(new byte[RegistryTable.HeaderBytes]); + return at; } public void Add(bool v6, UInt128 start, RegistryBlock block) @@ -225,7 +224,7 @@ public sealed class RegistryWriter : IDisposable } writer.Flush(); var end = output.Position; - output.Position = 0; + output.Position = header; writer.Write(RegistryTable.Magic); writer.Write(date.ToUnixTimeSeconds()); writer.Write(count); diff --git a/src/MeWeb.Core/Knowledge/Sources.cs b/src/MeWeb.Core/Knowledge/Sources.cs index 6f11b0b..b83456c 100644 --- a/src/MeWeb.Core/Knowledge/Sources.cs +++ b/src/MeWeb.Core/Knowledge/Sources.cs @@ -22,7 +22,7 @@ public static partial class Sources { var names = new Dictionary(StringComparer.Ordinal); using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress)); - string line; + var line = default(string); while ((line = reader.ReadLine()) is not null) { var parts = line.Split('\t'); @@ -36,7 +36,7 @@ public static partial class Sources { var seen = new Dictionary>(); using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress)); - string line; + var line = default(string); while ((line = reader.ReadLine()) is not null) { var first = line.IndexOf(','); @@ -62,7 +62,7 @@ public static partial class Sources { using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress), Encoding.Latin1); RegistryRange range = null; - string line; + var line = default(string); while ((line = reader.ReadLine()) is not null) { if (line.Length == 0) @@ -90,7 +90,7 @@ public static partial class Sources using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress), Encoding.Latin1); RegistryRange range = null; string status = null; - string line; + var line = default(string); while ((line = reader.ReadLine()) is not null) { if (line.Length == 0) @@ -145,7 +145,7 @@ public static partial class Sources string name = null; string tuple = null; var plain = false; - string line; + var line = default(string); while ((line = textpb.ReadLine()) is not null) { var text = line.Trim(); diff --git a/src/MeWeb.Core/Net/Answerer.cs b/src/MeWeb.Core/Net/Answerer.cs index b10e033..16b27e3 100644 --- a/src/MeWeb.Core/Net/Answerer.cs +++ b/src/MeWeb.Core/Net/Answerer.cs @@ -17,7 +17,7 @@ public sealed class Answerer : IAsyncDisposable Answerer(TcpListener listener) { this.listener = listener; - loop = Task.Run(Accept); + loop = Task.Run(() => Accept(stop.Token)); } public void Accept(string nonce, byte[] key) => keys[nonce] = key; @@ -46,30 +46,30 @@ public sealed class Answerer : IAsyncDisposable throw new InvalidOperationException("no free port for the answerer"); } - async Task Accept() + async Task Accept(CancellationToken ct) { - while (!stop.IsCancellationRequested) + while (!ct.IsCancellationRequested) { - TcpClient client; + var client = default(TcpClient); try { - client = await listener.AcceptTcpClientAsync(stop.Token); + client = await listener.AcceptTcpClientAsync(ct); } catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException) { return; } - _ = Task.Run(() => Answer(client)); + _ = Task.Run(() => Answer(client, ct)); } } - async Task Answer(TcpClient client) + async Task Answer(TcpClient client, CancellationToken ct) { using (client) { try { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stop.Token); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(4)); var stream = client.GetStream(); var buffer = new byte[128]; diff --git a/src/MeWeb.Core/Net/LocalNetwork.cs b/src/MeWeb.Core/Net/LocalNetwork.cs index 0ab8b87..8b8bcaa 100644 --- a/src/MeWeb.Core/Net/LocalNetwork.cs +++ b/src/MeWeb.Core/Net/LocalNetwork.cs @@ -205,7 +205,7 @@ public class SystemPlatform : IPlatform public virtual LocalSnapshot ReadNetwork() { var snapshot = new LocalSnapshot(); - NetworkInterface[] adapters; + var adapters = default(NetworkInterface[]); try { adapters = NetworkInterface.GetAllNetworkInterfaces(); diff --git a/src/MeWeb.Core/Net/SpeedCheck.cs b/src/MeWeb.Core/Net/SpeedCheck.cs index 91d3cdc..2894216 100644 --- a/src/MeWeb.Core/Net/SpeedCheck.cs +++ b/src/MeWeb.Core/Net/SpeedCheck.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Net; +using System.Net.Http.Json; using System.Net.Sockets; using MeWeb.Shared; @@ -30,19 +31,19 @@ public static class SpeedCheck var clock = Stopwatch.StartNew(); using (var response = await https.GetAsync(Endpoints.Base + Endpoints.Speed.GET_M_Down(report.DownBytes), HttpCompletionOption.ResponseHeadersRead, timeout.Token)) { - if ((int)response.StatusCode == 429) + if (!response.IsSuccessStatusCode) { - report.Limited = true; + report.Limited = response.Content.Headers.ContentType?.MediaType == "application/json" + && (await response.Content.ReadFromJsonAsync(MeWebJsonContext.Default.ViewRefusal, timeout.Token))?.Reason is "too-many" or "busy"; return report; } - response.EnsureSuccessStatusCode(); await using var body = await response.Content.ReadAsStreamAsync(timeout.Token); var buffer = new byte[64 * 1024]; var warm = report.DownBytes / 4; long total = 0; long warmBytes = 0; var warmAt = TimeSpan.Zero; - int read; + var read = default(int); while ((read = await body.ReadAsync(buffer, timeout.Token)) > 0) { total += read; diff --git a/src/MeWeb.Core/Router/Gateway.cs b/src/MeWeb.Core/Router/Gateway.cs index 7e5665d..1d19afd 100644 --- a/src/MeWeb.Core/Router/Gateway.cs +++ b/src/MeWeb.Core/Router/Gateway.cs @@ -109,7 +109,7 @@ public static class Gateway window.CancelAfter(TimeSpan.FromSeconds(2.5)); while (true) { - UdpReceiveResult received; + var received = default(UdpReceiveResult); try { received = await udp.ReceiveAsync(window.Token); diff --git a/src/MeWeb.Core/Router/Leftovers.cs b/src/MeWeb.Core/Router/Leftovers.cs index db53434..07a2bdf 100644 --- a/src/MeWeb.Core/Router/Leftovers.cs +++ b/src/MeWeb.Core/Router/Leftovers.cs @@ -37,7 +37,7 @@ public class Leftovers(string folder) public async Task Clean(CancellationToken ct) { - List list; + var list = default(List); lock (gate) list = Read(); foreach (var mapping in list.Where(m => m.Until > DateTimeOffset.UtcNow.AddMinutes(-1))) await PortMapper.Remove(mapping, ct); diff --git a/src/MeWeb.Server/DownloadPage.cs b/src/MeWeb.Server/DownloadPage.cs index 0dfe5b9..b4c3227 100644 --- a/src/MeWeb.Server/DownloadPage.cs +++ b/src/MeWeb.Server/DownloadPage.cs @@ -112,7 +112,7 @@ public static class DownloadPage { var asset = ReleaseFeed.Asset(release, platform); if (asset is null) continue; - html.Append($"{E(label)} · {asset.Size / 1048576.0:0} MB"); + html.Append($"{E(label)} · {asset.Size.ToWholeMegabytes()} MB"); } html.Append($"

{note}

{more}"); } diff --git a/src/MeWeb.Server/ObservedNotes.cs b/src/MeWeb.Server/ObservedNotes.cs index e3ccdb9..eda0388 100644 --- a/src/MeWeb.Server/ObservedNotes.cs +++ b/src/MeWeb.Server/ObservedNotes.cs @@ -38,7 +38,7 @@ public partial class ObservedNotes(IOptionsMonitor shares, IOption var latest = new Dictionary<(uint, string, string), SharedLine>(); foreach (var file in Directory.EnumerateFiles(settings.Folder, "MW-*.json")) { - SharedLine line; + var line = default(SharedLine); try { line = Read(File.ReadAllBytes(file)); diff --git a/src/MeWeb.Server/ProbeService.cs b/src/MeWeb.Server/ProbeService.cs index 8d0fa19..45427b6 100644 --- a/src/MeWeb.Server/ProbeService.cs +++ b/src/MeWeb.Server/ProbeService.cs @@ -30,10 +30,11 @@ public partial class ProbeService(IOptionsMonitor options, ILogger readonly SemaphoreSlim outbound = new(32, 32); readonly SemaphoreSlim tracing = new(4, 4); - public (ViewProbeTicket Ticket, ViewRefusal Refusal, int Status) Issue(IPAddress caller) + public WebResult Issue(IPAddress caller) { + var result = new WebResult(); var refusal = Refuse(caller); - if (refusal is not null) return (null, new ViewRefusal { Reason = refusal }, StatusCodes.Status403Forbidden); + if (refusal is not null) return result.Invalidate(refusal, StatusCodes.Status403Forbidden); var now = DateTimeOffset.UtcNow; foreach (var (id, old) in tickets) if (old.Expires < now) tickets.TryRemove(id, out _); @@ -47,7 +48,7 @@ public partial class ProbeService(IOptionsMonitor options, ILogger if (lastHour >= options.CurrentValue.PerHour || times.Count >= options.CurrentValue.PerDay) { var retry = lastHour >= options.CurrentValue.PerHour ? times.Where(t => now - t < TimeSpan.FromHours(1)).Min().AddHours(1) : times.Min().AddDays(1); - return (null, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = (int)Math.Ceiling((retry - now).TotalSeconds) }, StatusCodes.Status429TooManyRequests); + return result.Invalidate("too-many", retryAfterSeconds: (int)Math.Ceiling((retry - now).TotalSeconds)); } times.Add(now); } @@ -61,27 +62,30 @@ public partial class ProbeService(IOptionsMonitor options, ILogger Expires = now + TicketLife, }; tickets[ticket.Id] = ticket; - return (new ViewProbeTicket { Id = ticket.Id, Nonce = ticket.Nonce, Key = Convert.ToBase64String(ticket.Key), Address = caller.ToString(), Seconds = (int)TicketLife.TotalSeconds }, null, StatusCodes.Status200OK); + result.Data = new ViewProbeTicket { Id = ticket.Id, Nonce = ticket.Nonce, Key = Convert.ToBase64String(ticket.Key), Address = caller.ToString(), Seconds = (int)TicketLife.TotalSeconds }; + return result; } - public async Task<(ViewProbe Probe, ViewRefusal Refusal, int Status)> Run(string id, IPAddress caller, ProbeForm form, CancellationToken ct) + public async Task> Run(string id, IPAddress caller, ProbeForm form, CancellationToken ct) { + var result = new WebResult(); if (id is null || !tickets.TryRemove(id, out var ticket) || ticket.Expires < DateTimeOffset.UtcNow) - return (null, new ViewRefusal { Reason = "unknown-ticket" }, StatusCodes.Status404NotFound); - if (!ticket.Address.Equals(caller)) return (null, new ViewRefusal { Reason = "other-address" }, StatusCodes.Status403Forbidden); + return result.Invalidate("unknown-ticket", StatusCodes.Status404NotFound); + if (!ticket.Address.Equals(caller)) return result.Invalidate("other-address", StatusCodes.Status403Forbidden); var ports = form?.Ports.Distinct().ToList() ?? []; if (ports.Count is 0 or > 3 || ports.Any(p => p is not (80 or 443) and not (>= 49152 and <= 65535))) - return (null, new ViewRefusal { Reason = "bad-ports" }, StatusCodes.Status400BadRequest); + return result.Invalidate("bad-ports"); var network = NetworkOf(caller); - if (!running.TryAdd(network, 0)) return (null, new ViewRefusal { Reason = "busy" }, StatusCodes.Status409Conflict); + if (!running.TryAdd(network, 0)) return result.Invalidate("busy"); try { - var result = new ViewProbe { Address = caller.ToString() }; + var probe = new ViewProbe { Address = caller.ToString() }; var route = Trace(caller, ct); - foreach (var port in ports) result.Ports.Add(new ViewProbePort { Port = port, Outcome = await Knock(ticket, port, ct) }); - result.Route = await route; + foreach (var port in ports) probe.Ports.Add(new ViewProbePort { Port = port, Outcome = await Knock(ticket, port, ct) }); + probe.Route = await route; log.LogInformation("{Service} ran {Count} port(s)", nameof(ProbeService), ports.Count); - return (result, null, StatusCodes.Status200OK); + result.Data = probe; + return result; } finally { @@ -245,7 +249,7 @@ public partial class ProbeService(IOptionsMonitor options, ILogger { public string Id { get; set; } public string Nonce { get; set; } - public byte[] Key { get; set; } + public byte[] Key { get; set; } = []; public IPAddress Address { get; set; } public DateTimeOffset Expires { get; set; } } diff --git a/src/MeWeb.Server/ServerApi.cs b/src/MeWeb.Server/ServerApi.cs index 12bfd53..dadf979 100644 --- a/src/MeWeb.Server/ServerApi.cs +++ b/src/MeWeb.Server/ServerApi.cs @@ -1,4 +1,5 @@ using System.Net.Sockets; +using System.Text.Json.Serialization.Metadata; using MeWeb.Core.Knowledge; using MeWeb.Shared; @@ -46,8 +47,8 @@ public static class ServerApi { 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); + 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); @@ -63,7 +64,7 @@ public static class ServerApi 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(); + return received < 0 ? Refused(context, new ViewRefusal { Reason = "size" }, StatusCodes.Status400BadRequest) : Results.NoContent(); } public const string DeleteTokenHeader = "X-Delete-Token"; @@ -72,16 +73,14 @@ public static class ServerApi { 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); + return Answer(context, probe.Issue(caller), MeWebJsonContext.Default.ViewProbeTicket); } 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); + return Answer(context, await probe.Run(id, caller, form, ct), MeWebJsonContext.Default.ViewProbe); } static async Task Share(HttpContext context, ShareService shares, CancellationToken ct) @@ -90,19 +89,21 @@ public static class ServerApi if (caller is null) return Results.BadRequest(); using var buffer = new MemoryStream(); var chunk = new byte[8192]; - int read; + 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.Status413PayloadTooLarge); + if (buffer.Length > ShareService.MaxBytes) return Refused(context, new ViewRefusal { Reason = "size" }, StatusCodes.Status400BadRequest); } - var (shared, refusal, status) = shares.Create(caller, buffer.ToArray()); - return shared is not null ? Results.Json(shared, MeWebJsonContext.Default.ViewShared) : Refused(context, refusal, status); + 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(HttpContext context, WebResult result, JsonTypeInfo 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); diff --git a/src/MeWeb.Server/ShareService.cs b/src/MeWeb.Server/ShareService.cs index 2a30fe3..3afb37a 100644 --- a/src/MeWeb.Server/ShareService.cs +++ b/src/MeWeb.Server/ShareService.cs @@ -26,10 +26,11 @@ public partial class ShareService(IOptionsMonitor options, ILogger readonly ConcurrentDictionary today = new(); string day = ""; - public (ViewShared Shared, ViewRefusal Refusal, int Status) Create(IPAddress caller, byte[] body) + public WebResult Create(IPAddress caller, byte[] body) { - if (body.Length is 0 or > MaxBytes) return (null, new ViewRefusal { Reason = "size" }, StatusCodes.Status413PayloadTooLarge); - if (!Valid(body)) return (null, new ViewRefusal { Reason = "shape" }, StatusCodes.Status400BadRequest); + var result = new WebResult(); + if (body.Length is 0 or > MaxBytes) return result.Invalidate("size"); + if (!Valid(body)) return result.Invalidate("shape"); var now = DateTimeOffset.UtcNow; var date = now.ToString("yyyy-MM-dd"); @@ -45,7 +46,7 @@ public partial class ShareService(IOptionsMonitor options, ILogger if (count > options.CurrentValue.PerDay) { var midnight = new DateTimeOffset(now.UtcDateTime.Date.AddDays(1), TimeSpan.Zero); - return (null, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = (int)Math.Ceiling((midnight - now).TotalSeconds) }, StatusCodes.Status429TooManyRequests); + return result.Invalidate("too-many", retryAfterSeconds: (int)Math.Ceiling((midnight - now).TotalSeconds)); } var id = $"MW-{Code(4)}-{Code(4)}"; @@ -55,7 +56,8 @@ public partial class ShareService(IOptionsMonitor options, ILogger Directory.CreateDirectory(options.CurrentValue.Folder); File.WriteAllText(PathOf(id), record); log.LogInformation("{Service} stored a submission", nameof(ShareService)); - return (new ViewShared { Id = id, Token = token, KeepUntil = keepUntil.ToString("yyyy-MM-dd") }, null, StatusCodes.Status200OK); + result.Data = new ViewShared { Id = id, Token = token, KeepUntil = keepUntil.ToString("yyyy-MM-dd") }; + return result; } public int Delete(string id, string token) diff --git a/src/MeWeb.Server/SpeedService.cs b/src/MeWeb.Server/SpeedService.cs index ecd5ea9..c8570f1 100644 --- a/src/MeWeb.Server/SpeedService.cs +++ b/src/MeWeb.Server/SpeedService.cs @@ -54,7 +54,7 @@ public class SpeedService { var buffer = new byte[64 * 1024]; long total = 0; - int read; + var read = default(int); while ((read = await body.ReadAsync(buffer, ct)) > 0) { total += read; diff --git a/src/MeWeb.Shared/NumbersExtensions.cs b/src/MeWeb.Shared/NumbersExtensions.cs new file mode 100644 index 0000000..c210d13 --- /dev/null +++ b/src/MeWeb.Shared/NumbersExtensions.cs @@ -0,0 +1,10 @@ +using System.Globalization; + +namespace MeWeb.Shared; + +public static class NumbersExtensions +{ + public static string ToOneDecimal(this double value) => value.ToString("0.#", CultureInfo.InvariantCulture); + + public static string ToWholeMegabytes(this long bytes) => (bytes / 1048576.0).ToString("0", CultureInfo.InvariantCulture); +} diff --git a/src/MeWeb.Shared/WebResult.cs b/src/MeWeb.Shared/WebResult.cs new file mode 100644 index 0000000..59c59b0 --- /dev/null +++ b/src/MeWeb.Shared/WebResult.cs @@ -0,0 +1,23 @@ +using System.Net; + +namespace MeWeb.Shared; + +public class WebResult where T : class +{ + public T Data { get; set; } + public ViewRefusal Refusal { get; private set; } + public int StatusCode { get; private set; } = (int)HttpStatusCode.OK; + public bool IsInvalid => Refusal is not null; + public bool IsValid => Refusal is null; + + public WebResult Invalidate(string reason, int statusCode = (int)HttpStatusCode.BadRequest, int retryAfterSeconds = 0) => + Invalidate(new ViewRefusal { Reason = reason, RetryAfterSeconds = retryAfterSeconds }, statusCode); + + public WebResult Invalidate(ViewRefusal refusal, int statusCode = (int)HttpStatusCode.BadRequest) + { + Data = null; + Refusal = refusal; + StatusCode = statusCode; + return this; + } +}