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; + } +}