From 6fe816404f348f50219296d0a74a5a571709b1b1 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 24 Feb 2025 17:47:37 +0100 Subject: [PATCH 01/29] rename rate/ to Foxnouns.RateLimiter/ for consistency --- {rate => Foxnouns.RateLimiter}/Dockerfile | 0 {rate => Foxnouns.RateLimiter}/README.md | 0 {rate => Foxnouns.RateLimiter}/go.mod | 0 {rate => Foxnouns.RateLimiter}/go.sum | 0 {rate => Foxnouns.RateLimiter}/handler.go | 0 {rate => Foxnouns.RateLimiter}/main.go | 0 {rate => Foxnouns.RateLimiter}/proxy-config.example.json | 0 {rate => Foxnouns.RateLimiter}/rate_limiter.go | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename {rate => Foxnouns.RateLimiter}/Dockerfile (100%) rename {rate => Foxnouns.RateLimiter}/README.md (100%) rename {rate => Foxnouns.RateLimiter}/go.mod (100%) rename {rate => Foxnouns.RateLimiter}/go.sum (100%) rename {rate => Foxnouns.RateLimiter}/handler.go (100%) rename {rate => Foxnouns.RateLimiter}/main.go (100%) rename {rate => Foxnouns.RateLimiter}/proxy-config.example.json (100%) rename {rate => Foxnouns.RateLimiter}/rate_limiter.go (100%) diff --git a/rate/Dockerfile b/Foxnouns.RateLimiter/Dockerfile similarity index 100% rename from rate/Dockerfile rename to Foxnouns.RateLimiter/Dockerfile diff --git a/rate/README.md b/Foxnouns.RateLimiter/README.md similarity index 100% rename from rate/README.md rename to Foxnouns.RateLimiter/README.md diff --git a/rate/go.mod b/Foxnouns.RateLimiter/go.mod similarity index 100% rename from rate/go.mod rename to Foxnouns.RateLimiter/go.mod diff --git a/rate/go.sum b/Foxnouns.RateLimiter/go.sum similarity index 100% rename from rate/go.sum rename to Foxnouns.RateLimiter/go.sum diff --git a/rate/handler.go b/Foxnouns.RateLimiter/handler.go similarity index 100% rename from rate/handler.go rename to Foxnouns.RateLimiter/handler.go diff --git a/rate/main.go b/Foxnouns.RateLimiter/main.go similarity index 100% rename from rate/main.go rename to Foxnouns.RateLimiter/main.go diff --git a/rate/proxy-config.example.json b/Foxnouns.RateLimiter/proxy-config.example.json similarity index 100% rename from rate/proxy-config.example.json rename to Foxnouns.RateLimiter/proxy-config.example.json diff --git a/rate/rate_limiter.go b/Foxnouns.RateLimiter/rate_limiter.go similarity index 100% rename from rate/rate_limiter.go rename to Foxnouns.RateLimiter/rate_limiter.go From a72c0f41c3965288e2fa159d410719d5c83579df Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 24 Feb 2025 18:25:49 +0100 Subject: [PATCH 02/29] add build script --- .gitignore | 3 +++ build.sh | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100755 build.sh diff --git a/.gitignore b/.gitignore index 9c16977..9037fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ docker/proxy-config.json docker/frontend.env Foxnouns.DataMigrator/apps.json + +out/ +build/ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e14eb53 --- /dev/null +++ b/build.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euxo pipefail + +ROOT_DIR=$(pwd) + +echo "Cleaning output directory ($ROOT_DIR/build)" + +[ -d "$ROOT_DIR/build" ] && rm -r "$ROOT_DIR/build" +mkdir "$ROOT_DIR/build" + +echo "Building .NET backend" + +cd "$ROOT_DIR/Foxnouns.Backend" +[ -d "$ROOT_DIR/Foxnouns.Backend/out" ] && rm -r "$ROOT_DIR/Foxnouns.Backend/out" +dotnet publish --artifacts-path "$ROOT_DIR/Foxnouns.Backend/out" +mv "$ROOT_DIR/Foxnouns.Backend/out/publish/Foxnouns.Backend/"* "$ROOT_DIR/build/bin" + +echo "Building Go rate limiter" + +cd "$ROOT_DIR/Foxnouns.RateLimiter" +go build -o rate -v . +mv rate "$ROOT_DIR/build/rate" + +echo "Building Node.js frontend" + +cd "$ROOT_DIR/Foxnouns.Frontend" +[ -d "$ROOT_DIR/Foxnouns.Frontend/build" ] && rm -r "$ROOT_DIR/Foxnouns.Frontend/build" +pnpm install +pnpm build + +mkdir "$ROOT_DIR/build/fe" +cp -r build .env* package.json pnpm-lock.yaml "$ROOT_DIR/build/fe" +cd "$ROOT_DIR/build/fe" +pnpm install -P + +echo "Finished building Foxnouns.NET" From f1f777ff823b9cf2f4a33cb6ec157696f8e62e82 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 24 Feb 2025 20:37:51 +0100 Subject: [PATCH 03/29] fix(frontend): localize footer --- .../src/lib/components/Footer.svelte | 21 ++++++++++--------- .../src/lib/i18n/locales/en.json | 12 +++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Foxnouns.Frontend/src/lib/components/Footer.svelte b/Foxnouns.Frontend/src/lib/components/Footer.svelte index 6fd6564..857c07c 100644 --- a/Foxnouns.Frontend/src/lib/components/Footer.svelte +++ b/Foxnouns.Frontend/src/lib/components/Footer.svelte @@ -8,6 +8,7 @@ import Envelope from "svelte-bootstrap-icons/lib/Envelope.svelte"; import CashCoin from "svelte-bootstrap-icons/lib/CashCoin.svelte"; import Logo from "./Logo.svelte"; + import { t } from "$lib/i18n"; type Props = { meta: Meta }; let { meta }: Props = $props(); @@ -18,13 +19,13 @@
    -
  • Version {meta.version}
  • +
  • {$t("footer.version")} {meta.version}
    -
  • {meta.users.total.toLocaleString()} users
  • -
  • {meta.members.toLocaleString()} members
  • +
  • {meta.users.total.toLocaleString()} {$t("footer.users")}
  • +
  • {meta.members.toLocaleString()} {$t("footer.members")}
@@ -36,7 +37,7 @@ >
  • - Source code + {$t("footer.source")}
  • - Status + {$t("footer.status")}
  • - About and contact + {$t("footer.about-contact")}
  • - Terms of service + {$t("footer.terms")}
  • - Privacy policy + {$t("footer.privacy")}
  • - Changelog + {$t("footer.changelog")}
  • - Donate + {$t("footer.donate")}
  • diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 1c15a83..7f2b2ab 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -327,5 +327,17 @@ "alert": { "auth-method-remove-success": "Successfully unlinked account!", "auth-required": "You must log in to access this page." + }, + "footer": { + "version": "Version", + "users": "users", + "members": "members", + "source": "Source code", + "status": "Status", + "terms": "Terms of service", + "privacy": "Privacy policy", + "changelog": "Changelog", + "donate": "Donate", + "about-contact": "About and contact" } } From 64ea25e89e4bdcafd7209fc1300cb7a46a34ffdb Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 24 Feb 2025 21:32:20 +0100 Subject: [PATCH 04/29] feat(frontend): avatar cropping --- Foxnouns.Frontend/package.json | 3 +- Foxnouns.Frontend/pnpm-lock.yaml | 22 +++-- .../lib/components/editor/AvatarEditor.svelte | 89 ++++++++++++++++++- .../src/lib/i18n/locales/en.json | 4 +- 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/Foxnouns.Frontend/package.json b/Foxnouns.Frontend/package.json index d140f11..a76a918 100644 --- a/Foxnouns.Frontend/package.json +++ b/Foxnouns.Frontend/package.json @@ -15,7 +15,7 @@ "@sveltejs/adapter-node": "^5.2.10", "@sveltejs/kit": "^2.12.1", "@sveltejs/vite-plugin-svelte": "^5.0.2", - "@sveltestrap/sveltestrap": "^6.2.7", + "@sveltestrap/sveltestrap": "^7.1.0", "@types/eslint": "^9.6.1", "@types/luxon": "^3.4.2", "@types/markdown-it": "^14.1.2", @@ -31,6 +31,7 @@ "svelte": "^5.14.3", "svelte-bootstrap-icons": "^3.1.1", "svelte-check": "^4.1.1", + "svelte-easy-crop": "^4.0.0", "sveltekit-i18n": "^2.4.2", "typescript": "^5.7.2", "typescript-eslint": "^8.18.1", diff --git a/Foxnouns.Frontend/pnpm-lock.yaml b/Foxnouns.Frontend/pnpm-lock.yaml index c77b36c..46b0010 100644 --- a/Foxnouns.Frontend/pnpm-lock.yaml +++ b/Foxnouns.Frontend/pnpm-lock.yaml @@ -55,8 +55,8 @@ importers: specifier: ^5.0.2 version: 5.0.2(svelte@5.14.3)(vite@6.0.3(@types/node@22.12.0)(sass@1.83.0)) '@sveltestrap/sveltestrap': - specifier: ^6.2.7 - version: 6.2.7(svelte@5.14.3) + specifier: ^7.1.0 + version: 7.1.0(svelte@5.14.3) '@types/eslint': specifier: ^9.6.1 version: 9.6.1 @@ -102,6 +102,9 @@ importers: svelte-check: specifier: ^4.1.1 version: 4.1.1(picomatch@4.0.2)(svelte@5.14.3)(typescript@5.7.2) + svelte-easy-crop: + specifier: ^4.0.0 + version: 4.0.0(svelte@5.14.3) sveltekit-i18n: specifier: ^2.4.2 version: 2.4.2(svelte@5.14.3) @@ -1002,8 +1005,8 @@ packages: '@sveltekit-i18n/parser-default@1.1.1': resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==} - '@sveltestrap/sveltestrap@6.2.7': - resolution: {integrity: sha512-WwLLfAFUb42BGuRrf3Vbct30bQMzlEMMipN/MfxhjuLTmLQeW9muVJfPyvjtWS+mY+RjkSCoHvAp/ZobP1NLlQ==} + '@sveltestrap/sveltestrap@7.1.0': + resolution: {integrity: sha512-TpIx25kqLV+z+VD3yfqYayOI1IaCeWFbT0uqM6NfA4vQgDs9PjFwmjkU4YEAlV/ngs9e7xPmaRWE7lkrg4Miow==} peerDependencies: svelte: ^4.0.0 || ^5.0.0 || ^5.0.0-next.0 @@ -1967,6 +1970,11 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' + svelte-easy-crop@4.0.0: + resolution: {integrity: sha512-/asrrCYypXwCsPqJ07m7s7QArJwrdfEt7D1UN9hC4WF3GgEtuqmGuVi5DGeJVtBpKu5388gYFtCgQz9lA+/Rtg==} + peerDependencies: + svelte: ^4.0.0 || ^5.0.0 + svelte-eslint-parser@0.43.0: resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3079,7 +3087,7 @@ snapshots: '@sveltekit-i18n/parser-default@1.1.1': {} - '@sveltestrap/sveltestrap@6.2.7(svelte@5.14.3)': + '@sveltestrap/sveltestrap@7.1.0(svelte@5.14.3)': dependencies: '@popperjs/core': 2.11.8 svelte: 5.14.3 @@ -4051,6 +4059,10 @@ snapshots: transitivePeerDependencies: - picomatch + svelte-easy-crop@4.0.0(svelte@5.14.3): + dependencies: + svelte: 5.14.3 + svelte-eslint-parser@0.43.0(svelte@5.14.3): dependencies: eslint-scope: 7.2.2 diff --git a/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte b/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte index e18c6b6..a945bbf 100644 --- a/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte +++ b/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte @@ -1,7 +1,8 @@ @@ -44,6 +86,41 @@

    + (cropperOpen = !cropperOpen)} +> + + + + + {/if} + + diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 7f2b2ab..7145181 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -291,7 +291,9 @@ "custom-preference-muted": "Show as muted text", "custom-preference-favourite": "Treat like favourite", "custom-preference-notice": "Want to edit your custom preferences?", - "custom-preference-notice-link": "Go to settings" + "custom-preference-notice-link": "Go to settings", + "crop-avatar-header": "Crop avatar", + "crop-avatar-button": "Crop" }, "cancel": "Cancel", "report": { From 7ea6c62d67bd01820530703a324500e4bf981137 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 28 Feb 2025 16:36:45 +0100 Subject: [PATCH 05/29] chore(backend): update dependencies --- Foxnouns.Backend/Foxnouns.Backend.csproj | 36 +- Foxnouns.Backend/Program.cs | 24 +- .../OpenApi/PropertyKeySchemaTransformer.cs | 6 +- Foxnouns.Backend/packages.lock.json | 371 +++++++++--------- .../Foxnouns.DataMigrator.csproj | 6 +- 5 files changed, 224 insertions(+), 219 deletions(-) diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index 6f6d69f..c30f2b9 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -8,41 +8,41 @@ - - + + - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + - + diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 66e57a6..0f1d9f1 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -23,7 +23,6 @@ using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Prometheus; -using Scalar.AspNetCore; using Sentry.Extensibility; using Serilog; @@ -46,7 +45,8 @@ builder // No valid request body will ever come close to this limit, // but the limit is slightly higher to prevent valid requests from being rejected. opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024; - }); + }) + .UseUrls(config.Address); builder .Services.AddControllers() @@ -109,16 +109,18 @@ if (config.Logging.SentryTracing) app.UseCors(); app.UseCustomMiddleware(); app.MapControllers(); -app.MapOpenApi("/api-docs/openapi/{documentName}.json"); -app.MapScalarApiReference(options => -{ - options.Title = "pronouns.cc API"; - options.OpenApiRoutePattern = "/api-docs/openapi/{documentName}.json"; - options.EndpointPathPrefix = "/api-docs/{documentName}"; -}); -app.Urls.Clear(); -app.Urls.Add(config.Address); +// TODO: I can't figure out why this doesn't work yet +// TODO: Manually write API docs in the meantime +// app.MapOpenApi("/api-docs/openapi/{documentName}.json"); +// app.MapScalarApiReference( +// "/api-docs/", +// options => +// { +// options.Title = "pronouns.cc API"; +// options.OpenApiRoutePattern = "/api-docs/openapi/{documentName}.json"; +// } +// ); // Make sure metrics are updated whenever Prometheus scrapes them Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct => diff --git a/Foxnouns.Backend/Utils/OpenApi/PropertyKeySchemaTransformer.cs b/Foxnouns.Backend/Utils/OpenApi/PropertyKeySchemaTransformer.cs index 92c1f7c..9835b50 100644 --- a/Foxnouns.Backend/Utils/OpenApi/PropertyKeySchemaTransformer.cs +++ b/Foxnouns.Backend/Utils/OpenApi/PropertyKeySchemaTransformer.cs @@ -22,8 +22,10 @@ namespace Foxnouns.Backend.Utils.OpenApi; public class PropertyKeySchemaTransformer : IOpenApiSchemaTransformer { - private static readonly DefaultContractResolver SnakeCaseConverter = - new() { NamingStrategy = new SnakeCaseNamingStrategy() }; + private static readonly DefaultContractResolver SnakeCaseConverter = new() + { + NamingStrategy = new SnakeCaseNamingStrategy(), + }; public Task TransformAsync( OpenApiSchema schema, diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index dc238f7..e3799c6 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -4,9 +4,9 @@ "net9.0": { "Coravel": { "type": "Direct", - "requested": "[6.0.0, )", - "resolved": "6.0.0", - "contentHash": "U16V/IxGL2TcpU9sT1gUA3pqoVIlz+WthC4idn8OTPiEtLElTcmNF6sHt+gOx8DRU8TBgN5vjfL4AHetjacOWQ==", + "requested": "[6.0.2, )", + "resolved": "6.0.2", + "contentHash": "/XZiRId4Ilar/OqjGKdxkZWfW97ekeT0wgiWNjGdqf8pPxiK508//Zkc0xrKMDOqchFT7B/oqAoQ+Vrx1txpPQ==", "dependencies": { "Microsoft.Extensions.Caching.Memory": "3.1.0", "Microsoft.Extensions.Configuration.Binder": "6.0.0", @@ -17,12 +17,12 @@ }, "Coravel.Mailer": { "type": "Direct", - "requested": "[7.0.0, )", - "resolved": "7.0.0", - "contentHash": "mxSlOOBxPjCAZruOpgXtubnZA9lD0DRgutApQmAsts7DoRfe0wTzqWrYjeZTiIzgVJZKZxJglN8duTvbPrw3jQ==", + "requested": "[7.1.0, )", + "resolved": "7.1.0", + "contentHash": "yMbUrwKl5/HbJeX8JkHa8Q3CPTJ3OmPyDSG7sULbXGEhzc2GiYIh7pmVhI1FFeL3VUtFavMDkS8PTwEeCpiwlg==", "dependencies": { - "MailKit": "4.3.0", - "Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation": "6.0.27" + "MailKit": "4.8.0", + "Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation": "6.0.36" } }, "EFCore.NamingConventions": { @@ -60,41 +60,41 @@ }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "pTFDEmZi3GheCSPrBxzyE63+d5unln2vYldo/nOm1xet/4rpEk2oJYcwpclPQ13E+LZBF9XixkgwYTUwqznlWg==", + "requested": "[9.0.2, )", + "resolved": "9.0.2", + "contentHash": "cCnaxji6nqIHHLAEhZ6QirXCvwJNi0Q/qCPLkRW5SqMYNuOwoQdGk1KAhW65phBq1VHGt7wLbadpuGPGqfiZuA==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "9.0.0", + "Microsoft.AspNetCore.JsonPatch": "9.0.2", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.OpenApi": { "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "FqUK5j1EOPNuFT7IafltZQ3cakqhSwVzH5ZW1MhZDe4pPXs9sJ2M5jom1Omsu+mwF2tNKKlRAzLRHQTZzbd+6Q==", + "requested": "[9.0.2, )", + "resolved": "9.0.2", + "contentHash": "JUndpjRNdG8GvzBLH/J4hen4ehWaPcshtiQ6+sUs1Bcj3a7dOsmWpDloDlpPeMOVSlhHwUJ3Xld0ClZjsFLgFQ==", "dependencies": { "Microsoft.OpenApi": "1.6.17" } }, "Microsoft.EntityFrameworkCore": { "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "wpG+nfnfDAw87R3ovAsUmjr3MZ4tYXf6bFqEPVAIKE6IfPml3DS//iX0DBnf8kWn5ZHSO5oi1m4d/Jf+1LifJQ==", + "requested": "[9.0.2, )", + "resolved": "9.0.2", + "contentHash": "P90ZuybgcpW32y985eOYxSoZ9IiL0UTYQlY0y1Pt1iHAnpZj/dQHREpSpry1RNvk8YjAeoAkWFdem5conqB9zQ==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "9.0.0", - "Microsoft.EntityFrameworkCore.Analyzers": "9.0.0", - "Microsoft.Extensions.Caching.Memory": "9.0.0", - "Microsoft.Extensions.Logging": "9.0.0" + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.2", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.2", + "Microsoft.Extensions.Logging": "9.0.2" } }, "Microsoft.EntityFrameworkCore.Design": { "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "Pqo8I+yHJ3VQrAoY0hiSncf+5P7gN/RkNilK5e+/K/yKh+yAWxdUAI6t0TG26a9VPlCa9FhyklzyFvRyj3YG9A==", + "requested": "[9.0.2, )", + "resolved": "9.0.2", + "contentHash": "WWRmTxb/yd05cTW+k32lLvIhffxilgYvwKHDxiqe7GRLKeceyMspuf5BRpW65sFF7S2G+Be9JgjUe1ypGqt9tg==", "dependencies": { "Humanizer.Core": "2.14.1", "Microsoft.Build.Framework": "17.8.3", @@ -102,33 +102,33 @@ "Microsoft.CodeAnalysis.CSharp": "4.8.0", "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.8.0", "Microsoft.CodeAnalysis.Workspaces.MSBuild": "4.8.0", - "Microsoft.EntityFrameworkCore.Relational": "9.0.0", - "Microsoft.Extensions.Caching.Memory": "9.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", - "Microsoft.Extensions.DependencyModel": "9.0.0", - "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.EntityFrameworkCore.Relational": "9.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.2", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2", + "Microsoft.Extensions.DependencyModel": "9.0.2", + "Microsoft.Extensions.Logging": "9.0.2", "Mono.TextTemplating": "3.0.0", - "System.Text.Json": "9.0.0" + "System.Text.Json": "9.0.2" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "zbnPX/JQ0pETRSUG9fNPBvpIq42Aufvs15gGYyNIMhCun9yhmWihz0WgsI7bSDPjxWTKBf8oX/zv6v2uZ3W9OQ==", + "requested": "[9.0.2, )", + "resolved": "9.0.2", + "contentHash": "AlEfp0DMz8E1h1Exi8LBrUCNmCYcGDfSM4F/uK1D1cYx/R3w0LVvlmjICqxqXTsy7BEZaCf5leRZY2FuPEiFaw==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "9.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0", - "Microsoft.Extensions.Options": "9.0.0", - "Microsoft.Extensions.Primitives": "9.0.0" + "Microsoft.Extensions.Caching.Abstractions": "9.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.Logging.Abstractions": "9.0.2", + "Microsoft.Extensions.Options": "9.0.2", + "Microsoft.Extensions.Primitives": "9.0.2" } }, "MimeKit": { "type": "Direct", - "requested": "[4.9.0, )", - "resolved": "4.9.0", - "contentHash": "DZXXMZzmAABDxFhOSMb6SE8KKxcRd/sk1E6aJTUE5ys2FWOQhznYV2Gl3klaaSfqKn27hQ32haqquH1J8Z6kJw==", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "GQofI17cH55XSh109hJmHaYMtSFqTX/eUek3UcV7hTnYayAIXZ6eHlv345tfdc+bQ/BrEnYOSZVzx9I3wpvvpg==", "dependencies": { "BouncyCastle.Cryptography": "2.5.0", "System.Formats.Asn1": "8.0.1", @@ -137,11 +137,11 @@ }, "Minio": { "type": "Direct", - "requested": "[6.0.3, )", - "resolved": "6.0.3", - "contentHash": "WHlkouclHtiK/pIXPHcjVmbeELHPtElj2qRSopFVpSmsFhZXeM10sPvczrkSPePsmwuvZdFryJ/hJzKu3XeLVg==", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "JckRL95hQ/eDHTQZ/BB7jeR0JyF+bOctMW6uriXHY5YPjCX61hiJGsswGjuDSEViKJEPxtPi3e4IwD/1TJ7PIw==", "dependencies": { - "CommunityToolkit.HighPerformance": "8.2.2", + "CommunityToolkit.HighPerformance": "8.3.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.1", "Microsoft.Extensions.Logging": "8.0.0", "System.IO.Hashing": "8.0.0", @@ -156,39 +156,39 @@ }, "NodaTime": { "type": "Direct", - "requested": "[3.2.0, )", - "resolved": "3.2.0", - "contentHash": "yoRA3jEJn8NM0/rQm78zuDNPA3DonNSZdsorMUj+dltc1D+/Lc5h9YXGqbEEZozMGr37lAoYkcSM/KjTVqD0ow==" + "requested": "[3.2.1, )", + "resolved": "3.2.1", + "contentHash": "D1aHhUfPQUxU2nfDCVuSLahpp0xCYZTmj/KNH3mSK/tStJYcx9HO9aJ0qbOP3hzjGPV/DXOqY2AHe27Nt4xs4g==" }, "Npgsql.EntityFrameworkCore.PostgreSQL": { "type": "Direct", - "requested": "[9.0.2, )", - "resolved": "9.0.2", - "contentHash": "cYdOGplIvr9KgsG8nJ8xnzBTImeircbgetlzS1OmepS5dAQW6PuGpVrLOKBNEwEvGYZPsV8037X5vZ/Dmpwz7Q==", + "requested": "[9.0.3, )", + "resolved": "9.0.3", + "contentHash": "1A6HpMPbzK+quxdtug1aDHI4BSRTgpi7OaDt8WQh7SFJd2sSQ0nNTZ7sYrwyxVf4AdKdN7XJL9tpiiJjRUaa4g==", "dependencies": { - "Microsoft.EntityFrameworkCore": "[9.0.0, 10.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[9.0.0, 10.0.0)", + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", "Npgsql": "9.0.2" } }, "Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime": { "type": "Direct", - "requested": "[9.0.2, )", - "resolved": "9.0.2", - "contentHash": "+mfwiRCK+CAKTkeBZCuQuMaOwM/yMX8B65515PS1le9TUjlG8DobuAmb48MSR/Pr/YMvU1tV8FFEFlyQviQzrg==", + "requested": "[9.0.3, )", + "resolved": "9.0.3", + "contentHash": "Eks1o3NfIbS3EHhrXC0QABrQab7CJ64C2+kF0YJWLwlH/tu3ExrgrSLpLI6INdeMYcLr2PXu71LjJsrQNVciYg==", "dependencies": { - "Npgsql.EntityFrameworkCore.PostgreSQL": "9.0.2", + "Npgsql.EntityFrameworkCore.PostgreSQL": "9.0.3", "Npgsql.NodaTime": "9.0.2" } }, "Npgsql.Json.NET": { "type": "Direct", - "requested": "[9.0.2, )", - "resolved": "9.0.2", - "contentHash": "E81dvvpNtS4WigxZu16OAFxVvPvbEkXI7vJXZzEp7GQ03MArF5V4HBb7KXDzTaE5ZQ0bhCUFoMTODC6Z8mu27g==", + "requested": "[9.0.3, )", + "resolved": "9.0.3", + "contentHash": "lN8p9UKkoXaGUhX3DHg/1W6YeEfbjQiQ7XrJSGREUoDHXOLxDQHJnZ49P/9P2s/pH6HTVgTgT5dijpKoRLN0vQ==", "dependencies": { "Newtonsoft.Json": "13.0.3", - "Npgsql": "9.0.2" + "Npgsql": "9.0.3" } }, "prometheus-net": { @@ -212,24 +212,24 @@ }, "Roslynator.Analyzers": { "type": "Direct", - "requested": "[4.12.9, )", - "resolved": "4.12.9", - "contentHash": "X6lDpN/D5wuinq37KIx+l3GSUe9No+8bCjGBTI5sEEtxapLztkHg6gzNVhMXpXw8P+/5gFYxTXJ5Pf8O4iNz/w==" + "requested": "[4.13.1, )", + "resolved": "4.13.1", + "contentHash": "KZpLy6ZlCebMk+d/3I5KU2R7AOb4LNJ6tPJqPtvFXmO8bEBHQvCIAvJOnY2tu4C9/aVOROTDYUFADxFqw1gh/g==" }, "Scalar.AspNetCore": { "type": "Direct", - "requested": "[1.2.55, )", - "resolved": "1.2.55", - "contentHash": "zArlr6nfPQMRwyia0WFirsyczQby51GhNgWITiEIRkot+CVGZSGQ4oWGqExO11/6x26G+mcQo9Oft1mGpN0/ZQ==" + "requested": "[2.0.18, )", + "resolved": "2.0.18", + "contentHash": "nS8Sw6wRO1A/dARn3q9R6znIBfddJcmAczI5uMROBGWkO2KG/ad/Ld+UeUePTxGr1+6humJSOxI7An+q4q3oGA==" }, "Sentry.AspNetCore": { "type": "Direct", - "requested": "[4.13.0, )", - "resolved": "4.13.0", - "contentHash": "1cH9hSvjRbTkcpjUejFTrTC3jMIiOrcZ0DIvt16+AYqXhuxPEnI56npR1nhv+7WUGyhyp5cHFIZqrKnyrrGP0w==", + "requested": "[5.2.0, )", + "resolved": "5.2.0", + "contentHash": "vEKanBDOxCnEQrcMq3j47z8HOblRfiyJotdm9Fyc24cmLrLsTYZnWWprCYstt++M9bGSXYf4jrM2aaWxgJ8aww==", "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "8.0.0", - "Sentry.Extensions.Logging": "4.13.0" + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Sentry.Extensions.Logging": "5.2.0" } }, "Serilog": { @@ -264,12 +264,12 @@ }, "Serilog.Sinks.Seq": { "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "z5ig56/qzjkX6Fj4U/9m1g8HQaQiYPMZS4Uevtjg1I+WWzoGSf5t/E+6JbMP/jbZYhU63bA5NJN5y0x+qqx2Bw==", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==", "dependencies": { - "Serilog": "4.0.0", - "Serilog.Sinks.File": "5.0.0" + "Serilog": "4.2.0", + "Serilog.Sinks.File": "6.0.0" } }, "SixLabors.ImageSharp": { @@ -280,9 +280,9 @@ }, "System.Text.Json": { "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==" + "requested": "[9.0.2, )", + "resolved": "9.0.2", + "contentHash": "4TY2Yokh5Xp8XHFhsY9y84yokS7B0rhkaZCXuRiKppIiKwPVH4lVSFD9EEFzRpXdBM5ZeZXD43tc2vB6njEwwQ==" }, "System.Text.RegularExpressions": { "type": "Direct", @@ -306,8 +306,8 @@ }, "CommunityToolkit.HighPerformance": { "type": "Transitive", - "resolved": "8.2.2", - "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + "resolved": "8.3.0", + "contentHash": "2zc0Wfr9OtEbLqm6J1Jycim/nKmYv+v12CytJ3tZGNzw7n3yjh1vNCMX0kIBaFBk3sw8g0pMR86QJGXGlArC+A==" }, "EntityFrameworkCore.Exceptions.Common": { "type": "Transitive", @@ -319,16 +319,17 @@ }, "MailKit": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "jVmB3Nr0JpqhyMiXOGWMin+QvRKpucGpSFBCav9dG6jEJPdBV+yp1RHVpKzxZPfT+0adaBuZlMFdbIciZo1EWA==", + "resolved": "4.8.0", + "contentHash": "zZ1UoM4FUnSFUJ9fTl5CEEaejR0DNP6+FDt1OfXnjg4igZntcir1tg/8Ufd6WY5vrpmvToAjluYqjVM24A+5lA==", "dependencies": { - "MimeKit": "4.3.0" + "MimeKit": "4.8.0", + "System.Formats.Asn1": "8.0.1" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "/4UONYoAIeexPoAmbzBPkVGA6KAY7t0BM+1sr0fKss2V1ERCdcM+Llub4X5Ma+LJ60oPp6KzM0e3j+Pp/JHCNw==", + "resolved": "9.0.2", + "contentHash": "bZMRhazEBgw9aZ5EBGYt0017CSd+aecsUCnppVjSa1SzWH6C1ieTSQZRAe+H0DzAVzWAoK7HLwKnQUPioopPrA==", "dependencies": { "Microsoft.CSharp": "4.7.0", "Newtonsoft.Json": "13.0.3" @@ -336,27 +337,27 @@ }, "Microsoft.AspNetCore.Mvc.Razor.Extensions": { "type": "Transitive", - "resolved": "6.0.27", - "contentHash": "trwJhFrTQuJTImmixMsDnDgRE8zuTzAUAot7WqiUlmjNzlJWLOaXXBpeA/xfNJvZuOsyGjC7RIzEyNyDGhDTLg==", + "resolved": "6.0.36", + "contentHash": "KFHRhrGAnd80310lpuWzI7Cf+GidS/h3JaPDFFnSmSGjCxB5vkBv5E+TXclJCJhqPtgNxg+keTC5SF1T9ieG5w==", "dependencies": { - "Microsoft.AspNetCore.Razor.Language": "6.0.27", - "Microsoft.CodeAnalysis.Razor": "6.0.27" + "Microsoft.AspNetCore.Razor.Language": "6.0.36", + "Microsoft.CodeAnalysis.Razor": "6.0.36" } }, "Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation": { "type": "Transitive", - "resolved": "6.0.27", - "contentHash": "C6Gh/sAuUACxNtllcH4ZniWtPcGbixJuB1L5RXwoUe1a1wM6rpQ2TVMWpX2+cgeBj8U/izJyWY+nJ4Lz8mmMKA==", + "resolved": "6.0.36", + "contentHash": "0OG/wNedsQ9kTMrFuvrUDoJvp6Fxj6BzWgi7AUCluOENxu/0PzbjY9AC5w6mZJ22/AFxn2gFc2m0yOBTfQbiPg==", "dependencies": { - "Microsoft.AspNetCore.Mvc.Razor.Extensions": "6.0.27", - "Microsoft.CodeAnalysis.Razor": "6.0.27", - "Microsoft.Extensions.DependencyModel": "6.0.0" + "Microsoft.AspNetCore.Mvc.Razor.Extensions": "6.0.36", + "Microsoft.CodeAnalysis.Razor": "6.0.36", + "Microsoft.Extensions.DependencyModel": "6.0.2" } }, "Microsoft.AspNetCore.Razor.Language": { "type": "Transitive", - "resolved": "6.0.27", - "contentHash": "bI1kIZBgx7oJIB7utPrw4xIgcj7Pdx1jnHMTdsG54U602OcGpBzbfAuKaWs+LVdj+zZVuZsCSoRIZNJKTDP7Hw==" + "resolved": "6.0.36", + "contentHash": "n5Mg5D0aRrhHJJ6bJcwKqQydIFcgUq0jTlvuynoJjwA2IvAzh8Aqf9cpYagofQbIlIXILkCP6q6FgbngyVtpYA==" }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", @@ -410,10 +411,10 @@ }, "Microsoft.CodeAnalysis.Razor": { "type": "Transitive", - "resolved": "6.0.27", - "contentHash": "NAUvSjH8QY8gPp/fXjHhi3MnQEGtSJA0iRT/dT3RKO3AdGACPJyGmKEKxLag9+Kf2On51yGHT9DEPPnK3hyezg==", + "resolved": "6.0.36", + "contentHash": "RTLNJglWezr/1IkiWdtDpPYW7X7lwa4ow8E35cHt+sWdWxOnl+ayQqMy1RfbaLp7CLmRmgXSzMMZZU3D4vZi9Q==", "dependencies": { - "Microsoft.AspNetCore.Razor.Language": "6.0.27", + "Microsoft.AspNetCore.Razor.Language": "6.0.36", "Microsoft.CodeAnalysis.CSharp": "4.0.0", "Microsoft.CodeAnalysis.Common": "4.0.0" } @@ -449,48 +450,48 @@ }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "fnmifFL8KaA4ZNLCVgfjCWhZUFxkrDInx5hR4qG7Q8IEaSiy/6VOSRFyx55oH7MV4y7wM3J3EE90nSpcVBI44Q==" + "resolved": "9.0.2", + "contentHash": "oVSjNSIYHsk0N66eqAWgDcyo9etEFbUswbz7SmlYR6nGp05byHrJAYM5N8U2aGWJWJI6WvIC2e4TXJgH6GZ6HQ==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "Qje+DzXJOKiXF72SL0XxNlDtTkvWWvmwknuZtFahY5hIQpRKO59qnGuERIQ3qlzuq5x4bAJ8WMbgU5DLhBgeOQ==" + "resolved": "9.0.2", + "contentHash": "w4jzX7XI+L3erVGzbHXpx64A3QaLXxqG3f1vPpGYYZGpxOIHkh7e4iLLD7cq4Ng1vjkwzWl5ZJp0Kj/nHsgFYg==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "j+msw6fWgAE9M3Q/5B9Uhv7pdAdAQUvFPJAiBJmoy+OXvehVbfbCE8ftMAa51Uo2ZeiqVnHShhnv4Y4UJJmUzA==", + "resolved": "9.0.2", + "contentHash": "r7O4N5uaM95InVSGUj7SMOQWN0f1PBF2Y30ow7Jg+pGX5GJCRVd/1fq83lQ50YMyq+EzyHac5o4CDQA2RsjKJQ==", "dependencies": { - "Microsoft.EntityFrameworkCore": "9.0.0", - "Microsoft.Extensions.Caching.Memory": "9.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", - "Microsoft.Extensions.Logging": "9.0.0" + "Microsoft.EntityFrameworkCore": "9.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.2", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2", + "Microsoft.Extensions.Logging": "9.0.2" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "FPWZAa9c0H4dvOj351iR1jkUIs4u9ykL4Bm592yhjDyO5lCoWd+TMAHx2EMbarzUvCvgjWjJIoC6//Q9kH6YhA==", + "resolved": "9.0.2", + "contentHash": "a7QhA25n+BzSM5r5d7JznfyluMBGI7z3qyLlFviZ1Eiqv6DdiK27sLZdP/rpYirBM6UYAKxu5TbmfhIy13GN9A==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.0" + "Microsoft.Extensions.Primitives": "9.0.2" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "resolved": "9.0.0", + "contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==", + "resolved": "9.0.2", + "contentHash": "I0O/270E/lUNqbBxlRVjxKOMZyYjP88dpEgQTveml+h2lTzAP4vbawLVwjS9SC7lKaU893bwyyNz0IVJYsm9EA==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.0" + "Microsoft.Extensions.Primitives": "9.0.2" } }, "Microsoft.Extensions.Configuration.Binder": { @@ -503,30 +504,30 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==", + "resolved": "9.0.2", + "contentHash": "ZffbJrskOZ40JTzcTyKwFHS5eACSWp2bUQBBApIgGV+es8RaTD4OxUG7XxFr3RIPLXtYQ1jQzF2DjKB5fZn7Qg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==" + "resolved": "9.0.2", + "contentHash": "MNe7GSTBf3jQx5vYrXF0NZvn6l7hUKF6J54ENfAgCO8y6xjN1XUmKKWG464LP2ye6QqDiA1dkaWEZBYnhoZzjg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==" + "resolved": "9.0.2", + "contentHash": "3ImbcbS68jy9sKr9Z9ToRbEEX0bvIRdb8zyf5ebtL9Av2CUCGHvaO5wsSXfRfAjr60Vrq0tlmNji9IzAxW6EOw==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==", + "resolved": "9.0.0", + "contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==", "dependencies": { - "Microsoft.Extensions.Configuration": "8.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { @@ -559,49 +560,49 @@ } }, "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Diagnostics": "8.0.0", - "Microsoft.Extensions.Logging": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0" - } - }, - "Microsoft.Extensions.Logging": { "type": "Transitive", "resolved": "9.0.0", - "contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==", + "contentHash": "DqI4q54U4hH7bIAq9M5a/hl5Odr/KBAoaZ0dcT4OgutD8dook34CbkvAfAIzkMVjYXiL+E5ul9etwwqiX4PHGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Diagnostics": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", "Microsoft.Extensions.Logging.Abstractions": "9.0.0", "Microsoft.Extensions.Options": "9.0.0" } }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.2", + "contentHash": "loV/0UNpt2bD+6kCDzFALVE63CDtqzPeC0LAetkdhiEr/tTNbvOlQ7CBResH7BQBd3cikrwiBfaHdyHMFUlc2g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.2", + "Microsoft.Extensions.Logging.Abstractions": "9.0.2", + "Microsoft.Extensions.Options": "9.0.2" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", + "resolved": "9.0.2", + "contentHash": "dV9s2Lamc8jSaqhl2BQSPn/AryDIH2sSbQUyLitLXV0ROmsb+SROnn2cH939JFbsNrnf3mIM3GNRKT7P0ldwLg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "ixXXV0G/12g6MXK65TLngYN9V5hQQRuV+fZi882WIoVJT7h5JvoYoxTEwCgdqwLjSneqh1O+66gM8sMr9z/rsQ==", + "resolved": "9.0.0", + "contentHash": "H05HiqaNmg6GjH34ocYE9Wm1twm3Oz2aXZko8GTwGBzM7op2brpAA8pJ5yyD1OpS1mXUtModBYOlcZ/wXeWsSg==", "dependencies": { - "Microsoft.Extensions.Configuration": "8.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", - "Microsoft.Extensions.Configuration.Binder": "8.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Logging": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0" } }, "Microsoft.Extensions.ObjectPool": { @@ -611,29 +612,29 @@ }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", + "resolved": "9.0.2", + "contentHash": "zr98z+AN8+isdmDmQRuEJ/DAKZGUTHmdv3t0ZzjHvNqvA44nAgkXE9kYtfoN6581iALChhVaSw2Owt+Z2lVbkQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Primitives": "9.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.Primitives": "9.0.2" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "resolved": "9.0.0", + "contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", - "Microsoft.Extensions.Configuration.Binder": "8.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==" + "resolved": "9.0.2", + "contentHash": "puBMtKe/wLuYa7H6docBkLlfec+h8L35DXqsDKKJgW0WY5oCwJ3cBJKcDaZchv6knAyqOMfsl6VUbaR++E5LXA==" }, "Microsoft.NETCore.Platforms": { "type": "Transitive", @@ -668,8 +669,8 @@ }, "Npgsql": { "type": "Transitive", - "resolved": "9.0.2", - "contentHash": "hCbO8box7i/XXiTFqCJ3GoowyLqx3JXxyrbOJ6om7dr+eAknvBNhhUHeJVGAQo44sySZTfdVffp4BrtPeLZOAA==", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "8.0.2" } @@ -685,18 +686,18 @@ }, "Sentry": { "type": "Transitive", - "resolved": "4.13.0", - "contentHash": "Wfw3M1WpFcrYaGzPm7QyUTfIOYkVXQ1ry6p4WYjhbLz9fPwV23SGQZTFDpdox67NHM0V0g1aoQ4YKLm4ANtEEg==" + "resolved": "5.2.0", + "contentHash": "b3aZSOU2CjlIIFRtPRbXParKQ+9PF+JOqkSD7Gxq6PiR07t1rnK+crPtdrWMXfW6PVo/s67trCJ+fuLsgTeADw==" }, "Sentry.Extensions.Logging": { "type": "Transitive", - "resolved": "4.13.0", - "contentHash": "yZ5+TtJKWcss6cG17YjnovImx4X56T8O6Qy6bsMC8tMDttYy8J7HJ2F+WdaZNyjOCo0Rfi6N2gc+Clv/5pf+TQ==", + "resolved": "5.2.0", + "contentHash": "546bHsERKY7/pG5T4mVIp6WbHnQPMst6VDuxSaeU5DhQHLfh7KhgMmkdZ4Xvdlr95fvWk5/bX2xbipy6qoh/1A==", "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "8.0.0", - "Microsoft.Extensions.Http": "8.0.0", - "Microsoft.Extensions.Logging.Configuration": "8.0.0", - "Sentry": "4.13.0" + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.Http": "9.0.0", + "Microsoft.Extensions.Logging.Configuration": "9.0.0", + "Sentry": "5.2.0" } }, "Serilog.Extensions.Hosting": { diff --git a/Foxnouns.DataMigrator/Foxnouns.DataMigrator.csproj b/Foxnouns.DataMigrator/Foxnouns.DataMigrator.csproj index 5fde110..ead15ce 100644 --- a/Foxnouns.DataMigrator/Foxnouns.DataMigrator.csproj +++ b/Foxnouns.DataMigrator/Foxnouns.DataMigrator.csproj @@ -12,9 +12,9 @@ - - - + + + From 218c756a7075902bc3d6a4a762639ce9013b2ddd Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 28 Feb 2025 16:37:15 +0100 Subject: [PATCH 06/29] feat(backend): make field limits configurable --- Foxnouns.Backend/Config.cs | 5 + .../Controllers/MembersController.cs | 15 +- .../Controllers/UsersController.cs | 9 +- .../ValidationService.Fields.cs} | 224 +++++++++--------- Foxnouns.Backend/Utils/Limits.cs | 23 -- .../Utils/ValidationUtils.Strings.cs | 2 +- Foxnouns.DataMigrator/UserMigrator.cs | 4 +- 7 files changed, 133 insertions(+), 149 deletions(-) rename Foxnouns.Backend/{Utils/ValidationUtils.Fields.cs => Services/ValidationService.Fields.cs} (56%) delete mode 100644 Foxnouns.Backend/Utils/Limits.cs diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 0ed8b7a..d1e6df5 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -99,6 +99,11 @@ public class Config { public int MaxMemberCount { get; init; } = 1000; + public int MaxFields { get; init; } = 25; + public int MaxFieldNameLength { get; init; } = 100; + public int MaxFieldEntryTextLength { get; init; } = 100; + public int MaxFieldEntries { get; init; } = 100; + public int MaxUsernameLength { get; init; } = 40; public int MaxMemberNameLength { get; init; } = 100; public int MaxDisplayNameLength { get; init; } = 100; diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 8f832c1..dbea99c 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -81,13 +81,13 @@ public class MembersController( ("display_name", validationService.ValidateDisplayName(req.DisplayName)), ("bio", validationService.ValidateBio(req.Bio)), ("avatar", validationService.ValidateAvatar(req.Avatar)), - .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), - .. ValidationUtils.ValidateFieldEntries( + .. validationService.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), + .. validationService.ValidateFieldEntries( req.Names?.ToArray(), CurrentUser!.CustomPreferences, "names" ), - .. ValidationUtils.ValidatePronouns( + .. validationService.ValidatePronouns( req.Pronouns?.ToArray(), CurrentUser!.CustomPreferences ), @@ -191,7 +191,7 @@ public class MembersController( if (req.Names != null) { errors.AddRange( - ValidationUtils.ValidateFieldEntries( + validationService.ValidateFieldEntries( req.Names, CurrentUser!.CustomPreferences, "names" @@ -203,7 +203,7 @@ public class MembersController( if (req.Pronouns != null) { errors.AddRange( - ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences) + validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences) ); member.Pronouns = req.Pronouns.ToList(); } @@ -211,7 +211,10 @@ public class MembersController( if (req.Fields != null) { errors.AddRange( - ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences) + validationService.ValidateFields( + req.Fields.ToList(), + CurrentUser!.CustomPreferences + ) ); member.Fields = req.Fields.ToList(); } diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 6ccbff0..f7e3115 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -91,7 +91,7 @@ public class UsersController( if (req.Names != null) { errors.AddRange( - ValidationUtils.ValidateFieldEntries( + validationService.ValidateFieldEntries( req.Names, CurrentUser!.CustomPreferences, "names" @@ -103,7 +103,7 @@ public class UsersController( if (req.Pronouns != null) { errors.AddRange( - ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences) + validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences) ); user.Pronouns = req.Pronouns.ToList(); } @@ -111,7 +111,10 @@ public class UsersController( if (req.Fields != null) { errors.AddRange( - ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences) + validationService.ValidateFields( + req.Fields.ToList(), + CurrentUser!.CustomPreferences + ) ); user.Fields = req.Fields.ToList(); } diff --git a/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs b/Foxnouns.Backend/Services/ValidationService.Fields.cs similarity index 56% rename from Foxnouns.Backend/Utils/ValidationUtils.Fields.cs rename to Foxnouns.Backend/Services/ValidationService.Fields.cs index 0235eb6..e2cbff3 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs +++ b/Foxnouns.Backend/Services/ValidationService.Fields.cs @@ -15,9 +15,9 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; -namespace Foxnouns.Backend.Utils; +namespace Foxnouns.Backend.Services; -public static partial class ValidationUtils +public partial class ValidationService { public static readonly string[] DefaultStatusOptions = [ @@ -28,7 +28,7 @@ public static partial class ValidationUtils "avoid", ]; - public static IEnumerable<(string, ValidationError?)> ValidateFields( + public IEnumerable<(string, ValidationError?)> ValidateFields( List? fields, IReadOnlyDictionary customPreferences ) @@ -37,7 +37,7 @@ public static partial class ValidationUtils return []; var errors = new List<(string, ValidationError?)>(); - if (fields.Count > 25) + if (fields.Count > _limits.MaxFields) { errors.Add( ( @@ -45,7 +45,7 @@ public static partial class ValidationUtils ValidationError.LengthError( "Too many fields", 0, - Limits.FieldLimit, + _limits.MaxFields, fields.Count ) ) @@ -53,39 +53,38 @@ public static partial class ValidationUtils } // No overwhelming this function, thank you - if (fields.Count > 100) + if (fields.Count > _limits.MaxFields + 50) return errors; foreach ((Field? field, int index) in fields.Select((field, index) => (field, index))) { - switch (field.Name.Length) + if (field.Name.Length > _limits.MaxFieldNameLength) { - case > Limits.FieldNameLimit: - errors.Add( - ( - $"fields.{index}.name", - ValidationError.LengthError( - "Field name is too long", - 1, - Limits.FieldNameLimit, - field.Name.Length - ) + errors.Add( + ( + $"fields.{index}.name", + ValidationError.LengthError( + "Field name is too long", + 1, + _limits.MaxFieldNameLength, + field.Name.Length ) - ); - break; - case < 1: - errors.Add( - ( - $"fields.{index}.name", - ValidationError.LengthError( - "Field name is too short", - 1, - Limits.FieldNameLimit, - field.Name.Length - ) + ) + ); + } + else if (field.Name.Length < 1) + { + errors.Add( + ( + $"fields.{index}.name", + ValidationError.LengthError( + "Field name is too short", + 1, + _limits.MaxFieldNameLength, + field.Name.Length ) - ); - break; + ) + ); } errors = errors @@ -102,7 +101,7 @@ public static partial class ValidationUtils return errors; } - public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries( + public IEnumerable<(string, ValidationError?)> ValidateFieldEntries( FieldEntry[]? entries, IReadOnlyDictionary customPreferences, string errorPrefix = "fields" @@ -112,7 +111,7 @@ public static partial class ValidationUtils return []; var errors = new List<(string, ValidationError?)>(); - if (entries.Length > Limits.FieldEntriesLimit) + if (entries.Length > _limits.MaxFieldEntries) { errors.Add( ( @@ -120,7 +119,7 @@ public static partial class ValidationUtils ValidationError.LengthError( "Field has too many entries", 0, - Limits.FieldEntriesLimit, + _limits.MaxFieldEntries, entries.Length ) ) @@ -128,7 +127,7 @@ public static partial class ValidationUtils } // Same as above, no overwhelming this function with a ridiculous amount of entries - if (entries.Length > Limits.FieldEntriesLimit + 50) + if (entries.Length > _limits.MaxFieldEntries + 50) return errors; string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray(); @@ -139,34 +138,33 @@ public static partial class ValidationUtils ) ) { - switch (entry.Value.Length) + if (entry.Value.Length > _limits.MaxFieldEntryTextLength) { - case > Limits.FieldEntryTextLimit: - errors.Add( - ( - $"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError( - "Field value is too long", - 1, - Limits.FieldEntryTextLimit, - entry.Value.Length - ) + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Field value is too long", + 1, + _limits.MaxFieldEntryTextLength, + entry.Value.Length ) - ); - break; - case < 1: - errors.Add( - ( - $"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError( - "Field value is too short", - 1, - Limits.FieldEntryTextLimit, - entry.Value.Length - ) + ) + ); + } + else if (entry.Value.Length < 1) + { + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Field value is too short", + 1, + _limits.MaxFieldEntryTextLength, + entry.Value.Length ) - ); - break; + ) + ); } if ( @@ -186,7 +184,7 @@ public static partial class ValidationUtils return errors; } - public static IEnumerable<(string, ValidationError?)> ValidatePronouns( + public IEnumerable<(string, ValidationError?)> ValidatePronouns( Pronoun[]? entries, IReadOnlyDictionary customPreferences, string errorPrefix = "pronouns" @@ -196,7 +194,7 @@ public static partial class ValidationUtils return []; var errors = new List<(string, ValidationError?)>(); - if (entries.Length > Limits.FieldEntriesLimit) + if (entries.Length > _limits.MaxFieldEntries) { errors.Add( ( @@ -204,7 +202,7 @@ public static partial class ValidationUtils ValidationError.LengthError( "Too many pronouns", 0, - Limits.FieldEntriesLimit, + _limits.MaxFieldEntries, entries.Length ) ) @@ -212,7 +210,7 @@ public static partial class ValidationUtils } // Same as above, no overwhelming this function with a ridiculous amount of entries - if (entries.Length > Limits.FieldEntriesLimit + 50) + if (entries.Length > _limits.MaxFieldEntries + 50) return errors; string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray(); @@ -221,66 +219,64 @@ public static partial class ValidationUtils (Pronoun? entry, int entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)) ) { - switch (entry.Value.Length) + if (entry.Value.Length > _limits.MaxFieldEntryTextLength) { - case > Limits.FieldEntryTextLimit: - errors.Add( - ( - $"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError( - "Pronoun value is too long", - 1, - Limits.FieldEntryTextLimit, - entry.Value.Length - ) + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Pronoun value is too long", + 1, + _limits.MaxFieldEntryTextLength, + entry.Value.Length ) - ); - break; - case < 1: - errors.Add( - ( - $"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError( - "Pronoun value is too short", - 1, - Limits.FieldEntryTextLimit, - entry.Value.Length - ) + ) + ); + } + else if (entry.Value.Length < 1) + { + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Pronoun value is too short", + 1, + _limits.MaxFieldEntryTextLength, + entry.Value.Length ) - ); - break; + ) + ); } if (entry.DisplayText != null) { - switch (entry.DisplayText.Length) + if (entry.DisplayText.Length > _limits.MaxFieldEntryTextLength) { - case > Limits.FieldEntryTextLimit: - errors.Add( - ( - $"{errorPrefix}.{entryIdx}.display_text", - ValidationError.LengthError( - "Pronoun display text is too long", - 1, - Limits.FieldEntryTextLimit, - entry.Value.Length - ) + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.display_text", + ValidationError.LengthError( + "Pronoun display text is too long", + 1, + _limits.MaxFieldEntryTextLength, + entry.Value.Length ) - ); - break; - case < 1: - errors.Add( - ( - $"{errorPrefix}.{entryIdx}.display_text", - ValidationError.LengthError( - "Pronoun display text is too short", - 1, - Limits.FieldEntryTextLimit, - entry.Value.Length - ) + ) + ); + } + else if (entry.DisplayText.Length < 1) + { + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.display_text", + ValidationError.LengthError( + "Pronoun display text is too short", + 1, + _limits.MaxFieldEntryTextLength, + entry.Value.Length ) - ); - break; + ) + ); } } diff --git a/Foxnouns.Backend/Utils/Limits.cs b/Foxnouns.Backend/Utils/Limits.cs deleted file mode 100644 index 3010d46..0000000 --- a/Foxnouns.Backend/Utils/Limits.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -namespace Foxnouns.Backend.Utils; - -public static class Limits -{ - public const int FieldLimit = 25; - public const int FieldNameLimit = 100; - public const int FieldEntryTextLimit = 100; - public const int FieldEntriesLimit = 100; -} diff --git a/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs b/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs index 1a99993..82ee485 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs @@ -20,7 +20,7 @@ public static partial class ValidationUtils public static ValidationError? ValidateReportContext(string? context) => context?.Length > MaximumReportContextLength - ? ValidationError.GenericValidationError("Avatar is too large", null) + ? ValidationError.GenericValidationError("Report context is too long", null) : null; public const int MinimumPasswordLength = 12; diff --git a/Foxnouns.DataMigrator/UserMigrator.cs b/Foxnouns.DataMigrator/UserMigrator.cs index df895b9..ee46878 100644 --- a/Foxnouns.DataMigrator/UserMigrator.cs +++ b/Foxnouns.DataMigrator/UserMigrator.cs @@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; using Dapper; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; -using Foxnouns.Backend.Utils; +using Foxnouns.Backend.Services; using Foxnouns.DataMigrator.Models; using NodaTime.Extensions; using Npgsql; @@ -260,6 +260,6 @@ public class UserMigrator( { if (_preferenceIds.TryGetValue(id, out Snowflake preferenceId)) return preferenceId.ToString(); - return ValidationUtils.DefaultStatusOptions.Contains(id) ? id : "okay"; + return ValidationService.DefaultStatusOptions.Contains(id) ? id : "okay"; } } From a2485367894f52793227b0470d8e2e966b47b406 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 28 Feb 2025 16:47:21 +0100 Subject: [PATCH 07/29] fix typo in DOCKER.md --- DOCKER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DOCKER.md b/DOCKER.md index b485eb7..a007aab 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -2,7 +2,7 @@ 1. Copy `docker/config.example.ini` to `docker/config.ini`, and change the settings to your liking. 2. Copy `docker/proxy-config.example.json` to `docker/proxy-config.json`, and do the same. -3. Copy `docker/frontend.example.env` to `docker/frontend.env`, and do th esame. +3. Copy `docker/frontend.example.env` to `docker/frontend.env`, and do the same. 4. Build with `docker compose build` 5. Run with `docker compose up` From 7d6d4631b81168bf67df25c34d4d8cf2750a9942 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 28 Feb 2025 16:50:57 +0100 Subject: [PATCH 08/29] fix(frontend): don't reference email auth in text if it's disabled --- Foxnouns.Frontend/src/lib/i18n/locales/en.json | 3 ++- Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 7145181..fe10b04 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -86,7 +86,8 @@ "unlink-discord-header": "Unlink Discord account", "unlink-confirmation-1": "Are you sure you want to unlink {{username}} from your account?", "unlink-confirmation-2": "You will no longer be able to use this account to log in. Please make sure at least one of your other linked accounts is accessible before continuing.", - "unlink-button": "Unlink account" + "unlink-button": "Unlink account", + "log-in-3rd-party-desc-no-email": "You can use any of the following services to log in. You can add or remove others at any time." }, "error": { "bad-request-header": "Something was wrong with your input", diff --git a/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte b/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte index ee4d040..3efbfa0 100644 --- a/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte +++ b/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte @@ -50,8 +50,13 @@
    {/if}
    -

    {$t("auth.log-in-3rd-party-header")}

    -

    {$t("auth.log-in-3rd-party-desc")}

    + {#if data.urls.email_enabled} +

    {$t("auth.log-in-3rd-party-header")}

    +

    {$t("auth.log-in-3rd-party-desc")}

    + {:else} +

    {$t("title.log-in")}

    +

    {$t("auth.log-in-3rd-party-desc-no-email")}

    + {/if}
    {#if data.urls.discord} From cd24196cd126b2b7de3ca0d0b117b63aa1eecb8a Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 28 Feb 2025 16:53:53 +0100 Subject: [PATCH 09/29] chore(backend): format --- Foxnouns.Backend/Services/EmailRateLimiter.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Foxnouns.Backend/Services/EmailRateLimiter.cs b/Foxnouns.Backend/Services/EmailRateLimiter.cs index 3a1a81a..cc2dbb4 100644 --- a/Foxnouns.Backend/Services/EmailRateLimiter.cs +++ b/Foxnouns.Backend/Services/EmailRateLimiter.cs @@ -23,8 +23,11 @@ public class EmailRateLimiter { private readonly ConcurrentDictionary _limiters = new(); - private readonly FixedWindowRateLimiterOptions _limiterOptions = - new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 }; + private readonly FixedWindowRateLimiterOptions _limiterOptions = new() + { + Window = TimeSpan.FromHours(2), + PermitLimit = 3, + }; private RateLimiter GetLimiter(string bucket) => _limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions)); From 7759225428cc2540a5d292f5d67bd3606adc957a Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 4 Mar 2025 17:03:39 +0100 Subject: [PATCH 10/29] refactor(backend): replace coravel with hangfire for background jobs for *some reason*, coravel locks a persistent job queue behind a paywall. this means that if the server ever crashes, all pending jobs are lost. this is... not good, so we're switching to hangfire for that instead. coravel is still used for emails, though. BREAKING CHANGE: Foxnouns.NET now requires Redis to work. the EFCore storage for hangfire doesn't work well enough, unfortunately. --- Foxnouns.Backend/Config.cs | 1 + .../Authentication/AuthController.cs | 2 +- .../Authentication/EmailAuthController.cs | 2 +- .../Controllers/ExportsController.cs | 15 +-- .../Controllers/FlagsController.cs | 9 +- .../Controllers/MembersController.cs | 10 +- .../Controllers/UsersController.cs | 6 +- Foxnouns.Backend/Database/DatabaseContext.cs | 2 - .../20250304155708_RemoveTemporaryKeys.cs | 55 +++++++++++ .../DatabaseContextModelSnapshot.cs | 35 +------ .../Database/Models/TemporaryKey.cs | 25 ----- .../Extensions/ImageObjectExtensions.cs | 10 +- .../Extensions/KeyCacheExtensions.cs | 50 ++++------ .../Extensions/WebApplicationExtensions.cs | 13 ++- Foxnouns.Backend/Foxnouns.Backend.csproj | 4 + ...ortInvocable.cs => CreateDataExportJob.cs} | 26 +++--- Foxnouns.Backend/Jobs/CreateFlagInvocable.cs | 30 +++--- ...eInvocable.cs => MemberAvatarUpdateJob.cs} | 22 +++-- Foxnouns.Backend/Jobs/Payloads.cs | 2 - ...ateInvocable.cs => UserAvatarUpdateJob.cs} | 22 +++-- Foxnouns.Backend/Program.cs | 15 +++ Foxnouns.Backend/Services/KeyCacheService.cs | 92 ++++--------------- .../Services/ModerationService.cs | 17 +--- Foxnouns.Backend/packages.lock.json | 76 +++++++++++++++ 24 files changed, 272 insertions(+), 269 deletions(-) create mode 100644 Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs delete mode 100644 Foxnouns.Backend/Database/Models/TemporaryKey.cs rename Foxnouns.Backend/Jobs/{CreateDataExportInvocable.cs => CreateDataExportJob.cs} (93%) rename Foxnouns.Backend/Jobs/{MemberAvatarUpdateInvocable.cs => MemberAvatarUpdateJob.cs} (86%) rename Foxnouns.Backend/Jobs/{UserAvatarUpdateInvocable.cs => UserAvatarUpdateJob.cs} (88%) diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index d1e6df5..b48a2c4 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -55,6 +55,7 @@ public class Config public bool? EnablePooling { get; init; } public int? Timeout { get; init; } public int? MaxPoolSize { get; init; } + public string Redis { get; init; } = string.Empty; } public class StorageConfig diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 0d95eb2..39d3b11 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -46,7 +46,7 @@ public class AuthController( config.GoogleAuth.Enabled, config.TumblrAuth.Enabled ); - string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); + string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync()); string? discord = null; string? google = null; string? tumblr = null; diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index bdf4b9a..8024ee6 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -56,7 +56,7 @@ public class EmailAuthController( if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); - string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null, ct); + string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null); // If there's already a user with that email address, pretend we sent an email but actually ignore it if ( diff --git a/Foxnouns.Backend/Controllers/ExportsController.cs b/Foxnouns.Backend/Controllers/ExportsController.cs index 7f40625..0442386 100644 --- a/Foxnouns.Backend/Controllers/ExportsController.cs +++ b/Foxnouns.Backend/Controllers/ExportsController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto; @@ -28,13 +27,8 @@ namespace Foxnouns.Backend.Controllers; [Authorize("identify")] [Limit(UsableByDeletedUsers = true)] [ApiExplorerSettings(IgnoreApi = true)] -public class ExportsController( - ILogger logger, - Config config, - IClock clock, - DatabaseContext db, - IQueue queue -) : ApiControllerBase +public class ExportsController(ILogger logger, Config config, IClock clock, DatabaseContext db) + : ApiControllerBase { private static readonly Duration MinimumTimeBetween = Duration.FromDays(1); private readonly ILogger _logger = logger.ForContext(); @@ -80,10 +74,7 @@ public class ExportsController( throw new ApiError.BadRequest("You can't request a new data export so soon."); } - queue.QueueInvocableWithPayload( - new CreateDataExportPayload(CurrentUser.Id) - ); - + CreateDataExportJob.Enqueue(CurrentUser.Id); return NoContent(); } } diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index e976072..bed022a 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto; @@ -30,8 +29,7 @@ namespace Foxnouns.Backend.Controllers; public class FlagsController( DatabaseContext db, UserRendererService userRenderer, - ISnowflakeGenerator snowflakeGenerator, - IQueue queue + ISnowflakeGenerator snowflakeGenerator ) : ApiControllerBase { [HttpGet] @@ -74,10 +72,7 @@ public class FlagsController( db.Add(flag); await db.SaveChangesAsync(); - queue.QueueInvocableWithPayload( - new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image) - ); - + CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image)); return Accepted(userRenderer.RenderPrideFlag(flag)); } diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index dbea99c..bc35f62 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; @@ -37,7 +36,6 @@ public class MembersController( MemberRendererService memberRenderer, ISnowflakeGenerator snowflakeGenerator, ObjectStorageService objectStorageService, - IQueue queue, IClock clock, ValidationService validationService, Config config @@ -139,9 +137,7 @@ public class MembersController( if (req.Avatar != null) { - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(member.Id, req.Avatar) - ); + MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar)); } return Ok(memberRenderer.RenderMember(member, CurrentToken)); @@ -239,9 +235,7 @@ public class MembersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) { - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(member.Id, req.Avatar) - ); + MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar)); } try diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index f7e3115..787ff66 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; @@ -34,7 +33,6 @@ public class UsersController( ILogger logger, UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, - IQueue queue, IClock clock, ValidationService validationService ) : ApiControllerBase @@ -177,9 +175,7 @@ public class UsersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) { - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar) - ); + UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); } try diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index ae620f2..c9120f3 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -64,7 +64,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet FediverseApplications { get; init; } = null!; public DbSet Tokens { get; init; } = null!; public DbSet Applications { get; init; } = null!; - public DbSet TemporaryKeys { get; init; } = null!; public DbSet DataExports { get; init; } = null!; public DbSet PrideFlags { get; init; } = null!; @@ -87,7 +86,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) modelBuilder.Entity().HasIndex(u => u.Sid).IsUnique(); modelBuilder.Entity().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity().HasIndex(m => m.Sid).IsUnique(); - modelBuilder.Entity().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); // Two indexes on auth_methods, one for fediverse auth and one for all other types. diff --git a/Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs b/Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs new file mode 100644 index 0000000..27a8ada --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20250304155708_RemoveTemporaryKeys")] + public partial class RemoveTemporaryKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "temporary_keys"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "temporary_keys", + columns: table => new + { + id = table + .Column(type: "bigint", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + expires = table.Column( + type: "timestamp with time zone", + nullable: false + ), + key = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("pk_temporary_keys", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_temporary_keys_key", + table: "temporary_keys", + column: "key", + unique: true + ); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 6b4f4d4..922a599 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -19,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("ProductVersion", "9.0.2") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); @@ -479,39 +479,6 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("reports", (string)null); }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Expires") - .HasColumnType("timestamp with time zone") - .HasColumnName("expires"); - - b.Property("Key") - .IsRequired() - .HasColumnType("text") - .HasColumnName("key"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text") - .HasColumnName("value"); - - b.HasKey("Id") - .HasName("pk_temporary_keys"); - - b.HasIndex("Key") - .IsUnique() - .HasDatabaseName("ix_temporary_keys_key"); - - b.ToTable("temporary_keys", (string)null); - }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => { b.Property("Id") diff --git a/Foxnouns.Backend/Database/Models/TemporaryKey.cs b/Foxnouns.Backend/Database/Models/TemporaryKey.cs deleted file mode 100644 index f83e515..0000000 --- a/Foxnouns.Backend/Database/Models/TemporaryKey.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -using NodaTime; - -namespace Foxnouns.Backend.Database.Models; - -public class TemporaryKey -{ - public long Id { get; init; } - public required string Key { get; init; } - public required string Value { get; set; } - public Instant Expires { get; init; } -} diff --git a/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs b/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs index db0797c..2d3108b 100644 --- a/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs @@ -33,24 +33,20 @@ public static class ImageObjectExtensions Snowflake id, string hash, CancellationToken ct = default - ) => - await objectStorageService.RemoveObjectAsync( - MemberAvatarUpdateInvocable.Path(id, hash), - ct - ); + ) => await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateJob.Path(id, hash), ct); public static async Task DeleteUserAvatarAsync( this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default - ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); + ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateJob.Path(id, hash), ct); public static async Task DeleteFlagAsync( this ObjectStorageService objectStorageService, string hash, CancellationToken ct = default - ) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); + ) => await objectStorageService.RemoveObjectAsync(CreateFlagJob.Path(hash), ct); public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage( string uri, diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 615cc3d..a4fb444 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -23,23 +23,19 @@ namespace Foxnouns.Backend.Extensions; public static class KeyCacheExtensions { - public static async Task GenerateAuthStateAsync( - this KeyCacheService keyCacheService, - CancellationToken ct = default - ) + public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheService) { string state = AuthUtils.RandomToken(); - await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); + await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); return state; } public static async Task ValidateAuthStateAsync( this KeyCacheService keyCacheService, - string state, - CancellationToken ct = default + string state ) { - string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct); + string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}"); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); } @@ -47,63 +43,55 @@ public static class KeyCacheExtensions public static async Task GenerateRegisterEmailStateAsync( this KeyCacheService keyCacheService, string email, - Snowflake? userId = null, - CancellationToken ct = default + Snowflake? userId = null ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"email_state:{state}", new RegisterEmailState(email, userId), - Duration.FromDays(1), - ct + Duration.FromDays(1) ); return state; } public static async Task GetRegisterEmailStateAsync( this KeyCacheService keyCacheService, - string state, - CancellationToken ct = default - ) => await keyCacheService.GetKeyAsync($"email_state:{state}", ct: ct); + string state + ) => await keyCacheService.GetKeyAsync($"email_state:{state}"); public static async Task GenerateAddExtraAccountStateAsync( this KeyCacheService keyCacheService, AuthType authType, Snowflake userId, - string? instance = null, - CancellationToken ct = default + string? instance = null ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"add_account:{state}", new AddExtraAccountState(authType, userId, instance), - Duration.FromDays(1), - ct + Duration.FromDays(1) ); return state; } public static async Task GetAddExtraAccountStateAsync( this KeyCacheService keyCacheService, - string state, - CancellationToken ct = default - ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true, ct); + string state + ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true); public static async Task GenerateForgotPasswordStateAsync( this KeyCacheService keyCacheService, string email, - Snowflake userId, - CancellationToken ct = default + Snowflake userId ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"forgot_password:{state}", new ForgotPasswordState(email, userId), - Duration.FromHours(1), - ct + Duration.FromHours(1) ); return state; } @@ -111,14 +99,8 @@ public static class KeyCacheExtensions public static async Task GetForgotPasswordStateAsync( this KeyCacheService keyCacheService, string state, - bool delete = true, - CancellationToken ct = default - ) => - await keyCacheService.GetKeyAsync( - $"forgot_password:{state}", - delete, - ct - ); + bool delete = true + ) => await keyCacheService.GetKeyAsync($"forgot_password:{state}", delete); } public record RegisterEmailState( diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 07394f2..8db7a1b 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -51,9 +51,12 @@ public static class WebApplicationExtensions "Microsoft.EntityFrameworkCore.Database.Command", config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal ) + // These spam the output even on INF level .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) + // Hangfire's debug-level logs are extremely spammy for no reason + .MinimumLevel.Override("Hangfire", LogEventLevel.Information) .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen); if (config.Logging.SeqLogUrl != null) @@ -112,12 +115,12 @@ public static class WebApplicationExtensions .AddSnowflakeGenerator() .AddSingleton() .AddSingleton() + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() - .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -126,10 +129,10 @@ public static class WebApplicationExtensions // Background services .AddHostedService() // Transient jobs - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() // Legacy services .AddScoped() .AddScoped(); diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index c30f2b9..a8c21fb 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -12,6 +12,9 @@ + + + @@ -42,6 +45,7 @@ + diff --git a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs b/Foxnouns.Backend/Jobs/CreateDataExportJob.cs similarity index 93% rename from Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs rename to Foxnouns.Backend/Jobs/CreateDataExportJob.cs index becd858..3662e33 100644 --- a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateDataExportJob.cs @@ -14,11 +14,11 @@ // along with this program. If not, see . using System.IO.Compression; using System.Net; -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; +using Hangfire; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; @@ -26,7 +26,7 @@ using NodaTime.Text; namespace Foxnouns.Backend.Jobs; -public class CreateDataExportInvocable( +public class CreateDataExportJob( DatabaseContext db, IClock clock, UserRendererService userRenderer, @@ -34,37 +34,41 @@ public class CreateDataExportInvocable( ObjectStorageService objectStorageService, ISnowflakeGenerator snowflakeGenerator, ILogger logger -) : IInvocable, IInvocableWithPayload +) { private static readonly HttpClient Client = new(); - private readonly ILogger _logger = logger.ForContext(); - public required CreateDataExportPayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(Snowflake userId) + { + BackgroundJob.Enqueue(j => j.InvokeAsync(userId)); + } + + public async Task InvokeAsync(Snowflake userId) { try { - await InvokeAsync(); + await InvokeAsyncInner(userId); } catch (Exception e) { - _logger.Error(e, "Error generating data export for user {UserId}", Payload.UserId); + _logger.Error(e, "Error generating data export for user {UserId}", userId); } } - private async Task InvokeAsync() + private async Task InvokeAsyncInner(Snowflake userId) { User? user = await db .Users.Include(u => u.AuthMethods) .Include(u => u.Flags) .Include(u => u.ProfileFlags) .AsSplitQuery() - .FirstOrDefaultAsync(u => u.Id == Payload.UserId); + .FirstOrDefaultAsync(u => u.Id == userId); if (user == null) { _logger.Warning( "Received create data export request for user {UserId} but no such user exists, deleted or otherwise. Ignoring request", - Payload.UserId + userId ); return; } diff --git a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs index 1b8905b..e40bfa4 100644 --- a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs @@ -12,49 +12,53 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Hangfire; using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Jobs; -public class CreateFlagInvocable( +public class CreateFlagJob( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) : IInvocable, IInvocableWithPayload +) { - private readonly ILogger _logger = logger.ForContext(); - public required CreateFlagPayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(CreateFlagPayload payload) + { + BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); + } + + public async Task InvokeAsync(CreateFlagPayload payload) { _logger.Information( "Creating flag {FlagId} for user {UserId} with image data length {DataLength}", - Payload.Id, - Payload.UserId, - Payload.ImageData.Length + payload.Id, + payload.UserId, + payload.ImageData.Length ); try { PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => - f.Id == Payload.Id && f.UserId == Payload.UserId + f.Id == payload.Id && f.UserId == payload.UserId ); if (flag == null) { _logger.Warning( "Got a flag create job for {FlagId} but it doesn't exist, aborting", - Payload.Id + payload.Id ); return; } (string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage( - Payload.ImageData, + payload.ImageData, 256, false ); @@ -68,7 +72,7 @@ public class CreateFlagInvocable( } catch (ArgumentException ae) { - _logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", Payload.Id, ae.Message); + _logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", payload.Id, ae.Message); } throw new NotImplementedException(); diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs similarity index 86% rename from Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs rename to Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs index 01ec9e3..907dfc4 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs @@ -12,29 +12,33 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Hangfire; namespace Foxnouns.Backend.Jobs; -public class MemberAvatarUpdateInvocable( +public class MemberAvatarUpdateJob( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) : IInvocable, IInvocableWithPayload +) { - private readonly ILogger _logger = logger.ForContext(); - public required AvatarUpdatePayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(AvatarUpdatePayload payload) { - if (Payload.NewAvatar != null) - await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar); + BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); + } + + public async Task InvokeAsync(AvatarUpdatePayload payload) + { + if (payload.NewAvatar != null) + await UpdateMemberAvatarAsync(payload.Id, payload.NewAvatar); else - await ClearMemberAvatarAsync(Payload.Id); + await ClearMemberAvatarAsync(payload.Id); } private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar) diff --git a/Foxnouns.Backend/Jobs/Payloads.cs b/Foxnouns.Backend/Jobs/Payloads.cs index 374a5b7..1f76ea2 100644 --- a/Foxnouns.Backend/Jobs/Payloads.cs +++ b/Foxnouns.Backend/Jobs/Payloads.cs @@ -19,5 +19,3 @@ namespace Foxnouns.Backend.Jobs; public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string ImageData); - -public record CreateDataExportPayload(Snowflake UserId); diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs similarity index 88% rename from Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs rename to Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs index 862d0da..1ab446c 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs @@ -12,29 +12,33 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Hangfire; namespace Foxnouns.Backend.Jobs; -public class UserAvatarUpdateInvocable( +public class UserAvatarUpdateJob( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) : IInvocable, IInvocableWithPayload +) { - private readonly ILogger _logger = logger.ForContext(); - public required AvatarUpdatePayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(AvatarUpdatePayload payload) { - if (Payload.NewAvatar != null) - await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar); + BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); + } + + public async Task InvokeAsync(AvatarUpdatePayload payload) + { + if (payload.NewAvatar != null) + await UpdateUserAvatarAsync(payload.Id, payload.NewAvatar); else - await ClearUserAvatarAsync(Payload.Id); + await ClearUserAvatarAsync(payload.Id); } private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar) diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 0f1d9f1..b5bc338 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -19,6 +19,8 @@ using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils.OpenApi; +using Hangfire; +using Hangfire.Redis.StackExchange; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -73,6 +75,18 @@ builder ); }); +builder + .Services.AddHangfire( + (services, c) => + { + c.UseRedisStorage( + services.GetRequiredService().Multiplexer, + new RedisStorageOptions { Prefix = "foxnouns_net:" } + ); + } + ) + .AddHangfireServer(); + builder.Services.AddOpenApi( "v2", options => @@ -109,6 +123,7 @@ if (config.Logging.SentryTracing) app.UseCors(); app.UseCustomMiddleware(); app.MapControllers(); +app.UseHangfireDashboard(); // TODO: I can't figure out why this doesn't work yet // TODO: Manually write API docs in the meantime diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 0163516..1ad825f 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -17,94 +17,42 @@ using Foxnouns.Backend.Database.Models; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; +using StackExchange.Redis; namespace Foxnouns.Backend.Services; -public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) +public class KeyCacheService(Config config) { - private readonly ILogger _logger = logger.ForContext(); + public ConnectionMultiplexer Multiplexer { get; } = + // ConnectionMultiplexer.Connect(config.Database.Redis); + ConnectionMultiplexer.Connect("127.0.0.1:6379"); - public Task SetKeyAsync( - string key, - string value, - Duration expireAfter, - CancellationToken ct = default - ) => SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); + public async Task SetKeyAsync(string key, string value, Duration expireAfter) => + await Multiplexer + .GetDatabase() + .StringSetAsync(key, value, expiry: expireAfter.ToTimeSpan()); - public async Task SetKeyAsync( - string key, - string value, - Instant expires, - CancellationToken ct = default - ) - { - db.TemporaryKeys.Add( - new TemporaryKey - { - Expires = expires, - Key = key, - Value = value, - } - ); - await db.SaveChangesAsync(ct); - } + public async Task GetKeyAsync(string key, bool delete = false) => + delete + ? await Multiplexer.GetDatabase().StringGetDeleteAsync(key) + : await Multiplexer.GetDatabase().StringGetAsync(key); - public async Task GetKeyAsync( - string key, - bool delete = false, - CancellationToken ct = default - ) - { - TemporaryKey? value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct); - if (value == null) - return null; + public async Task DeleteKeyAsync(string key) => + await Multiplexer.GetDatabase().KeyDeleteAsync(key); - if (delete) - await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); + public Task DeleteExpiredKeysAsync(CancellationToken ct) => Task.CompletedTask; - return value.Value; - } - - public async Task DeleteKeyAsync(string key, CancellationToken ct = default) => - await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); - - public async Task DeleteExpiredKeysAsync(CancellationToken ct) - { - int count = await db - .TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()) - .ExecuteDeleteAsync(ct); - if (count != 0) - _logger.Information("Removed {Count} expired keys from the database", count); - } - - public Task SetKeyAsync( - string key, - T obj, - Duration expiresAt, - CancellationToken ct = default - ) - where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); - - public async Task SetKeyAsync( - string key, - T obj, - Instant expires, - CancellationToken ct = default - ) + public async Task SetKeyAsync(string key, T obj, Duration expiresAt) where T : class { string value = JsonConvert.SerializeObject(obj); - await SetKeyAsync(key, value, expires, ct); + await SetKeyAsync(key, value, expiresAt); } - public async Task GetKeyAsync( - string key, - bool delete = false, - CancellationToken ct = default - ) + public async Task GetKeyAsync(string key, bool delete = false) where T : class { - string? value = await GetKeyAsync(key, delete, ct); + string? value = await GetKeyAsync(key, delete); return value == null ? default : JsonConvert.DeserializeObject(value); } } diff --git a/Foxnouns.Backend/Services/ModerationService.cs b/Foxnouns.Backend/Services/ModerationService.cs index 4e2afe6..30d99ed 100644 --- a/Foxnouns.Backend/Services/ModerationService.cs +++ b/Foxnouns.Backend/Services/ModerationService.cs @@ -27,7 +27,6 @@ public class ModerationService( ILogger logger, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator, - IQueue queue, IClock clock ) { @@ -181,9 +180,7 @@ public class ModerationService( target.CustomPreferences = []; target.ProfileFlags = []; - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(target.Id, null) - ); + UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(target.Id, null)); // TODO: also clear member profiles? @@ -264,10 +261,9 @@ public class ModerationService( targetMember.DisplayName = null; break; case FieldsToClear.Avatar: - queue.QueueInvocableWithPayload< - MemberAvatarUpdateInvocable, - AvatarUpdatePayload - >(new AvatarUpdatePayload(targetMember.Id, null)); + MemberAvatarUpdateJob.Enqueue( + new AvatarUpdatePayload(targetMember.Id, null) + ); break; case FieldsToClear.Bio: targetMember.Bio = null; @@ -306,10 +302,7 @@ public class ModerationService( targetUser.DisplayName = null; break; case FieldsToClear.Avatar: - queue.QueueInvocableWithPayload< - UserAvatarUpdateInvocable, - AvatarUpdatePayload - >(new AvatarUpdatePayload(targetUser.Id, null)); + UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(targetUser.Id, null)); break; case FieldsToClear.Bio: targetUser.Bio = null; diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index e3799c6..3a7aec6 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -46,6 +46,37 @@ "Npgsql": "8.0.3" } }, + "Hangfire": { + "type": "Direct", + "requested": "[1.8.18, )", + "resolved": "1.8.18", + "contentHash": "EY+UqMHTOQAtdjeJf3jlnj8MpENyDPTpA6OHMncucVlkaongZjrx+gCN4bgma7vD3BNHqfQ7irYrfE5p1DOBEQ==", + "dependencies": { + "Hangfire.AspNetCore": "[1.8.18]", + "Hangfire.Core": "[1.8.18]", + "Hangfire.SqlServer": "[1.8.18]" + } + }, + "Hangfire.Core": { + "type": "Direct", + "requested": "[1.8.18, )", + "resolved": "1.8.18", + "contentHash": "oNAkV8QQoYg5+vM2M024NBk49EhTO2BmKDLuQaKNew23RpH9OUGtKDl1KldBdDJrD8TMFzjhWCArol3igd2i2w==", + "dependencies": { + "Newtonsoft.Json": "11.0.1" + } + }, + "Hangfire.Redis.StackExchange": { + "type": "Direct", + "requested": "[1.9.4, )", + "resolved": "1.9.4", + "contentHash": "rB4eGf4+hFhdnrN3//2O39JGuy1ThIKL3oTdVI2F3HqmSaSD9Cixl2xmMAqGJMld39Ke7eoP9sxbxnpVnYW66g==", + "dependencies": { + "Hangfire.Core": "1.8.7", + "Newtonsoft.Json": "13.0.3", + "StackExchange.Redis": "2.7.10" + } + }, "Humanizer.Core": { "type": "Direct", "requested": "[2.14.1, )", @@ -278,6 +309,16 @@ "resolved": "3.1.6", "contentHash": "dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA==" }, + "StackExchange.Redis": { + "type": "Direct", + "requested": "[2.8.24, )", + "resolved": "2.8.24", + "contentHash": "GWllmsFAtLyhm4C47cOCipGxyEi1NQWTFUHXnJ8hiHOsK/bH3T5eLkWPVW+LRL6jDiB3g3izW3YEHgLuPoJSyA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, "System.Text.Json": { "type": "Direct", "requested": "[9.0.2, )", @@ -317,6 +358,33 @@ "Microsoft.EntityFrameworkCore.Relational": "8.0.0" } }, + "Hangfire.AspNetCore": { + "type": "Transitive", + "resolved": "1.8.18", + "contentHash": "5D6Do0qgoAnakvh4KnKwhIoUzFU84Z0sCYMB+Sit+ygkpL1P6JGYDcd/9vDBcfr5K3JqBxD4Zh2IK2LOXuuiaw==", + "dependencies": { + "Hangfire.NetCore": "[1.8.18]" + } + }, + "Hangfire.NetCore": { + "type": "Transitive", + "resolved": "1.8.18", + "contentHash": "3KAV9AZ1nqQHC54qR4buNEEKRmQJfq+lODtZxUk5cdi68lV8+9K2f4H1/mIfDlPpgjPFjEfCobNoi2+TIpKySw==", + "dependencies": { + "Hangfire.Core": "[1.8.18]", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.0.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.0" + } + }, + "Hangfire.SqlServer": { + "type": "Transitive", + "resolved": "1.8.18", + "contentHash": "yBfI2ygYfN/31rOrahfOFHee1mwTrG0ppsmK9awCS0mAr2GEaB9eyYqg/lURgZy8AA8UVJVs5nLHa2hc1pDAVQ==", + "dependencies": { + "Hangfire.Core": "[1.8.18]" + } + }, "MailKit": { "type": "Transitive", "resolved": "4.8.0", @@ -684,6 +752,14 @@ "Npgsql": "9.0.2" } }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, "Sentry": { "type": "Transitive", "resolved": "5.2.0", From f99d10ecf01881154bf95a8eb14c0fdfb9e21c6c Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 4 Mar 2025 17:25:07 +0100 Subject: [PATCH 11/29] fix(backend): don't hardcode redis URL, add redis to docker compose --- Foxnouns.Backend/Services/KeyCacheService.cs | 3 +-- docker-compose.yml | 10 +++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 1ad825f..228a8fc 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -24,8 +24,7 @@ namespace Foxnouns.Backend.Services; public class KeyCacheService(Config config) { public ConnectionMultiplexer Multiplexer { get; } = - // ConnectionMultiplexer.Connect(config.Database.Redis); - ConnectionMultiplexer.Connect("127.0.0.1:6379"); + ConnectionMultiplexer.Connect(config.Database.Redis); public async Task SetKeyAsync(string key, string value, Duration expireAfter) => await Multiplexer diff --git a/docker-compose.yml b/docker-compose.yml index 751d919..084bcd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: environment: - "Database:Url=Host=postgres;Database=postgres;Username=postgres;Password=postgres" - "Database:EnablePooling=true" + - "Database:Redis=redis:6379" - "Host=0.0.0.0" - "Port=5000" - "Logging:MetricsPort=5001" @@ -31,7 +32,7 @@ services: rate: image: rate - build: ./rate + build: ./Foxnouns.RateLimiter environment: - "PORT=5003" restart: unless-stopped @@ -52,6 +53,12 @@ services: volumes: - postgres_data:/var/lib/postgresql/data + redis: + image: registry.redict.io/redict:7 + restart: unless-stopped + volumes: + - redict_data:/data + caddy: image: docker.io/caddy:2 restart: unless-stopped @@ -67,3 +74,4 @@ volumes: caddy_data: caddy_config: postgres_data: + redict_data: From dd9d35249c6c2ce1c17cce181b058f6f720f12b1 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Mar 2025 01:18:21 +0100 Subject: [PATCH 12/29] feat(frontend): notifications --- .../src/lib/api/models/moderation.ts | 9 ++++ .../src/lib/components/Navbar.svelte | 17 +++++++- .../components/settings/Notification.svelte | 43 +++++++++++++++++++ .../src/lib/i18n/locales/en.json | 13 +++++- .../src/routes/+layout.server.ts | 10 ++++- Foxnouns.Frontend/src/routes/+layout.svelte | 2 +- .../settings/notifications/+page.server.ts | 11 +++++ .../settings/notifications/+page.svelte | 16 +++++++ .../notifications/ack/[id]/+server.ts | 14 ++++++ 9 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 Foxnouns.Frontend/src/lib/components/settings/Notification.svelte create mode 100644 Foxnouns.Frontend/src/routes/settings/notifications/+page.server.ts create mode 100644 Foxnouns.Frontend/src/routes/settings/notifications/+page.svelte create mode 100644 Foxnouns.Frontend/src/routes/settings/notifications/ack/[id]/+server.ts diff --git a/Foxnouns.Frontend/src/lib/api/models/moderation.ts b/Foxnouns.Frontend/src/lib/api/models/moderation.ts index eee9382..689e9b8 100644 --- a/Foxnouns.Frontend/src/lib/api/models/moderation.ts +++ b/Foxnouns.Frontend/src/lib/api/models/moderation.ts @@ -112,3 +112,12 @@ export enum ClearableField { Flags = "FLAGS", CustomPreferences = "CUSTOM_PREFERENCES", } + +export type Notification = { + id: string; + type: "NOTICE" | "WARNING" | "SUSPENSION"; + message?: string; + localization_key?: string; + localization_params: Record; + acknowledged: boolean; +}; diff --git a/Foxnouns.Frontend/src/lib/components/Navbar.svelte b/Foxnouns.Frontend/src/lib/components/Navbar.svelte index edfbd1a..2074347 100644 --- a/Foxnouns.Frontend/src/lib/components/Navbar.svelte +++ b/Foxnouns.Frontend/src/lib/components/Navbar.svelte @@ -13,13 +13,21 @@ import Logo from "$components/Logo.svelte"; import { t } from "$lib/i18n"; - type Props = { user: MeUser | null; meta: Meta }; - let { user, meta }: Props = $props(); + type Props = { user: MeUser | null; meta: Meta; unreadNotifications?: boolean }; + let { user, meta, unreadNotifications }: Props = $props(); let isOpen = $state(true); const toggleMenu = () => (isOpen = !isOpen); +{#if user && unreadNotifications} +
    + {$t("nav.unread-notification-text")} +
    + {$t("nav.unread-notification-link")} +
    +{/if} + {#if user && user.deleted}
    {#if user.suspended} @@ -87,6 +95,11 @@ background-color: var(--bs-danger-bg-subtle); } + .notification-alert { + color: var(--bs-warning-text-emphasis); + background-color: var(--bs-warning-bg-subtle); + } + /* These exact values make it look almost identical to the SVG version, which is what we want */ #beta-text { font-size: 0.7em; diff --git a/Foxnouns.Frontend/src/lib/components/settings/Notification.svelte b/Foxnouns.Frontend/src/lib/components/settings/Notification.svelte new file mode 100644 index 0000000..c452f4c --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/settings/Notification.svelte @@ -0,0 +1,43 @@ + + +
    +
    +
    + +
    +

    + {#if notification.localization_key} + {$t(notification.localization_key, notification.localization_params)} + {:else} + {notification.message} + {/if} +

    +
    +
    +
    + +
    diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index fe10b04..9f54943 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -9,7 +9,9 @@ "reactivate-account-link": "Reactivate account", "delete-permanently-link": "I want my account deleted permanently", "reactivate-or-delete-link": "I want to reactivate my account or delete all my data", - "export-link": "I want to export a copy of my data" + "export-link": "I want to export a copy of my data", + "unread-notification-text": "You have an unread notification.", + "unread-notification-link": "Go to your notifications" }, "avatar-tooltip": "Avatar for {{name}}", "profile": { @@ -329,7 +331,9 @@ }, "alert": { "auth-method-remove-success": "Successfully unlinked account!", - "auth-required": "You must log in to access this page." + "auth-required": "You must log in to access this page.", + "notif-ack-successful": "Successfully marked notification as read!", + "notif-ack-fail": "Could not mark notification as read." }, "footer": { "version": "Version", @@ -342,5 +346,10 @@ "changelog": "Changelog", "donate": "Donate", "about-contact": "About and contact" + }, + "notification": { + "suspension": "Your account has been suspended for the following reason: {{reason}}", + "warning": "You have been warned for the following reason: {{reason}}", + "warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}" } } diff --git a/Foxnouns.Frontend/src/routes/+layout.server.ts b/Foxnouns.Frontend/src/routes/+layout.server.ts index 82f3cb2..2debd7c 100644 --- a/Foxnouns.Frontend/src/routes/+layout.server.ts +++ b/Foxnouns.Frontend/src/routes/+layout.server.ts @@ -2,16 +2,24 @@ import { clearToken, TOKEN_COOKIE_NAME } from "$lib"; import { apiRequest } from "$api"; import ApiError, { ErrorCode } from "$api/error"; import type { Meta, MeUser } from "$api/models"; +import type { Notification } from "$api/models/moderation"; import log from "$lib/log"; import type { LayoutServerLoad } from "./$types"; export const load = (async ({ fetch, cookies }) => { let token: string | null = null; let meUser: MeUser | null = null; + let unreadNotifications: boolean = false; if (cookies.get(TOKEN_COOKIE_NAME)) { try { meUser = await apiRequest("GET", "/users/@me", { fetch, cookies }); token = cookies.get(TOKEN_COOKIE_NAME) || null; + + const notifications = await apiRequest("GET", "/notifications", { + fetch, + cookies, + }); + unreadNotifications = notifications.filter((n) => !n.acknowledged).length > 0; } catch (e) { if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies); else log.error("Could not fetch /users/@me and token has not expired:", e); @@ -19,5 +27,5 @@ export const load = (async ({ fetch, cookies }) => { } const meta = await apiRequest("GET", "/meta", { fetch, cookies }); - return { meta, meUser, token }; + return { meta, meUser, token, unreadNotifications }; }) satisfies LayoutServerLoad; diff --git a/Foxnouns.Frontend/src/routes/+layout.svelte b/Foxnouns.Frontend/src/routes/+layout.svelte index e5be130..b991f8a 100644 --- a/Foxnouns.Frontend/src/routes/+layout.svelte +++ b/Foxnouns.Frontend/src/routes/+layout.svelte @@ -11,7 +11,7 @@
    - + {@render children?.()}
    diff --git a/Foxnouns.Frontend/src/routes/settings/notifications/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/notifications/+page.server.ts new file mode 100644 index 0000000..40470bd --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/notifications/+page.server.ts @@ -0,0 +1,11 @@ +import { apiRequest } from "$api"; +import type { Notification } from "$api/models/moderation"; +import { alertKey } from "$lib"; + +export const load = async ({ url, fetch, cookies }) => { + const notifications = await apiRequest("GET", "/notifications", { + fetch, + cookies, + }); + return { notifications, alertKey: alertKey(url) }; +}; diff --git a/Foxnouns.Frontend/src/routes/settings/notifications/+page.svelte b/Foxnouns.Frontend/src/routes/settings/notifications/+page.svelte new file mode 100644 index 0000000..a33d734 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/notifications/+page.svelte @@ -0,0 +1,16 @@ + + + + +{#each data.notifications as notification (notification.id)} + +{:else} + You have no notifications. +{/each} diff --git a/Foxnouns.Frontend/src/routes/settings/notifications/ack/[id]/+server.ts b/Foxnouns.Frontend/src/routes/settings/notifications/ack/[id]/+server.ts new file mode 100644 index 0000000..a9a22fc --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/notifications/ack/[id]/+server.ts @@ -0,0 +1,14 @@ +import { redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { fastRequest } from "$api"; +import log from "$lib/log"; + +export const GET: RequestHandler = async ({ params, fetch, cookies }) => { + try { + await fastRequest("PUT", `/notifications/${params.id}/ack`, { fetch, cookies }); + } catch (e) { + log.error("error acking notification %s:", params.id, e); + redirect(303, "/settings/notifications?alert=notif-ack-fail"); + } + redirect(303, "/settings/notifications?alert=notif-ack-successful"); +}; From 7d0df67c067eeb09f5f4c17af83b86b074f5ef71 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Mar 2025 15:13:26 +0100 Subject: [PATCH 13/29] fix(frontend): fix moving pronouns --- .../src/lib/components/editor/PronounEntryEditor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Foxnouns.Frontend/src/lib/components/editor/PronounEntryEditor.svelte b/Foxnouns.Frontend/src/lib/components/editor/PronounEntryEditor.svelte index bcf5c15..64b354e 100644 --- a/Foxnouns.Frontend/src/lib/components/editor/PronounEntryEditor.svelte +++ b/Foxnouns.Frontend/src/lib/components/editor/PronounEntryEditor.svelte @@ -48,7 +48,7 @@ icon="chevron-down" color="secondary" tooltip={$t("editor.move-entry-down")} - onclick={() => moveValue(index, true)} + onclick={() => moveValue(index, false)} /> From 790b39f730a6df3e42d33225f01f7e5839cc8729 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Mar 2025 15:13:44 +0100 Subject: [PATCH 14/29] fix(frontend): consistency in the editor --- .../src/lib/components/editor/AvatarEditor.svelte | 2 +- .../src/lib/components/editor/CustomPreferencesNotice.svelte | 2 +- .../src/lib/components/editor/FieldsEditor.svelte | 2 ++ .../src/routes/settings/members/[id]/fields/+page.svelte | 5 ----- .../src/routes/settings/profile/fields/+page.svelte | 5 ----- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte b/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte index a945bbf..288bcc5 100644 --- a/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte +++ b/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte @@ -130,7 +130,7 @@ accept="image/png, image/jpeg, image/gif, image/webp" />
    diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 9f54943..dc74d2f 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -350,6 +350,8 @@ "notification": { "suspension": "Your account has been suspended for the following reason: {{reason}}", "warning": "You have been warned for the following reason: {{reason}}", - "warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}" + "warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}", + "mark-as-read": "Mark as read", + "no-notifications": "You have no notifications." } } diff --git a/Foxnouns.Frontend/src/routes/+page.svelte b/Foxnouns.Frontend/src/routes/+page.svelte index 47ab0e5..4fd36dc 100644 --- a/Foxnouns.Frontend/src/routes/+page.svelte +++ b/Foxnouns.Frontend/src/routes/+page.svelte @@ -1,4 +1,5 @@ {$t("title.settings")} • pronouns.cc +{#if data.meta.notice} +
    + +
    +{/if} +