diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
deleted file mode 100644
index 3652ec5..0000000
--- a/.config/dotnet-tools.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "version": 1,
- "isRoot": true,
- "tools": {
- "husky": {
- "version": "0.7.2",
- "commands": [
- "husky"
- ],
- "rollForward": false
- },
- "csharpier": {
- "version": "0.30.6",
- "commands": [
- "dotnet-csharpier"
- ],
- "rollForward": false
- }
- }
-}
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index d755b6c..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,24 +0,0 @@
-**/.dockerignore
-**/.gitignore
-**/.project
-**/.settings
-**/.toolstarget
-**/.vs
-**/.vscode
-**/.idea
-**/*.*proj.user
-**/*.dbmdl
-**/*.jfm
-**/azds.yaml
-**/bin
-**/charts
-**/docker-compose*
-**/Dockerfile*
-**/node_modules
-**/npm-debug.log
-**/obj
-**/secrets.dev.yaml
-**/values.dev.yaml
-LICENSE
-README.md
-static-pages/*
diff --git a/.editorconfig b/.editorconfig
index 22061dc..2a1f655 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,52 +1,2 @@
-[*]
-# We use PostgresSQL which doesn't recommend more specific string types
+[*.cs]
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
-# This is raised for every single property of records returned by endpoints
-resharper_not_accessed_positional_property_local_highlighting = none
-
-
-# Microsoft .NET properties
-csharp_new_line_before_members_in_object_initializers = false
-csharp_preferred_modifier_order = public, internal, protected, private, file, new, virtual, override, required, abstract, sealed, static, extern, unsafe, volatile, async, readonly:suggestion
-
-# ReSharper properties
-resharper_align_multiline_binary_expressions_chain = false
-resharper_arguments_skip_single = true
-resharper_blank_lines_after_start_comment = 0
-resharper_blank_lines_around_single_line_invocable = 0
-resharper_blank_lines_before_block_statements = 0
-resharper_braces_for_foreach = required_for_multiline
-resharper_braces_for_ifelse = required_for_multiline
-resharper_braces_redundant = false
-resharper_csharp_blank_lines_around_field = 0
-resharper_csharp_empty_block_style = together_same_line
-resharper_csharp_max_line_length = 166
-resharper_csharp_wrap_after_declaration_lpar = true
-resharper_csharp_wrap_before_binary_opsign = true
-resharper_csharp_wrap_before_declaration_rpar = true
-resharper_csharp_wrap_parameters_style = chop_if_long
-resharper_indent_preprocessor_other = do_not_change
-resharper_instance_members_qualify_declared_in =
-resharper_keep_existing_attribute_arrangement = true
-resharper_max_attribute_length_for_same_line = 70
-resharper_place_accessorholder_attribute_on_same_line = false
-resharper_place_expr_method_on_single_line = if_owner_is_single_line
-resharper_place_method_attribute_on_same_line = if_owner_is_single_line
-resharper_place_record_field_attribute_on_same_line = true
-resharper_place_simple_embedded_statement_on_same_line = false
-resharper_place_simple_initializer_on_single_line = false
-resharper_place_simple_list_pattern_on_single_line = false
-resharper_space_within_empty_braces = false
-resharper_trailing_comma_in_multiline_lists = true
-resharper_wrap_after_invocation_lpar = false
-resharper_wrap_before_invocation_rpar = false
-resharper_wrap_before_primary_constructor_declaration_rpar = true
-resharper_wrap_chained_binary_patterns = chop_if_long
-resharper_wrap_list_pattern = chop_always
-resharper_wrap_object_and_collection_initializer_style = chop_always
-
-# Roslynator properties
-dotnet_diagnostic.RCS1194.severity = none
-
-[*generated.cs]
-generated_code = true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b1e845f..cd1b080 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,21 +1,3 @@
bin/
obj/
-node_modules/
.version
-config.ini
-*.DotSettings.user
-proxy-config.json
-.DS_Store
-.idea/.idea.Foxnouns.NET/.idea/dataSources.xml
-.idea/.idea.Foxnouns.NET/.idea/sqldialects.xml
-
-docker/config.ini
-docker/proxy-config.json
-docker/frontend.env
-
-Foxnouns.DataMigrator/apps.json
-migration-tools/avatar-proxy/config.json
-migration-tools/avatar-migrator/.env
-
-out/
-build/
diff --git a/.husky/pre-commit b/.husky/pre-commit
deleted file mode 100755
index fd85d23..0000000
--- a/.husky/pre-commit
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
-## husky task runner examples -------------------
-## Note : for local installation use 'dotnet' prefix. e.g. 'dotnet husky'
-
-## run all tasks
-#husky run
-
-### run all tasks with group: 'group-name'
-#husky run --group group-name
-
-## run task with name: 'task-name'
-#husky run --name task-name
-
-## pass hook arguments to task
-#husky run --args "$1" "$2"
-
-## or put your custom commands -------------------
-#echo 'Husky.Net is awesome!'
-
-dotnet husky run
diff --git a/.husky/task-runner.json b/.husky/task-runner.json
deleted file mode 100644
index 72e6fea..0000000
--- a/.husky/task-runner.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "$schema": "https://alirezanet.github.io/Husky.Net/schema.json",
- "tasks": [
- {
- "name": "run-prettier",
- "command": "npx",
- "args": ["prettier", "-w", "${staged}"],
- "include": [
- "Foxnouns.Frontend/**/*.ts",
- "Foxnouns.Frontend/**/*.json",
- "Foxnouns.Frontend/**/*.scss",
- "Foxnouns.Frontend/**/*.js",
- "Foxnouns.Frontend/**/*.svelte"
- ],
- "cwd": "Foxnouns.Frontend/",
- "pathMode": "absolute"
- },
- {
- "name": "run-csharpier",
- "command": "dotnet",
- "args": ["csharpier", "${staged}"],
- "include": ["**/*.cs"]
- }
- ]
-}
diff --git a/.idea/.idea.Foxnouns.NET/.idea/CSharpierPlugin.xml b/.idea/.idea.Foxnouns.NET/.idea/CSharpierPlugin.xml
deleted file mode 100644
index 5e24061..0000000
--- a/.idea/.idea.Foxnouns.NET/.idea/CSharpierPlugin.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.Foxnouns.NET/.idea/codeStyles/Project.xml b/.idea/.idea.Foxnouns.NET/.idea/codeStyles/Project.xml
deleted file mode 100644
index c80f77e..0000000
--- a/.idea/.idea.Foxnouns.NET/.idea/codeStyles/Project.xml
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.Foxnouns.NET/.idea/codeStyles/codeStyleConfig.xml b/.idea/.idea.Foxnouns.NET/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index 79ee123..0000000
--- a/.idea/.idea.Foxnouns.NET/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.Foxnouns.NET/.idea/indexLayout.xml b/.idea/.idea.Foxnouns.NET/.idea/indexLayout.xml
index ea4646c..7b08163 100644
--- a/.idea/.idea.Foxnouns.NET/.idea/indexLayout.xml
+++ b/.idea/.idea.Foxnouns.NET/.idea/indexLayout.xml
@@ -1,10 +1,7 @@
-
- Foxnouns.Frontend
- migrators/go-exporter
-
+
diff --git a/.idea/.idea.Foxnouns.NET/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.Foxnouns.NET/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index c9504c5..0000000
--- a/.idea/.idea.Foxnouns.NET/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.Foxnouns.NET/.idea/jsLinters/eslint.xml b/.idea/.idea.Foxnouns.NET/.idea/jsLinters/eslint.xml
deleted file mode 100644
index 5f8621e..0000000
--- a/.idea/.idea.Foxnouns.NET/.idea/jsLinters/eslint.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.Foxnouns.NET/.idea/prettier.xml b/.idea/.idea.Foxnouns.NET/.idea/prettier.xml
deleted file mode 100644
index ffcf89b..0000000
--- a/.idea/.idea.Foxnouns.NET/.idea/prettier.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.Foxnouns.NET/.idea/watcherTasks.xml b/.idea/.idea.Foxnouns.NET/.idea/watcherTasks.xml
deleted file mode 100644
index fb0d65a..0000000
--- a/.idea/.idea.Foxnouns.NET/.idea/watcherTasks.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/.noai b/.noai
deleted file mode 100644
index e69de29..0000000
diff --git a/DOCKER.md b/DOCKER.md
deleted file mode 100644
index b670743..0000000
--- a/DOCKER.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Running with Docker (pre-built backend and rate limiter) *(linux/arm64 only)*
-
-Because SvelteKit is a pain in the ass to build in a container, and processes secrets at build time,
-there is no pre-built frontend image available.
-If you don't want to build images on your server, I recommend running the frontend outside of Docker.
-This is preconfigured in `docker-compose.prebuilt.yml`: the backend, database, and rate limiter will run in Docker,
-while the frontend is run as a normal, non-containerized service.
-
-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. Run with `docker compose up -f docker-compose.prebuilt.yml`
-
-The backend will listen on port 5001 and metrics will be available on port 5002.
-The rate limiter (which is what should be exposed to the outside) will listen on port 5003.
-You can use `docker/Caddyfile` as an example for your reverse proxy. If you use nginx, good luck.
-
-# Running with Docker (local builds)
-
-In order to run *everything* in Docker, you'll have to build every container yourself.
-The advantage of this is that it's an all-in-one solution, where you only have to point your reverse proxy at a single container.
-The disadvantage is that you'll likely have to build the images on the server you'll be running them on.
-
-1. Configure the backend and rate limiter as in the section above.
-2. Copy `docker/frontend.example.env` to `docker/frontend.env`, and configure it.
-3. Build with `docker compose build -f docker-compose.local.yml`
-4. Run with `docker compose up -f docker-compose.local.yml`
-
-The Caddy server will listen on `localhost:5004` for the frontend and API,
-and on `localhost:5005` for the profile URL shortener.
-
-The backend server listens on `localhost:5006` for unproxied API access,
-and `localhost:5007` for metrics.
\ No newline at end of file
diff --git a/Dockerfile.backend b/Dockerfile.backend
deleted file mode 100644
index 69f90b0..0000000
--- a/Dockerfile.backend
+++ /dev/null
@@ -1,22 +0,0 @@
-FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
-USER $APP_UID
-WORKDIR /app
-EXPOSE 5000
-
-FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
-ARG BUILD_CONFIGURATION=Release
-WORKDIR /src
-COPY ["Foxnouns.Backend/Foxnouns.Backend.csproj", "Foxnouns.Backend/"]
-RUN dotnet restore "Foxnouns.Backend/Foxnouns.Backend.csproj"
-COPY . .
-WORKDIR "/src/Foxnouns.Backend"
-RUN dotnet build "Foxnouns.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
-
-FROM build AS publish
-ARG BUILD_CONFIGURATION=Release
-RUN dotnet publish "Foxnouns.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
-
-FROM base AS final
-WORKDIR /app
-COPY --from=publish /app/publish .
-ENTRYPOINT ["dotnet", "Foxnouns.Backend.dll", "--migrate-and-start"]
diff --git a/ENDPOINTS.md b/ENDPOINTS.md
deleted file mode 100644
index ddadf1e..0000000
--- a/ENDPOINTS.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# List of API endpoints and scopes
-
-## Scopes
-
-- `identify`: `@me` will refer to token user (always granted)
-- `user.read_hidden`: can read non-privileged hidden information such as timezone,
- whether the member list is hidden, and whether a member is unlisted.
-- `user.read_privileged`: can read privileged information such as authentication methods
-- `user.update`: can update the user's profile.
- **cannot** update anything locked behind `user.read_privileged`
-- `member.read`: can view member list if it's hidden and enumerate unlisted members
-- `member.create`: can create new members
-- `member.update`: can edit and delete members
-
-## Meta
-
-- [x] GET `/meta`: gets stats and server information
-
-## Users
-
-- [x] GET `/users/{userRef}`: views current user.
- `identify` required to use `@me` as user reference.
- `user.read_hidden` required to view timezone and other hidden non-privileged data.
- `user.read_privileged` required to view authentication methods.
- `member.read` required to view unlisted members.
-- [x] PATCH `/users/@me`: updates current user. `user.update` required
-- [x] PATCH `/users/@me/custom-preferences`: updates user's custom preferences. `user.update` required
-- [ ] DELETE `/users/@me`: deletes current user. `*` required
-- [ ] POST `/users/@me/export`: queues new data export. `*` required
-- [ ] GET `/users/@me/export`: gets latest data export. `*` required
-- [ ] GET `/users/@me/flags`: get all the user's flags. `identify` required
-- [ ] POST `/users/@me/flags`: creates a new flag. `user.update` required
-- [ ] PATCH `/users/@me/flags/{id}`: updates an existing flag. `user.update` required
-- [ ] DELETE `/users/@me/flags/{id}`: deletes a user flag. `user.update` required
-- [ ] POST `/users/@me/reroll`: rerolls a user's short ID. `user.update` required
-
-## Members
-
-- [x] GET `/users/{userRef}/members`: gets list of a user's members.
- if the user's member list is hidden,
- and it is not the authenticated user (or the token doesn't have the `member.read` scope)
- returns an empty array.
-- [x] GET `/users/{userRef}/members/{memberRef}`: gets a single member.
- will always return a member if it exists, even if the member is unlisted.
-- [x] POST `/users/@me/members`: creates a new member. `member.create` required
-- [ ] PATCH `/users/@me/members/{memberRef}`: edits a member. `member.update` required
-- [x] DELETE `/users/@me/members/{memberRef}`: deletes a member. `member.update` required
-- [ ] POST `/users/@me/members/{memberRef}/reroll`: rerolls a member's short ID. `member.update` required.
diff --git a/Foxnouns.Backend/BuildInfo.cs b/Foxnouns.Backend/BuildInfo.cs
index 1e27874..fe39640 100644
--- a/Foxnouns.Backend/BuildInfo.cs
+++ b/Foxnouns.Backend/BuildInfo.cs
@@ -1,17 +1,3 @@
-// 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;
public static class BuildInfo
@@ -21,27 +7,20 @@ public static class BuildInfo
public static async Task ReadBuildInfo()
{
- await using Stream? stream = typeof(BuildInfo).Assembly.GetManifestResourceStream(
- "version"
- );
- if (stream == null)
- return;
+ await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version");
+ if (stream == null) return;
using var reader = new StreamReader(stream);
- string[] data = (await reader.ReadToEndAsync()).Trim().Split("\n");
- if (data.Length < 3)
- return;
+ var data = (await reader.ReadToEndAsync()).Trim().Split("\n");
+ if (data.Length < 3) return;
Hash = data[0];
- bool dirty = data[2] == "dirty";
+ var dirty = data[2] == "dirty";
- string[] versionData = data[1].Split("-");
- if (versionData.Length < 3)
- return;
+ var versionData = data[1].Split("-");
+ if (versionData.Length < 3) return;
Version = versionData[0];
- if (versionData[1] != "0" || dirty)
- Version += $"+{versionData[2]}";
- if (dirty)
- Version += ".dirty";
+ if (versionData[1] != "0" || dirty) Version += $"+{versionData[2]}";
+ if (dirty) Version += ".dirty";
}
-}
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs
index 461e55e..39a417f 100644
--- a/Foxnouns.Backend/Config.cs
+++ b/Foxnouns.Backend/Config.cs
@@ -1,115 +1,24 @@
-// 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 .
-
-// ReSharper disable UnusedAutoPropertyAccessor.Global
using Serilog.Events;
namespace Foxnouns.Backend;
public class Config
{
- public string Host { get; init; } = "localhost";
- public int Port { get; init; } = 3000;
- public string BaseUrl { get; init; } = null!;
- public string MediaBaseUrl { get; init; } = null!;
+ public string Host { get; set; } = "localhost";
+ public int Port { get; set; } = 3000;
+ public string BaseUrl { get; set; } = null!;
public string Address => $"http://{Host}:{Port}";
- public LoggingConfig Logging { get; init; } = new();
- public DatabaseConfig Database { get; init; } = new();
- public StorageConfig Storage { get; init; } = new();
- public LimitsConfig Limits { get; init; } = new();
- public EmailAuthConfig EmailAuth { get; init; } = new();
- public DiscordAuthConfig DiscordAuth { get; init; } = new();
- public GoogleAuthConfig GoogleAuth { get; init; } = new();
- public TumblrAuthConfig TumblrAuth { get; init; } = new();
+ public string? SeqLogUrl { get; set; }
+ public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug;
- public class LoggingConfig
- {
- public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug;
- public string? SeqLogUrl { get; init; }
- public string? SentryUrl { get; init; }
- public bool SentryTracing { get; init; } = false;
- public double SentryTracesSampleRate { get; init; } = 0.0;
- public bool LogQueries { get; init; } = false;
- public bool EnableMetrics { get; init; } = false;
- public ushort MetricsPort { get; init; } = 5001;
- }
+ public DatabaseConfig Database { get; set; } = new();
public class DatabaseConfig
{
- public string Url { get; init; } = string.Empty;
- public bool? EnablePooling { get; init; }
- public int? Timeout { get; init; }
- public int? MaxPoolSize { get; init; }
- public string Redis { get; init; } = string.Empty;
+ public string Url { get; set; } = string.Empty;
+ public int? Timeout { get; set; }
+ public int? MaxPoolSize { get; set; }
}
-
- public class StorageConfig
- {
- public string Endpoint { get; init; } = string.Empty;
- public string AccessKey { get; init; } = string.Empty;
- public string SecretKey { get; init; } = string.Empty;
- public string Bucket { get; init; } = string.Empty;
- }
-
- public class EmailAuthConfig
- {
- public bool Enabled => From != null;
- public string? From { get; init; }
- }
-
- public class DiscordAuthConfig
- {
- public bool Enabled => ClientId != null && ClientSecret != null;
-
- public string? ClientId { get; init; }
- public string? ClientSecret { get; init; }
- }
-
- public class GoogleAuthConfig
- {
- public bool Enabled => ClientId != null && ClientSecret != null;
-
- public string? ClientId { get; init; }
- public string? ClientSecret { get; init; }
- }
-
- public class TumblrAuthConfig
- {
- public bool Enabled => ClientId != null && ClientSecret != null;
-
- public string? ClientId { get; init; }
- public string? ClientSecret { get; init; }
- }
-
- public class LimitsConfig
- {
- 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;
- public int MaxLinks { get; init; } = 25;
- public int MaxLinkLength { get; init; } = 256;
- public int MaxBioLength { get; init; } = 1024;
- public int MaxAvatarLength { get; init; } = 1_500_000;
- }
-}
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Controllers/ApiControllerBase.cs b/Foxnouns.Backend/Controllers/ApiControllerBase.cs
index 75a256e..d30803e 100644
--- a/Foxnouns.Backend/Controllers/ApiControllerBase.cs
+++ b/Foxnouns.Backend/Controllers/ApiControllerBase.cs
@@ -1,17 +1,3 @@
-// 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 Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Microsoft.AspNetCore.Mvc;
@@ -24,4 +10,4 @@ public class ApiControllerBase : ControllerBase
{
internal Token? CurrentToken => HttpContext.GetToken();
internal User? CurrentUser => HttpContext.GetUser();
-}
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs
deleted file mode 100644
index 39d3b11..0000000
--- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs
+++ /dev/null
@@ -1,155 +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 System.Net;
-using System.Web;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Extensions;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-namespace Foxnouns.Backend.Controllers.Authentication;
-
-[Route("/api/internal/auth")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class AuthController(
- Config config,
- DatabaseContext db,
- KeyCacheService keyCacheService,
- ILogger logger
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpPost("urls")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task UrlsAsync(CancellationToken ct = default)
- {
- _logger.Debug(
- "Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}",
- config.DiscordAuth.Enabled,
- config.GoogleAuth.Enabled,
- config.TumblrAuth.Enabled
- );
- string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync());
- string? discord = null;
- string? google = null;
- string? tumblr = null;
- if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
- {
- discord =
- "https://discord.com/oauth2/authorize?response_type=code"
- + $"&client_id={config.DiscordAuth.ClientId}&scope=identify"
- + $"&prompt=none&state={state}"
- + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
- }
-
- if (config.GoogleAuth is { ClientId: not null, ClientSecret: not null })
- {
- google =
- "https://accounts.google.com/o/oauth2/auth?response_type=code"
- + $"&client_id={config.GoogleAuth.ClientId}"
- + $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
- + $"&prompt=select_account&state={state}"
- + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
- }
-
- if (config.TumblrAuth is { ClientId: not null, ClientSecret: not null })
- {
- tumblr =
- "https://www.tumblr.com/oauth2/authorize?response_type=code"
- + $"&client_id={config.TumblrAuth.ClientId}"
- + $"&scope=basic&state={state}"
- + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}";
- }
-
- return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, tumblr));
- }
-
- [HttpPost("force-log-out")]
- [Authorize("identify")]
- public async Task ForceLogoutAsync()
- {
- _logger.Information("Invalidating all tokens for user {UserId}", CurrentUser!.Id);
- await db
- .Tokens.Where(t => t.UserId == CurrentUser.Id)
- .ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true));
-
- return NoContent();
- }
-
- [HttpGet("methods/{id}")]
- [Authorize("*")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task GetAuthMethodAsync(Snowflake id)
- {
- AuthMethod? authMethod = await db
- .AuthMethods.Include(a => a.FediverseApplication)
- .FirstOrDefaultAsync(a => a.UserId == CurrentUser!.Id && a.Id == id);
- if (authMethod == null)
- throw new ApiError.NotFound("No authentication method with that ID found.");
-
- return Ok(UserRendererService.RenderAuthMethod(authMethod));
- }
-
- [HttpDelete("methods/{id}")]
- [Authorize("*")]
- public async Task DeleteAuthMethodAsync(Snowflake id)
- {
- List authMethods = await db
- .AuthMethods.Where(a => a.UserId == CurrentUser!.Id)
- .ToListAsync();
- if (authMethods.Count < 2)
- {
- throw new ApiError(
- "You cannot remove your last authentication method.",
- HttpStatusCode.BadRequest,
- ErrorCode.LastAuthMethod
- );
- }
-
- AuthMethod? authMethod = authMethods.FirstOrDefault(a => a.Id == id);
- if (authMethod == null)
- throw new ApiError.NotFound("No authentication method with that ID found.");
-
- _logger.Debug(
- "Deleting auth method {AuthMethodId} for user {UserId}",
- authMethod.Id,
- CurrentUser!.Id
- );
-
- // If this is the user's last email, we should also clear the user's password.
- if (
- authMethod.AuthType == AuthType.Email
- && authMethods.Count(a => a.AuthType == AuthType.Email) == 1
- )
- {
- _logger.Debug(
- "Deleted last email address for user {UserId}, resetting their password",
- CurrentUser.Id
- );
- CurrentUser.Password = null;
- db.Update(CurrentUser);
- }
-
- db.Remove(authMethod);
- await db.SaveChangesAsync();
-
- return NoContent();
- }
-}
diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs
deleted file mode 100644
index a82956a..0000000
--- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs
+++ /dev/null
@@ -1,186 +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 System.Net;
-using System.Web;
-using EntityFramework.Exceptions.Common;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Extensions;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Services.Auth;
-using Foxnouns.Backend.Utils;
-using JetBrains.Annotations;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers.Authentication;
-
-[Route("/api/internal/auth/discord")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class DiscordAuthController(
- [UsedImplicitly] Config config,
- ILogger logger,
- DatabaseContext db,
- KeyCacheService keyCacheService,
- AuthService authService,
- RemoteAuthService remoteAuthService
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpPost("callback")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task CallbackAsync([FromBody] CallbackRequest req)
- {
- CheckRequirements();
- await keyCacheService.ValidateAuthStateAsync(req.State);
-
- RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestDiscordTokenAsync(
- req.Code
- );
- User? user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
- if (user != null)
- return Ok(await authService.GenerateUserTokenAsync(user));
-
- _logger.Debug(
- "Discord user {Username} ({Id}) authenticated with no local account",
- remoteUser.Username,
- remoteUser.Id
- );
-
- string ticket = AuthUtils.RandomToken();
- await keyCacheService.SetKeyAsync(
- $"discord:{ticket}",
- remoteUser,
- Duration.FromMinutes(20)
- );
-
- return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
- }
-
- [HttpPost("register")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task RegisterAsync([FromBody] OauthRegisterRequest req)
- {
- RemoteAuthService.RemoteUser? remoteUser =
- await keyCacheService.GetKeyAsync(
- $"discord:{req.Ticket}"
- );
- if (remoteUser == null)
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
- if (
- await db.AuthMethods.AnyAsync(a =>
- a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id
- )
- )
- {
- _logger.Error(
- "Discord user {Id} has valid ticket but is already linked to an existing account",
- remoteUser.Id
- );
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
- }
-
- User user = await authService.CreateUserWithRemoteAuthAsync(
- req.Username,
- AuthType.Discord,
- remoteUser.Id,
- remoteUser.Username
- );
-
- return Ok(await authService.GenerateUserTokenAsync(user));
- }
-
- [HttpGet("add-account")]
- [Authorize("*")]
- public async Task AddDiscordAccountAsync()
- {
- CheckRequirements();
-
- string state = await remoteAuthService.ValidateAddAccountRequestAsync(
- CurrentUser!.Id,
- AuthType.Discord
- );
-
- string url =
- "https://discord.com/oauth2/authorize?response_type=code"
- + $"&client_id={config.DiscordAuth.ClientId}&scope=identify"
- + $"&prompt=none&state={state}"
- + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
-
- return Ok(new SingleUrlResponse(url));
- }
-
- [HttpPost("add-account/callback")]
- [Authorize("*")]
- public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req)
- {
- CheckRequirements();
-
- await remoteAuthService.ValidateAddAccountStateAsync(
- req.State,
- CurrentUser!.Id,
- AuthType.Discord
- );
-
- RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestDiscordTokenAsync(
- req.Code
- );
- try
- {
- AuthMethod authMethod = await authService.AddAuthMethodAsync(
- CurrentUser.Id,
- AuthType.Discord,
- remoteUser.Id,
- remoteUser.Username
- );
- _logger.Debug(
- "Added new Discord auth method {AuthMethodId} to user {UserId}",
- authMethod.Id,
- CurrentUser.Id
- );
-
- return Ok(
- new AddOauthAccountResponse(
- authMethod.Id,
- AuthType.Discord,
- authMethod.RemoteId,
- authMethod.RemoteUsername
- )
- );
- }
- catch (UniqueConstraintException)
- {
- throw new ApiError(
- "That account is already linked.",
- HttpStatusCode.BadRequest,
- ErrorCode.AccountAlreadyLinked
- );
- }
- }
-
- private void CheckRequirements()
- {
- if (!config.DiscordAuth.Enabled)
- {
- throw new ApiError.BadRequest(
- "Discord authentication is not enabled on this instance."
- );
- }
- }
-}
diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs
deleted file mode 100644
index 8024ee6..0000000
--- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs
+++ /dev/null
@@ -1,374 +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 System.Net;
-using EntityFramework.Exceptions.Common;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Extensions;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Services.Auth;
-using Foxnouns.Backend.Utils;
-using JetBrains.Annotations;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers.Authentication;
-
-[Route("/api/internal/auth/email")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class EmailAuthController(
- [UsedImplicitly] Config config,
- DatabaseContext db,
- AuthService authService,
- MailService mailService,
- EmailRateLimiter rateLimiter,
- KeyCacheService keyCacheService,
- UserRendererService userRenderer,
- IClock clock,
- ILogger logger
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpPost("register/init")]
- public async Task RegisterInitAsync(
- [FromBody] EmailRegisterRequest req,
- CancellationToken ct = default
- )
- {
- CheckRequirements();
-
- if (!req.Email.Contains('@'))
- throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
-
- 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 (
- await db.AuthMethods.AnyAsync(
- a => a.AuthType == AuthType.Email && a.RemoteId == req.Email,
- ct
- )
- )
- {
- return NoContent();
- }
-
- if (IsRateLimited())
- return NoContent();
-
- mailService.QueueAccountCreationEmail(req.Email, state);
- return NoContent();
- }
-
- [HttpPost("callback")]
- public async Task CallbackAsync([FromBody] EmailCallbackRequest req)
- {
- CheckRequirements();
-
- RegisterEmailState? state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
- if (state is not { ExistingUserId: null })
- throw new ApiError.BadRequest("Invalid state", "state", req.State);
-
- string ticket = AuthUtils.RandomToken();
- await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
-
- return Ok(new CallbackResponse(false, ticket, state.Email, null, null, null));
- }
-
- [HttpPost("register")]
- public async Task CompleteRegistrationAsync(
- [FromBody] EmailCompleteRegistrationRequest req
- )
- {
- CheckRequirements();
-
- string? email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}");
- if (email == null)
- throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket);
-
- User user = await authService.CreateUserWithPasswordAsync(
- req.Username,
- email,
- req.Password
- );
- Application frontendApp = await db.GetFrontendApplicationAsync();
-
- (string? tokenStr, Token? token) = authService.GenerateToken(
- user,
- frontendApp,
- ["*"],
- clock.GetCurrentInstant() + Duration.FromDays(365)
- );
- db.Add(token);
-
- await db.SaveChangesAsync();
-
- await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}");
-
- return Ok(
- new AuthResponse(
- await userRenderer.RenderUserAsync(user, user, renderMembers: false),
- tokenStr,
- token.ExpiresAt
- )
- );
- }
-
- [HttpPost("login")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task LoginAsync(
- [FromBody] EmailLoginRequest req,
- CancellationToken ct = default
- )
- {
- CheckRequirements();
-
- (User? user, AuthService.EmailAuthenticationResult authenticationResult) =
- await authService.AuthenticateUserAsync(req.Email, req.Password, ct);
- if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired)
- throw new NotImplementedException("MFA is not implemented yet");
-
- Application frontendApp = await db.GetFrontendApplicationAsync(ct);
-
- _logger.Debug("Logging user {Id} in with email and password", user.Id);
-
- (string? tokenStr, Token? token) = authService.GenerateToken(
- user,
- frontendApp,
- ["*"],
- clock.GetCurrentInstant() + Duration.FromDays(365)
- );
- db.Add(token);
-
- _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id);
-
- await db.SaveChangesAsync(ct);
-
- return Ok(
- new AuthResponse(
- await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
- tokenStr,
- token.ExpiresAt
- )
- );
- }
-
- [HttpPost("change-password")]
- [Authorize("*")]
- public async Task UpdatePasswordAsync([FromBody] EmailChangePasswordRequest req)
- {
- if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
- throw new ApiError.Forbidden("Invalid password");
-
- ValidationUtils.Validate([("new", ValidationUtils.ValidatePassword(req.New))]);
-
- await authService.SetUserPasswordAsync(CurrentUser!, req.New);
- await db.SaveChangesAsync();
- return NoContent();
- }
-
- [HttpPost("forgot-password")]
- public async Task ForgotPasswordAsync([FromBody] EmailForgotPasswordRequest req)
- {
- CheckRequirements();
-
- if (!req.Email.Contains('@'))
- throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
-
- AuthMethod? authMethod = await db
- .AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email)
- .FirstOrDefaultAsync();
- if (authMethod == null)
- return NoContent();
-
- string state = await keyCacheService.GenerateForgotPasswordStateAsync(
- req.Email,
- authMethod.UserId
- );
-
- if (IsRateLimited())
- return NoContent();
-
- mailService.QueueResetPasswordEmail(req.Email, state);
- return NoContent();
- }
-
- [HttpPost("reset-password")]
- public async Task ResetPasswordAsync([FromBody] EmailResetPasswordRequest req)
- {
- ForgotPasswordState? state = await keyCacheService.GetForgotPasswordStateAsync(req.State);
- if (state == null)
- throw new ApiError.BadRequest("Unknown state", "state", req.State);
-
- if (
- !await db
- .AuthMethods.Where(m =>
- m.AuthType == AuthType.Email
- && m.RemoteId == state.Email
- && m.UserId == state.UserId
- )
- .AnyAsync()
- )
- {
- throw new ApiError.BadRequest("Invalid state");
- }
-
- ValidationUtils.Validate([("password", ValidationUtils.ValidatePassword(req.Password))]);
-
- User user = await db.Users.FirstAsync(u => u.Id == state.UserId);
- await authService.SetUserPasswordAsync(user, req.Password);
- await db.SaveChangesAsync();
-
- mailService.QueuePasswordChangedEmail(state.Email);
-
- return NoContent();
- }
-
- [HttpPost("add-account")]
- [Authorize("*")]
- public async Task AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
- {
- CheckRequirements();
-
- List emails = await db
- .AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Email)
- .ToListAsync();
- if (emails.Count > AuthUtils.MaxAuthMethodsPerType)
- {
- throw new ApiError.BadRequest(
- "Too many email addresses, maximum of 3 per account.",
- "email",
- null
- );
- }
-
- if (emails.Count != 0)
- {
- if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Password))
- throw new ApiError.Forbidden("Invalid password");
- }
- else
- {
- ValidationUtils.Validate(
- [("password", ValidationUtils.ValidatePassword(req.Password))]
- );
- await authService.SetUserPasswordAsync(CurrentUser!, req.Password);
- await db.SaveChangesAsync();
- }
-
- string state = await keyCacheService.GenerateRegisterEmailStateAsync(
- req.Email,
- CurrentUser!.Id
- );
-
- bool emailExists = await db
- .AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email)
- .AnyAsync();
- if (emailExists)
- {
- return NoContent();
- }
-
- if (IsRateLimited())
- return NoContent();
-
- mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
- return NoContent();
- }
-
- [HttpPost("add-account/callback")]
- [Authorize("*")]
- public async Task AddEmailCallbackAsync([FromBody] EmailCallbackRequest req)
- {
- CheckRequirements();
-
- RegisterEmailState? state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
- if (state?.ExistingUserId != CurrentUser!.Id)
- throw new ApiError.BadRequest("Invalid state", "state", req.State);
-
- try
- {
- AuthMethod authMethod = await authService.AddAuthMethodAsync(
- CurrentUser.Id,
- AuthType.Email,
- state.Email
- );
- _logger.Debug(
- "Added email auth {AuthId} for user {UserId}",
- authMethod.Id,
- CurrentUser.Id
- );
-
- return Ok(
- new AddOauthAccountResponse(
- authMethod.Id,
- AuthType.Email,
- authMethod.RemoteId,
- null
- )
- );
- }
- catch (UniqueConstraintException)
- {
- throw new ApiError(
- "That email address is already linked.",
- HttpStatusCode.BadRequest,
- ErrorCode.AccountAlreadyLinked
- );
- }
- }
-
- public record AddEmailAddressRequest(string Email, string Password);
-
- private void CheckRequirements()
- {
- if (!config.EmailAuth.Enabled)
- throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
- }
-
- ///
- /// Checks whether the context's IP address is rate limited from dispatching emails.
- ///
- private bool IsRateLimited()
- {
- if (HttpContext.Connection.RemoteIpAddress == null)
- {
- _logger.Information(
- "No remote IP address in HTTP context for email-related request, ignoring as we can't rate limit it"
- );
- return true;
- }
-
- if (
- !rateLimiter.IsLimited(
- HttpContext.Connection.RemoteIpAddress.ToString(),
- out Duration retryAfter
- )
- )
- {
- return false;
- }
-
- _logger.Information(
- "IP address cannot send email until {RetryAfter}, ignoring",
- retryAfter
- );
- return true;
- }
-}
diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs
deleted file mode 100644
index 1ae2ef9..0000000
--- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs
+++ /dev/null
@@ -1,204 +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 System.Net;
-using EntityFramework.Exceptions.Common;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Services.Auth;
-using Foxnouns.Backend.Utils;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers.Authentication;
-
-[Route("/api/internal/auth/fediverse")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class FediverseAuthController(
- ILogger logger,
- DatabaseContext db,
- FediverseAuthService fediverseAuthService,
- AuthService authService,
- RemoteAuthService remoteAuthService,
- KeyCacheService keyCacheService
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpGet]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task GetFediverseUrlAsync(
- [FromQuery] string instance,
- [FromQuery(Name = "force-refresh")] bool forceRefresh = false
- )
- {
- if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
- throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
-
- string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
- return Ok(new SingleUrlResponse(url));
- }
-
- [HttpPost("callback")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task FediverseCallbackAsync([FromBody] FediverseCallbackRequest req)
- {
- FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
- FediverseAuthService.FediverseUser remoteUser =
- await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code, req.State);
-
- User? user = await authService.AuthenticateUserAsync(
- AuthType.Fediverse,
- remoteUser.Id,
- app
- );
- if (user != null)
- return Ok(await authService.GenerateUserTokenAsync(user));
-
- string ticket = AuthUtils.RandomToken();
- await keyCacheService.SetKeyAsync(
- $"fediverse:{ticket}",
- new FediverseTicketData(app.Id, remoteUser),
- Duration.FromMinutes(20)
- );
-
- return Ok(
- new CallbackResponse(
- false,
- ticket,
- $"@{remoteUser.Username}@{app.Domain}",
- null,
- null,
- null
- )
- );
- }
-
- [HttpPost("register")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task RegisterAsync([FromBody] OauthRegisterRequest req)
- {
- FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync(
- $"fediverse:{req.Ticket}"
- );
- if (ticketData == null)
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
-
- FediverseApplication? app = await db.FediverseApplications.FindAsync(
- ticketData.ApplicationId
- );
- if (app == null)
- throw new FoxnounsError("Null application found for ticket");
-
- if (
- await db.AuthMethods.AnyAsync(a =>
- a.AuthType == AuthType.Fediverse
- && a.RemoteId == ticketData.User.Id
- && a.FediverseApplicationId == app.Id
- )
- )
- {
- _logger.Error(
- "Fediverse user {Id}/{ApplicationId} ({Username} on {Domain}) has valid ticket but is already linked to an existing account",
- ticketData.User.Id,
- ticketData.ApplicationId,
- ticketData.User.Username,
- app.Domain
- );
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
- }
-
- User user = await authService.CreateUserWithRemoteAuthAsync(
- req.Username,
- AuthType.Fediverse,
- ticketData.User.Id,
- ticketData.User.Username,
- app
- );
-
- return Ok(await authService.GenerateUserTokenAsync(user));
- }
-
- [HttpGet("add-account")]
- [Authorize("*")]
- public async Task AddFediverseAccountAsync(
- [FromQuery] string instance,
- [FromQuery(Name = "force-refresh")] bool forceRefresh = false
- )
- {
- if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
- throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
-
- string state = await remoteAuthService.ValidateAddAccountRequestAsync(
- CurrentUser!.Id,
- AuthType.Fediverse,
- instance
- );
-
- string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
- return Ok(new SingleUrlResponse(url));
- }
-
- [HttpPost("add-account/callback")]
- [Authorize("*")]
- public async Task AddAccountCallbackAsync(
- [FromBody] FediverseCallbackRequest req
- )
- {
- FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
- FediverseAuthService.FediverseUser remoteUser =
- await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
- try
- {
- AuthMethod authMethod = await authService.AddAuthMethodAsync(
- CurrentUser!.Id,
- AuthType.Fediverse,
- remoteUser.Id,
- remoteUser.Username,
- app
- );
- _logger.Debug(
- "Added new Fediverse auth method {AuthMethodId} to user {UserId}",
- authMethod.Id,
- CurrentUser.Id
- );
-
- return Ok(
- new AddOauthAccountResponse(
- authMethod.Id,
- AuthType.Fediverse,
- authMethod.RemoteId,
- $"{authMethod.RemoteUsername}@{app.Domain}"
- )
- );
- }
- catch (UniqueConstraintException)
- {
- throw new ApiError(
- "That account is already linked.",
- HttpStatusCode.BadRequest,
- ErrorCode.AccountAlreadyLinked
- );
- }
- }
-
- private record FediverseTicketData(
- Snowflake ApplicationId,
- FediverseAuthService.FediverseUser User
- );
-}
diff --git a/Foxnouns.Backend/Controllers/Authentication/GoogleAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/GoogleAuthController.cs
deleted file mode 100644
index 0f386d7..0000000
--- a/Foxnouns.Backend/Controllers/Authentication/GoogleAuthController.cs
+++ /dev/null
@@ -1,179 +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 System.Net;
-using System.Web;
-using EntityFramework.Exceptions.Common;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Extensions;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Services.Auth;
-using Foxnouns.Backend.Utils;
-using JetBrains.Annotations;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers.Authentication;
-
-[Route("/api/internal/auth/google")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class GoogleAuthController(
- [UsedImplicitly] Config config,
- ILogger logger,
- DatabaseContext db,
- KeyCacheService keyCacheService,
- AuthService authService,
- RemoteAuthService remoteAuthService
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpPost("callback")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task CallbackAsync([FromBody] CallbackRequest req)
- {
- CheckRequirements();
- await keyCacheService.ValidateAuthStateAsync(req.State);
-
- RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync(
- req.Code
- );
- User? user = await authService.AuthenticateUserAsync(AuthType.Google, remoteUser.Id);
- if (user != null)
- return Ok(await authService.GenerateUserTokenAsync(user));
-
- _logger.Debug(
- "Google user {Username} ({Id}) authenticated with no local account",
- remoteUser.Username,
- remoteUser.Id
- );
-
- string ticket = AuthUtils.RandomToken();
- await keyCacheService.SetKeyAsync($"google:{ticket}", remoteUser, Duration.FromMinutes(20));
-
- return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
- }
-
- [HttpPost("register")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task RegisterAsync([FromBody] OauthRegisterRequest req)
- {
- RemoteAuthService.RemoteUser? remoteUser =
- await keyCacheService.GetKeyAsync($"google:{req.Ticket}");
- if (remoteUser == null)
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
- if (
- await db.AuthMethods.AnyAsync(a =>
- a.AuthType == AuthType.Google && a.RemoteId == remoteUser.Id
- )
- )
- {
- _logger.Error(
- "Google user {Id} has valid ticket but is already linked to an existing account",
- remoteUser.Id
- );
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
- }
-
- User user = await authService.CreateUserWithRemoteAuthAsync(
- req.Username,
- AuthType.Google,
- remoteUser.Id,
- remoteUser.Username
- );
-
- return Ok(await authService.GenerateUserTokenAsync(user));
- }
-
- [HttpGet("add-account")]
- [Authorize("*")]
- public async Task AddGoogleAccountAsync()
- {
- CheckRequirements();
-
- string state = await remoteAuthService.ValidateAddAccountRequestAsync(
- CurrentUser!.Id,
- AuthType.Google
- );
-
- string url =
- "https://accounts.google.com/o/oauth2/auth?response_type=code"
- + $"&client_id={config.GoogleAuth.ClientId}"
- + $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
- + $"&prompt=select_account&state={state}"
- + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
-
- return Ok(new SingleUrlResponse(url));
- }
-
- [HttpPost("add-account/callback")]
- [Authorize("*")]
- public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req)
- {
- CheckRequirements();
-
- await remoteAuthService.ValidateAddAccountStateAsync(
- req.State,
- CurrentUser!.Id,
- AuthType.Google
- );
-
- RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync(
- req.Code
- );
- try
- {
- AuthMethod authMethod = await authService.AddAuthMethodAsync(
- CurrentUser.Id,
- AuthType.Google,
- remoteUser.Id,
- remoteUser.Username
- );
- _logger.Debug(
- "Added new Google auth method {AuthMethodId} to user {UserId}",
- authMethod.Id,
- CurrentUser.Id
- );
-
- return Ok(
- new AddOauthAccountResponse(
- authMethod.Id,
- AuthType.Google,
- authMethod.RemoteId,
- authMethod.RemoteUsername
- )
- );
- }
- catch (UniqueConstraintException)
- {
- throw new ApiError(
- "That account is already linked.",
- HttpStatusCode.BadRequest,
- ErrorCode.AccountAlreadyLinked
- );
- }
- }
-
- private void CheckRequirements()
- {
- if (!config.GoogleAuth.Enabled)
- {
- throw new ApiError.BadRequest("Google authentication is not enabled on this instance.");
- }
- }
-}
diff --git a/Foxnouns.Backend/Controllers/Authentication/TumblrAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/TumblrAuthController.cs
deleted file mode 100644
index 9860957..0000000
--- a/Foxnouns.Backend/Controllers/Authentication/TumblrAuthController.cs
+++ /dev/null
@@ -1,178 +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 System.Net;
-using System.Web;
-using EntityFramework.Exceptions.Common;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Extensions;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Services.Auth;
-using Foxnouns.Backend.Utils;
-using JetBrains.Annotations;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers.Authentication;
-
-[Route("/api/internal/auth/tumblr")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class TumblrAuthController(
- [UsedImplicitly] Config config,
- ILogger logger,
- DatabaseContext db,
- KeyCacheService keyCacheService,
- AuthService authService,
- RemoteAuthService remoteAuthService
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpPost("callback")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task CallbackAsync([FromBody] CallbackRequest req)
- {
- CheckRequirements();
- await keyCacheService.ValidateAuthStateAsync(req.State);
-
- RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync(
- req.Code
- );
- User? user = await authService.AuthenticateUserAsync(AuthType.Tumblr, remoteUser.Id);
- if (user != null)
- return Ok(await authService.GenerateUserTokenAsync(user));
-
- _logger.Debug(
- "Tumblr user {Username} ({Id}) authenticated with no local account",
- remoteUser.Username,
- remoteUser.Id
- );
-
- string ticket = AuthUtils.RandomToken();
- await keyCacheService.SetKeyAsync($"tumblr:{ticket}", remoteUser, Duration.FromMinutes(20));
-
- return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
- }
-
- [HttpPost("register")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task RegisterAsync([FromBody] OauthRegisterRequest req)
- {
- RemoteAuthService.RemoteUser? remoteUser =
- await keyCacheService.GetKeyAsync($"tumblr:{req.Ticket}");
- if (remoteUser == null)
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
- if (
- await db.AuthMethods.AnyAsync(a =>
- a.AuthType == AuthType.Tumblr && a.RemoteId == remoteUser.Id
- )
- )
- {
- _logger.Error(
- "Tumblr user {Id} has valid ticket but is already linked to an existing account",
- remoteUser.Id
- );
- throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
- }
-
- User user = await authService.CreateUserWithRemoteAuthAsync(
- req.Username,
- AuthType.Tumblr,
- remoteUser.Id,
- remoteUser.Username
- );
-
- return Ok(await authService.GenerateUserTokenAsync(user));
- }
-
- [HttpGet("add-account")]
- [Authorize("*")]
- public async Task AddTumblrAccountAsync()
- {
- CheckRequirements();
-
- string state = await remoteAuthService.ValidateAddAccountRequestAsync(
- CurrentUser!.Id,
- AuthType.Tumblr
- );
-
- string url =
- "https://www.tumblr.com/oauth2/authorize?response_type=code"
- + $"&client_id={config.TumblrAuth.ClientId}"
- + $"&scope=basic&state={state}"
- + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}";
-
- return Ok(new SingleUrlResponse(url));
- }
-
- [HttpPost("add-account/callback")]
- [Authorize("*")]
- public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req)
- {
- CheckRequirements();
-
- await remoteAuthService.ValidateAddAccountStateAsync(
- req.State,
- CurrentUser!.Id,
- AuthType.Tumblr
- );
-
- RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync(
- req.Code
- );
- try
- {
- AuthMethod authMethod = await authService.AddAuthMethodAsync(
- CurrentUser.Id,
- AuthType.Tumblr,
- remoteUser.Id,
- remoteUser.Username
- );
- _logger.Debug(
- "Added new Tumblr auth method {AuthMethodId} to user {UserId}",
- authMethod.Id,
- CurrentUser.Id
- );
-
- return Ok(
- new AddOauthAccountResponse(
- authMethod.Id,
- AuthType.Tumblr,
- authMethod.RemoteId,
- authMethod.RemoteUsername
- )
- );
- }
- catch (UniqueConstraintException)
- {
- throw new ApiError(
- "That account is already linked.",
- HttpStatusCode.BadRequest,
- ErrorCode.AccountAlreadyLinked
- );
- }
- }
-
- private void CheckRequirements()
- {
- if (!config.TumblrAuth.Enabled)
- {
- throw new ApiError.BadRequest("Tumblr authentication is not enabled on this instance.");
- }
- }
-}
diff --git a/Foxnouns.Backend/Controllers/DebugController.cs b/Foxnouns.Backend/Controllers/DebugController.cs
new file mode 100644
index 0000000..328bf3d
--- /dev/null
+++ b/Foxnouns.Backend/Controllers/DebugController.cs
@@ -0,0 +1,32 @@
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Services;
+using Microsoft.AspNetCore.Mvc;
+using NodaTime;
+
+namespace Foxnouns.Backend.Controllers;
+
+[Route("/api/v2/debug")]
+public class DebugController(DatabaseContext db, AuthService authSvc, IClock clock, ILogger logger) : ApiControllerBase
+{
+ [HttpPost("users")]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
+ public async Task CreateUser([FromBody] CreateUserRequest req)
+ {
+ logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email);
+
+ var user = await authSvc.CreateUserWithPasswordAsync(req.Username, req.Email, req.Password);
+ var frontendApp = await db.GetFrontendApplicationAsync();
+
+ var (tokenStr, token) =
+ authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
+ db.Add(token);
+
+ await db.SaveChangesAsync();
+
+ return Ok(new AuthResponse(user.Id, user.Username, tokenStr));
+ }
+
+ public record CreateUserRequest(string Username, string Password, string Email);
+
+ public record AuthResponse(Snowflake Id, string Username, string Token);
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Controllers/DeleteUserController.cs b/Foxnouns.Backend/Controllers/DeleteUserController.cs
deleted file mode 100644
index d1c8e62..0000000
--- a/Foxnouns.Backend/Controllers/DeleteUserController.cs
+++ /dev/null
@@ -1,89 +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 Foxnouns.Backend.Database;
-using Foxnouns.Backend.Middleware;
-using Microsoft.AspNetCore.Mvc;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers;
-
-[Route("/api/internal/self-delete")]
-[Authorize("*")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class DeleteUserController(DatabaseContext db, IClock clock, ILogger logger)
- : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpPost("delete")]
- public async Task DeleteSelfAsync()
- {
- _logger.Information(
- "User {UserId} has requested their account to be deleted",
- CurrentUser!.Id
- );
-
- CurrentUser.Deleted = true;
- CurrentUser.DeletedAt = clock.GetCurrentInstant();
-
- db.Update(CurrentUser);
- await db.SaveChangesAsync();
- return NoContent();
- }
-
- [HttpPost("force")]
- [Limit(UsableByDeletedUsers = true)]
- public async Task ForceDeleteAsync()
- {
- if (!CurrentUser!.Deleted)
- throw new ApiError.BadRequest("Your account isn't deleted.");
-
- _logger.Information(
- "User {UserId} has requested an early full delete of their account",
- CurrentUser.Id
- );
-
- // This is the easiest way to force delete a user, don't judge me
- CurrentUser.DeletedAt = clock.GetCurrentInstant() - Duration.FromDays(365);
- db.Update(CurrentUser);
- await db.SaveChangesAsync();
- return NoContent();
- }
-
- [HttpPost("undelete")]
- [Limit(UsableByDeletedUsers = true)]
- public async Task UndeleteSelfAsync()
- {
- if (!CurrentUser!.Deleted)
- throw new ApiError.BadRequest("Your account isn't deleted.");
- if (CurrentUser!.DeletedBy != null)
- {
- throw new ApiError.BadRequest(
- "Your account has been suspended and can't be reactivated by yourself."
- );
- }
-
- _logger.Information(
- "User {UserId} has requested to undelete their account",
- CurrentUser.Id
- );
-
- CurrentUser.Deleted = false;
- CurrentUser.DeletedAt = null;
- db.Update(CurrentUser);
- await db.SaveChangesAsync();
- return NoContent();
- }
-}
diff --git a/Foxnouns.Backend/Controllers/ExportsController.cs b/Foxnouns.Backend/Controllers/ExportsController.cs
deleted file mode 100644
index 0442386..0000000
--- a/Foxnouns.Backend/Controllers/ExportsController.cs
+++ /dev/null
@@ -1,80 +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 Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Jobs;
-using Foxnouns.Backend.Middleware;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers;
-
-[Route("/api/internal/data-exports")]
-[Authorize("identify")]
-[Limit(UsableByDeletedUsers = true)]
-[ApiExplorerSettings(IgnoreApi = true)]
-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();
-
- [HttpGet]
- public async Task GetDataExportsAsync()
- {
- DataExport? export = await db
- .DataExports.Where(d => d.UserId == CurrentUser!.Id)
- .OrderByDescending(d => d.Id)
- .FirstOrDefaultAsync();
- if (export == null)
- return Ok(new DataExportResponse(null, null));
-
- return Ok(
- new DataExportResponse(
- ExportUrl(CurrentUser!.Id, export.Filename),
- export.Id.Time + DataExport.Expiration
- )
- );
- }
-
- private string ExportUrl(Snowflake userId, string filename) =>
- $"{config.MediaBaseUrl}/data-exports/{userId}/{filename}/data-export.zip";
-
- [HttpPost]
- public async Task QueueDataExportAsync()
- {
- var snowflakeToCheck = Snowflake.FromInstant(
- clock.GetCurrentInstant() - MinimumTimeBetween
- );
- _logger.Debug(
- "Checking if user {UserId} has data exports newer than {Snowflake}",
- CurrentUser!.Id,
- snowflakeToCheck
- );
- if (
- await db.DataExports.AnyAsync(d =>
- d.UserId == CurrentUser.Id && d.Id > snowflakeToCheck
- )
- )
- {
- throw new ApiError.BadRequest("You can't request a new data export so soon.");
- }
-
- CreateDataExportJob.Enqueue(CurrentUser.Id);
- return NoContent();
- }
-}
diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs
deleted file mode 100644
index bed022a..0000000
--- a/Foxnouns.Backend/Controllers/FlagsController.cs
+++ /dev/null
@@ -1,209 +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 Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Jobs;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Utils;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using XidNet;
-
-namespace Foxnouns.Backend.Controllers;
-
-[Route("/api/v2/users/@me/flags")]
-public class FlagsController(
- DatabaseContext db,
- UserRendererService userRenderer,
- ISnowflakeGenerator snowflakeGenerator
-) : ApiControllerBase
-{
- [HttpGet]
- [Limit(UsableByDeletedUsers = true)]
- [Authorize("user.read_flags")]
- [ProducesResponseType>(statusCode: StatusCodes.Status200OK)]
- public async Task GetFlagsAsync(CancellationToken ct = default)
- {
- List flags = await db
- .PrideFlags.Where(f => f.UserId == CurrentUser!.Id)
- .OrderBy(f => f.Name)
- .ThenBy(f => f.Id)
- .ToListAsync(ct);
-
- return Ok(flags.Select(userRenderer.RenderPrideFlag));
- }
-
- public const int MaxFlagCount = 500;
-
- [HttpPost]
- [Authorize("user.update_flags")]
- [ProducesResponseType(statusCode: StatusCodes.Status202Accepted)]
- public async Task CreateFlagAsync([FromBody] CreateFlagRequest req)
- {
- int flagCount = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).CountAsync();
- if (flagCount >= MaxFlagCount)
- throw new ApiError.BadRequest("Maximum number of flags reached");
-
- ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
-
- var flag = new PrideFlag
- {
- Id = snowflakeGenerator.GenerateSnowflake(),
- LegacyId = Xid.NewXid().ToString(),
- UserId = CurrentUser!.Id,
- Name = req.Name,
- Description = req.Description,
- };
-
- db.Add(flag);
- await db.SaveChangesAsync();
-
- CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image));
- return Accepted(userRenderer.RenderPrideFlag(flag));
- }
-
- [HttpPatch("{id}")]
- [Authorize("user.create_flags")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
- {
- ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, null));
-
- PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
- f.Id == id && f.UserId == CurrentUser!.Id
- );
- if (flag == null)
- throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
-
- if (req.Name != null)
- flag.Name = req.Name;
-
- if (req.HasProperty(nameof(req.Description)))
- flag.Description = req.Description;
-
- db.Update(flag);
- await db.SaveChangesAsync();
-
- return Ok(userRenderer.RenderPrideFlag(flag));
- }
-
- [HttpDelete("{id}")]
- [Authorize("user.update_flags")]
- public async Task DeleteFlagAsync(Snowflake id)
- {
- PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
- f.Id == id && f.UserId == CurrentUser!.Id
- );
- if (flag == null)
- throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
-
- db.PrideFlags.Remove(flag);
- await db.SaveChangesAsync();
-
- return NoContent();
- }
-
- private static List<(string, ValidationError?)> ValidateFlag(
- string? name,
- string? description,
- string? imageData
- )
- {
- var errors = new List<(string, ValidationError?)>();
-
- if (name != null)
- {
- switch (name.Length)
- {
- case < 1:
- errors.Add(
- (
- "name",
- ValidationError.LengthError("Name is too short", 1, 100, name.Length)
- )
- );
- break;
- case > 100:
- errors.Add(
- (
- "name",
- ValidationError.LengthError("Name is too long", 1, 100, name.Length)
- )
- );
- break;
- }
- }
-
- if (description != null)
- {
- switch (description.Length)
- {
- case < 1:
- errors.Add(
- (
- "description",
- ValidationError.LengthError(
- "Description is too short",
- 1,
- 100,
- description.Length
- )
- )
- );
- break;
- case > 500:
- errors.Add(
- (
- "description",
- ValidationError.LengthError(
- "Description is too long",
- 1,
- 100,
- description.Length
- )
- )
- );
- break;
- }
- }
-
- if (imageData != null)
- {
- switch (imageData.Length)
- {
- case 0:
- errors.Add(
- (
- "image",
- ValidationError.GenericValidationError("Image cannot be empty", null)
- )
- );
- break;
- case > 1_500_000:
- errors.Add(
- (
- "image",
- ValidationError.GenericValidationError("Image is too large", null)
- )
- );
- break;
- }
- }
-
- return errors;
- }
-}
diff --git a/Foxnouns.Backend/Controllers/InternalController.cs b/Foxnouns.Backend/Controllers/InternalController.cs
deleted file mode 100644
index 3954547..0000000
--- a/Foxnouns.Backend/Controllers/InternalController.cs
+++ /dev/null
@@ -1,123 +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 System.Text.RegularExpressions;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Utils;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Controllers;
-using Microsoft.AspNetCore.Mvc.Routing;
-using Microsoft.AspNetCore.Routing.Template;
-
-namespace Foxnouns.Backend.Controllers;
-
-[ApiController]
-[Route("/api/internal")]
-[ApiExplorerSettings(IgnoreApi = true)]
-public partial class InternalController(DatabaseContext db) : ControllerBase
-{
- [GeneratedRegex(@"(\{\w+\})")]
- private static partial Regex PathVarRegex();
-
- [GeneratedRegex(@"\{id\}")]
- private static partial Regex IdCountRegex();
-
- private static string GetCleanedTemplate(string template)
- {
- if (template.StartsWith("api/v2"))
- template = template["api/v2".Length..];
- else if (template.StartsWith("api/v1"))
- template = template["api/v1".Length..];
- template = PathVarRegex()
- .Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}`
- .Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}`
-
- // If there's at least one path parameter, we only return the *first* part of the path.
- if (template.Contains("{id}"))
- {
- // However, if the path starts with /users/{id} *and* there's another path parameter (such as a member ID)
- // we ignore the leading /users/{id}. This is because a lot of routes are scoped by user, but should have
- // separate rate limits from other user-scoped routes.
- if (template.StartsWith("/users/{id}/") && IdCountRegex().Count(template) >= 2)
- template = template["/users/{id}".Length..];
-
- return template.Split("{id}")[0] + "{id}";
- }
-
- return template;
- }
-
- [HttpPost("request-data")]
- public async Task GetRequestDataAsync([FromBody] RequestDataRequest req)
- {
- RouteEndpoint? endpoint = GetEndpoint(HttpContext, req.Path, req.Method);
- if (endpoint == null)
- throw new ApiError.BadRequest("Path/method combination is invalid");
-
- ControllerActionDescriptor? actionDescriptor =
- endpoint.Metadata.GetMetadata();
- string? template = actionDescriptor?.AttributeRouteInfo?.Template;
- if (template == null)
- throw new FoxnounsError("Template value was null on valid endpoint");
- template = GetCleanedTemplate(template);
-
- // If no token was supplied, or it isn't valid base 64, return a null user ID (limiting by IP)
- if (!AuthUtils.TryParseToken(req.Token, out byte[]? rawToken))
- return Ok(new RequestDataResponse(null, template));
-
- Snowflake? userId = await db.GetTokenUserId(rawToken);
- return Ok(new RequestDataResponse(userId, template));
- }
-
- private static RouteEndpoint? GetEndpoint(
- HttpContext httpContext,
- string url,
- string requestMethod
- )
- {
- EndpointDataSource? endpointDataSource =
- httpContext.RequestServices.GetService();
- if (endpointDataSource == null)
- return null;
- IEnumerable endpoints = endpointDataSource.Endpoints.OfType();
-
- foreach (RouteEndpoint? endpoint in endpoints)
- {
- if (endpoint.RoutePattern.RawText == null)
- continue;
-
- var templateMatcher = new TemplateMatcher(
- TemplateParser.Parse(endpoint.RoutePattern.RawText),
- new RouteValueDictionary()
- );
- if (!templateMatcher.TryMatch(url, new RouteValueDictionary()))
- continue;
- HttpMethodAttribute? httpMethodAttribute =
- endpoint.Metadata.GetMetadata();
- if (
- httpMethodAttribute?.HttpMethods.Any(x =>
- x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase)
- ) == false
- )
- {
- continue;
- }
-
- return endpoint;
- }
-
- return null;
- }
-}
diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs
deleted file mode 100644
index 635dab9..0000000
--- a/Foxnouns.Backend/Controllers/MembersController.cs
+++ /dev/null
@@ -1,322 +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 EntityFramework.Exceptions.Common;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Extensions;
-using Foxnouns.Backend.Jobs;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Utils;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Storage;
-using NodaTime;
-using XidNet;
-
-namespace Foxnouns.Backend.Controllers;
-
-[Route("/api/v2/users/{userRef}/members")]
-public class MembersController(
- ILogger logger,
- DatabaseContext db,
- MemberRendererService memberRenderer,
- ISnowflakeGenerator snowflakeGenerator,
- ObjectStorageService objectStorageService,
- IClock clock,
- ValidationService validationService,
- Config config
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- [HttpGet]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [Limit(UsableByDeletedUsers = true)]
- public async Task GetMembersAsync(string userRef, CancellationToken ct = default)
- {
- User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
- return Ok(await memberRenderer.RenderUserMembersAsync(user, CurrentToken));
- }
-
- [HttpGet("{memberRef}")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [Limit(UsableByDeletedUsers = true)]
- public async Task GetMemberAsync(
- string userRef,
- string memberRef,
- CancellationToken ct = default
- )
- {
- Member member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct);
- return Ok(memberRenderer.RenderMember(member, CurrentToken));
- }
-
- [HttpPost("/api/v2/users/@me/members")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [Authorize("member.create")]
- public async Task CreateMemberAsync(
- [FromBody] CreateMemberRequest req,
- CancellationToken ct = default
- )
- {
- ValidationUtils.Validate(
- [
- ("name", validationService.ValidateMemberName(req.Name)),
- ("display_name", validationService.ValidateDisplayName(req.DisplayName)),
- ("bio", validationService.ValidateBio(req.Bio)),
- ("avatar", validationService.ValidateAvatar(req.Avatar)),
- .. validationService.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
- .. validationService.ValidateFieldEntries(
- req.Names?.ToArray(),
- CurrentUser!.CustomPreferences,
- "names"
- ),
- .. validationService.ValidatePronouns(
- req.Pronouns?.ToArray(),
- CurrentUser!.CustomPreferences
- ),
- .. validationService.ValidateLinks(req.Links),
- ]
- );
-
- int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
- if (memberCount >= config.Limits.MaxMemberCount)
- throw new ApiError.BadRequest("Maximum number of members reached");
-
- var member = new Member
- {
- Id = snowflakeGenerator.GenerateSnowflake(),
- LegacyId = Xid.NewXid().ToString(),
- User = CurrentUser!,
- Name = req.Name,
- DisplayName = req.DisplayName,
- Bio = req.Bio,
- Links = req.Links ?? [],
- Fields = req.Fields ?? [],
- Names = req.Names ?? [],
- Pronouns = req.Pronouns ?? [],
- Unlisted = req.Unlisted ?? false,
- Sid = null!,
- };
- db.Add(member);
-
- _logger.Debug(
- "Creating member {MemberName} ({Id}) for {UserId}",
- member.Name,
- member.Id,
- CurrentUser!.Id
- );
-
- CurrentUser.LastActive = clock.GetCurrentInstant();
- db.Update(CurrentUser);
-
- try
- {
- await db.SaveChangesAsync(ct);
- }
- catch (UniqueConstraintException)
- {
- _logger.Debug("Could not create member {Id} due to name conflict", member.Id);
- throw new ApiError.BadRequest(
- "A member with that name already exists",
- "name",
- req.Name
- );
- }
-
- if (req.Avatar != null)
- {
- MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
- }
-
- return Ok(memberRenderer.RenderMember(member, CurrentToken));
- }
-
- [HttpPatch("/api/v2/users/@me/members/{memberRef}")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- [Authorize("member.update")]
- public async Task UpdateMemberAsync(
- string memberRef,
- [FromBody] UpdateMemberRequest req
- )
- {
- await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
- Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
- var errors = new List<(string, ValidationError?)>();
-
- // We might add extra validations for names later down the line.
- // These should only take effect when a member's name is changed, not on other changes.
- if (req.Name != null && req.Name != member.Name)
- {
- errors.Add(("name", validationService.ValidateMemberName(req.Name)));
- member.Name = req.Name;
- }
-
- if (req.HasProperty(nameof(req.DisplayName)))
- {
- errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName)));
- member.DisplayName = req.DisplayName;
- }
-
- if (req.HasProperty(nameof(req.Bio)))
- {
- errors.Add(("bio", validationService.ValidateBio(req.Bio)));
- member.Bio = req.Bio;
- }
-
- if (req.HasProperty(nameof(req.Links)))
- {
- errors.AddRange(validationService.ValidateLinks(req.Links));
- member.Links = req.Links ?? [];
- }
-
- if (req.HasProperty(nameof(req.Unlisted)))
- member.Unlisted = req.Unlisted ?? false;
-
- if (req.Names != null)
- {
- errors.AddRange(
- validationService.ValidateFieldEntries(
- req.Names,
- CurrentUser!.CustomPreferences,
- "names"
- )
- );
- member.Names = req.Names.ToList();
- }
-
- if (req.Pronouns != null)
- {
- errors.AddRange(
- validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
- );
- member.Pronouns = req.Pronouns.ToList();
- }
-
- if (req.Fields != null)
- {
- errors.AddRange(
- validationService.ValidateFields(
- req.Fields.ToList(),
- CurrentUser!.CustomPreferences
- )
- );
- member.Fields = req.Fields.ToList();
- }
-
- if (req.Flags != null)
- {
- ValidationError? flagError = await db.SetMemberFlagsAsync(
- CurrentUser!.Id,
- member.Id,
- req.Flags
- );
- if (flagError != null)
- errors.Add(("flags", flagError));
- }
-
- if (req.HasProperty(nameof(req.Avatar)))
- errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar)));
-
- ValidationUtils.Validate(errors);
- // This is fired off regardless of whether the transaction is committed
- // (atomic operations are hard when combined with background jobs)
- // so it's in a separate block to the validation above.
- if (req.HasProperty(nameof(req.Avatar)))
- {
- MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
- }
-
- CurrentUser.LastActive = clock.GetCurrentInstant();
- db.Update(CurrentUser);
-
- try
- {
- await db.SaveChangesAsync();
- }
- catch (UniqueConstraintException)
- {
- _logger.Debug(
- "Could not update member {Id} due to name conflict ({CurrentName} / {NewName})",
- member.Id,
- member.Name,
- req.Name
- );
- throw new ApiError.BadRequest(
- "A member with that name already exists",
- "name",
- req.Name
- );
- }
-
- await tx.CommitAsync();
- return Ok(memberRenderer.RenderMember(member, CurrentToken));
- }
-
- [HttpDelete("/api/v2/users/@me/members/{memberRef}")]
- [Authorize("member.update")]
- public async Task DeleteMemberAsync(string memberRef)
- {
- Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
- int deleteCount = await db
- .Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id)
- .ExecuteDeleteAsync();
- if (deleteCount == 0)
- {
- _logger.Warning(
- "Successfully resolved member {Id} but could not delete them",
- member.Id
- );
- return NoContent();
- }
-
- if (member.Avatar != null)
- await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar);
- return NoContent();
- }
-
- [HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
- [Authorize("member.update")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task RerollSidAsync(string memberRef)
- {
- Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
-
- Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
- if (CurrentUser!.LastSidReroll > minTimeAgo)
- throw new ApiError.BadRequest("Cannot reroll short ID yet");
-
- // Using ExecuteUpdateAsync here as the new short ID is generated by the database
- await db
- .Members.Where(m => m.Id == member.Id)
- .ExecuteUpdateAsync(s => s.SetProperty(m => m.Sid, _ => db.FindFreeMemberSid()));
-
- await db
- .Users.Where(u => u.Id == CurrentUser.Id)
- .ExecuteUpdateAsync(s =>
- s.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
- .SetProperty(u => u.LastActive, clock.GetCurrentInstant())
- );
-
- // Fetch the new sid then pass that to RenderMember
- string newSid = await db
- .Members.Where(m => m.Id == member.Id)
- .Select(m => m.Sid)
- .FirstAsync();
- return Ok(memberRenderer.RenderMember(member, CurrentToken, newSid));
- }
-}
diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs
deleted file mode 100644
index 0166e86..0000000
--- a/Foxnouns.Backend/Controllers/MetaController.cs
+++ /dev/null
@@ -1,85 +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 System.Text.RegularExpressions;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Services.Caching;
-using Foxnouns.Backend.Utils;
-using Microsoft.AspNetCore.Mvc;
-
-namespace Foxnouns.Backend.Controllers;
-
-[Route("/api/v2/meta")]
-public partial class MetaController(Config config, NoticeCacheService noticeCache)
- : ApiControllerBase
-{
- private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
-
- [HttpGet]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task GetMeta(CancellationToken ct = default) =>
- Ok(
- new MetaResponse(
- Repository,
- BuildInfo.Version,
- BuildInfo.Hash,
- (int)FoxnounsMetrics.MemberCount.Value,
- new UserInfoResponse(
- (int)FoxnounsMetrics.UsersCount.Value,
- (int)FoxnounsMetrics.UsersActiveMonthCount.Value,
- (int)FoxnounsMetrics.UsersActiveWeekCount.Value,
- (int)FoxnounsMetrics.UsersActiveDayCount.Value
- ),
- new LimitsResponse(
- config.Limits.MaxMemberCount,
- config.Limits.MaxBioLength,
- ValidationUtils.MaxCustomPreferences,
- AuthUtils.MaxAuthMethodsPerType,
- FlagsController.MaxFlagCount
- ),
- Notice: NoticeResponse(await noticeCache.GetAsync(ct))
- )
- );
-
- private static MetaNoticeResponse? NoticeResponse(Notice? notice) =>
- notice == null ? null : new MetaNoticeResponse(notice.Id, notice.Message);
-
- [HttpGet("page/{page}")]
- public async Task GetStaticPageAsync(string page, CancellationToken ct = default)
- {
- if (!PageRegex().IsMatch(page))
- {
- throw new ApiError.BadRequest("Invalid page name");
- }
-
- string path = Path.Join(Directory.GetCurrentDirectory(), "static-pages", $"{page}.md");
- try
- {
- string text = await System.IO.File.ReadAllTextAsync(path, ct);
- return Ok(text);
- }
- catch (FileNotFoundException)
- {
- throw new ApiError.NotFound("Page not found", code: ErrorCode.PageNotFound);
- }
- }
-
- [HttpGet("/api/v2/coffee")]
- public IActionResult BrewCoffee() =>
- StatusCode(StatusCodes.Status418ImATeapot, "Sorry, I'm a teapot!");
-
- [GeneratedRegex(@"^[a-z\-_]+$")]
- private static partial Regex PageRegex();
-}
diff --git a/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs b/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs
deleted file mode 100644
index 304cfa4..0000000
--- a/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs
+++ /dev/null
@@ -1,78 +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 Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-namespace Foxnouns.Backend.Controllers.Moderation;
-
-[Route("/api/v2/moderation/audit-log")]
-[Authorize("user.moderation")]
-[Limit(RequireModerator = true)]
-public class AuditLogController(DatabaseContext db, ModerationRendererService moderationRenderer)
- : ApiControllerBase
-{
- public async Task GetAuditLogAsync(
- [FromQuery] AuditLogEntryType? type = null,
- [FromQuery] int? limit = null,
- [FromQuery] Snowflake? before = null,
- [FromQuery] Snowflake? after = null,
- [FromQuery(Name = "by-moderator")] Snowflake? byModerator = null
- )
- {
- limit = limit switch
- {
- > 100 => 100,
- < 0 => 100,
- null => 100,
- _ => limit,
- };
-
- IQueryable query = db
- .AuditLog.Include(e => e.Report)
- .OrderByDescending(e => e.Id);
-
- if (before != null)
- query = query.Where(e => e.Id < before.Value);
- else if (after != null)
- query = query.Where(e => e.Id > after.Value);
-
- if (type != null)
- query = query.Where(e => e.Type == type);
- if (byModerator != null)
- query = query.Where(e => e.ModeratorId == byModerator.Value);
-
- List entries = await query.Take(limit!.Value).ToListAsync();
-
- return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
- }
-
- [HttpGet("moderators")]
- public async Task GetModeratorsAsync(CancellationToken ct = default)
- {
- var moderators = await db
- .Users.Where(u =>
- !u.Deleted && (u.Role == UserRole.Admin || u.Role == UserRole.Moderator)
- )
- .Select(u => new { u.Id, u.Username })
- .OrderBy(u => u.Id)
- .ToListAsync(ct);
-
- return Ok(moderators);
- }
-}
diff --git a/Foxnouns.Backend/Controllers/Moderation/LookupController.cs b/Foxnouns.Backend/Controllers/Moderation/LookupController.cs
deleted file mode 100644
index 9e9fa7f..0000000
--- a/Foxnouns.Backend/Controllers/Moderation/LookupController.cs
+++ /dev/null
@@ -1,96 +0,0 @@
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-namespace Foxnouns.Backend.Controllers.Moderation;
-
-[Route("/api/v2/moderation/lookup")]
-[Authorize("user.moderation")]
-[Limit(RequireModerator = true)]
-public class LookupController(
- DatabaseContext db,
- UserRendererService userRenderer,
- ModerationService moderationService,
- ModerationRendererService moderationRenderer
-) : ApiControllerBase
-{
- [HttpPost]
- public async Task QueryUsersAsync(
- [FromBody] QueryUsersRequest req,
- CancellationToken ct = default
- )
- {
- var query = db.Users.Select(u => new { u.Id, u.Username });
- query = req.Fuzzy
- ? query.Where(u => u.Username.Contains(req.Query))
- : query.Where(u => u.Username == req.Query);
-
- var users = await query.OrderBy(u => u.Id).Take(100).ToListAsync(ct);
- return Ok(users);
- }
-
- [HttpGet("{id}")]
- public async Task QueryUserAsync(Snowflake id, CancellationToken ct = default)
- {
- User user = await db.ResolveUserAsync(id, ct);
-
- bool showSensitiveData = await moderationService.ShowSensitiveDataAsync(
- CurrentUser!,
- user,
- ct
- );
-
- List authMethods = showSensitiveData
- ? await db
- .AuthMethods.Where(a => a.UserId == user.Id)
- .Include(a => a.FediverseApplication)
- .ToListAsync(ct)
- : [];
-
- return Ok(
- new QueryUserResponse(
- User: await userRenderer.RenderUserAsync(
- user,
- renderMembers: false,
- renderAuthMethods: false,
- ct: ct
- ),
- MemberListHidden: user.ListHidden,
- LastActive: user.LastActive,
- LastSidReroll: user.LastSidReroll,
- Suspended: user is { Deleted: true, DeletedBy: not null },
- Deleted: user.Deleted,
- ShowSensitiveData: showSensitiveData,
- AuthMethods: showSensitiveData
- ? authMethods.Select(UserRendererService.RenderAuthMethod)
- : null
- )
- );
- }
-
- [HttpPost("{id}/sensitive")]
- public async Task QuerySensitiveUserDataAsync(
- Snowflake id,
- [FromBody] QuerySensitiveUserDataRequest req
- )
- {
- User user = await db.ResolveUserAsync(id);
-
- // Don't let mods accidentally spam the audit log
- bool alreadyAuthorized = await moderationService.ShowSensitiveDataAsync(CurrentUser!, user);
- if (alreadyAuthorized)
- return NoContent();
-
- AuditLogEntry entry = await moderationService.QuerySensitiveDataAsync(
- CurrentUser!,
- user,
- req.Reason
- );
-
- return Ok(moderationRenderer.RenderAuditLogEntry(entry));
- }
-}
diff --git a/Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs b/Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs
deleted file mode 100644
index 2fb4473..0000000
--- a/Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs
+++ /dev/null
@@ -1,138 +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 System.Net;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-namespace Foxnouns.Backend.Controllers.Moderation;
-
-[Route("/api/v2/moderation")]
-[Authorize("user.moderation")]
-[Limit(RequireModerator = true)]
-public class ModActionsController(
- DatabaseContext db,
- ModerationService moderationService,
- ModerationRendererService moderationRenderer
-) : ApiControllerBase
-{
- [HttpPost("warnings/{id}")]
- public async Task WarnUserAsync(Snowflake id, [FromBody] WarnUserRequest req)
- {
- User user = await db.ResolveUserAsync(id);
- if (user.Deleted)
- {
- throw new ApiError(
- "This user is already deleted.",
- HttpStatusCode.BadRequest,
- ErrorCode.InvalidWarningTarget
- );
- }
-
- if (user.Id == CurrentUser!.Id)
- {
- throw new ApiError(
- "You can't warn yourself.",
- HttpStatusCode.BadRequest,
- ErrorCode.InvalidWarningTarget
- );
- }
-
- Member? member = null;
- if (req.MemberId != null)
- {
- member = await db.Members.FirstOrDefaultAsync(m =>
- m.Id == req.MemberId && m.UserId == user.Id
- );
- if (member == null)
- throw new ApiError.NotFound("No member with that ID found.");
- }
-
- Report? report = null;
- if (req.ReportId != null)
- {
- report = await db.Reports.FindAsync(req.ReportId);
- if (report is not { Status: ReportStatus.Open })
- {
- throw new ApiError.NotFound(
- "No report with that ID found, or it's already closed."
- );
- }
- }
-
- AuditLogEntry entry = await moderationService.ExecuteWarningAsync(
- CurrentUser,
- user,
- member,
- report,
- req.Reason,
- req.ClearFields
- );
-
- return Ok(moderationRenderer.RenderAuditLogEntry(entry));
- }
-
- [HttpPost("suspensions/{id}")]
- public async Task SuspendUserAsync(
- Snowflake id,
- [FromBody] SuspendUserRequest req
- )
- {
- User user = await db.ResolveUserAsync(id);
- if (user.Deleted)
- {
- throw new ApiError(
- "This user is already deleted.",
- HttpStatusCode.BadRequest,
- ErrorCode.InvalidWarningTarget
- );
- }
-
- if (user.Id == CurrentUser!.Id)
- {
- throw new ApiError(
- "You can't warn yourself.",
- HttpStatusCode.BadRequest,
- ErrorCode.InvalidWarningTarget
- );
- }
-
- Report? report = null;
- if (req.ReportId != null)
- {
- report = await db.Reports.FindAsync(req.ReportId);
- if (report is not { Status: ReportStatus.Open })
- {
- throw new ApiError.NotFound(
- "No report with that ID found, or it's already closed."
- );
- }
- }
-
- AuditLogEntry entry = await moderationService.ExecuteSuspensionAsync(
- CurrentUser,
- user,
- report,
- req.Reason,
- req.ClearProfile
- );
-
- return Ok(moderationRenderer.RenderAuditLogEntry(entry));
- }
-}
diff --git a/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs b/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
deleted file mode 100644
index 3d2d6bb..0000000
--- a/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers.Moderation;
-
-[Route("/api/v2/notices")]
-[Authorize("user.moderation")]
-[Limit(RequireModerator = true)]
-public class NoticesController(
- DatabaseContext db,
- UserRendererService userRenderer,
- ISnowflakeGenerator snowflakeGenerator,
- IClock clock
-) : ApiControllerBase
-{
- [HttpGet]
- public async Task GetNoticesAsync(CancellationToken ct = default)
- {
- List notices = await db
- .Notices.Include(n => n.Author)
- .OrderByDescending(n => n.Id)
- .ToListAsync(ct);
- return Ok(notices.Select(RenderNotice));
- }
-
- [HttpPost]
- public async Task CreateNoticeAsync(CreateNoticeRequest req)
- {
- Instant now = clock.GetCurrentInstant();
- if (req.StartTime < now)
- {
- throw new ApiError.BadRequest(
- "Start time cannot be in the past",
- "start_time",
- req.StartTime
- );
- }
-
- if (req.EndTime < now)
- {
- throw new ApiError.BadRequest(
- "End time cannot be in the past",
- "end_time",
- req.EndTime
- );
- }
-
- var notice = new Notice
- {
- Id = snowflakeGenerator.GenerateSnowflake(),
- Message = req.Message,
- StartTime = req.StartTime ?? clock.GetCurrentInstant(),
- EndTime = req.EndTime,
- Author = CurrentUser!,
- };
-
- db.Add(notice);
- await db.SaveChangesAsync();
-
- return Ok(RenderNotice(notice));
- }
-
- private NoticeResponse RenderNotice(Notice notice) =>
- new(
- notice.Id,
- notice.Message,
- notice.StartTime,
- notice.EndTime,
- userRenderer.RenderPartialUser(notice.Author)
- );
-}
diff --git a/Foxnouns.Backend/Controllers/Moderation/ReportsController.cs b/Foxnouns.Backend/Controllers/Moderation/ReportsController.cs
deleted file mode 100644
index c5472b3..0000000
--- a/Foxnouns.Backend/Controllers/Moderation/ReportsController.cs
+++ /dev/null
@@ -1,277 +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 System.Net;
-using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Utils;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using Newtonsoft.Json;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers.Moderation;
-
-[Route("/api/v2/moderation")]
-public class ReportsController(
- ILogger logger,
- DatabaseContext db,
- IClock clock,
- ISnowflakeGenerator snowflakeGenerator,
- UserRendererService userRenderer,
- MemberRendererService memberRenderer,
- ModerationRendererService moderationRenderer,
- ModerationService moderationService
-) : ApiControllerBase
-{
- private readonly ILogger _logger = logger.ForContext();
-
- private Snowflake MaxReportId() =>
- Snowflake.FromInstant(clock.GetCurrentInstant() - Duration.FromHours(12));
-
- [HttpPost("report-user/{id}")]
- [Authorize("user.moderation")]
- public async Task ReportUserAsync(
- Snowflake id,
- [FromBody] CreateReportRequest req
- )
- {
- ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
-
- User target = await db.ResolveUserAsync(id);
-
- if (target.Id == CurrentUser!.Id)
- {
- throw new ApiError(
- "You can't report yourself.",
- HttpStatusCode.BadRequest,
- ErrorCode.InvalidReportTarget
- );
- }
-
- Snowflake reportCutoff = MaxReportId();
- if (
- await db
- .Reports.Where(r =>
- r.ReporterId == CurrentUser!.Id
- && r.TargetUserId == target.Id
- && r.Id > reportCutoff
- )
- .AnyAsync()
- )
- {
- _logger.Debug(
- "User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
- CurrentUser!.Id,
- target.Id
- );
- return NoContent();
- }
-
- _logger.Information(
- "Creating report on {TargetId} by {ReporterId}",
- target.Id,
- CurrentUser!.Id
- );
-
- string snapshot = JsonConvert.SerializeObject(
- await userRenderer.RenderUserAsync(target, renderMembers: false)
- );
-
- var report = new Report
- {
- Id = snowflakeGenerator.GenerateSnowflake(),
- ReporterId = CurrentUser.Id,
- TargetUserId = target.Id,
- TargetMemberId = null,
- Reason = req.Reason,
- Context = req.Context,
- TargetType = ReportTargetType.User,
- TargetSnapshot = snapshot,
- };
-
- db.Reports.Add(report);
- await db.SaveChangesAsync();
- return NoContent();
- }
-
- [HttpPost("report-member/{id}")]
- [Authorize("user.moderation")]
- public async Task ReportMemberAsync(
- Snowflake id,
- [FromBody] CreateReportRequest req
- )
- {
- ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
-
- Member target = await db.ResolveMemberAsync(id);
-
- if (target.User.Id == CurrentUser!.Id)
- {
- throw new ApiError(
- "You can't report yourself.",
- HttpStatusCode.BadRequest,
- ErrorCode.InvalidReportTarget
- );
- }
-
- Snowflake reportCutoff = MaxReportId();
- if (
- await db
- .Reports.Where(r =>
- r.ReporterId == CurrentUser!.Id
- && r.TargetUserId == target.User.Id
- && r.Id > reportCutoff
- )
- .AnyAsync()
- )
- {
- _logger.Debug(
- "User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
- CurrentUser!.Id,
- target.User.Id
- );
- return NoContent();
- }
-
- _logger.Information(
- "Creating report on {TargetId} (member {TargetMemberId}) by {ReporterId}",
- target.User.Id,
- target.Id,
- CurrentUser!.Id
- );
-
- string snapshot = JsonConvert.SerializeObject(memberRenderer.RenderMember(target));
-
- var report = new Report
- {
- Id = snowflakeGenerator.GenerateSnowflake(),
- ReporterId = CurrentUser.Id,
- TargetUserId = target.User.Id,
- TargetMemberId = target.Id,
- Reason = req.Reason,
- Context = req.Context,
- TargetType = ReportTargetType.Member,
- TargetSnapshot = snapshot,
- };
-
- db.Reports.Add(report);
- await db.SaveChangesAsync();
- return NoContent();
- }
-
- [HttpGet("reports")]
- [Authorize("user.moderation")]
- [Limit(RequireModerator = true)]
- public async Task GetReportsAsync(
- [FromQuery] int? limit = null,
- [FromQuery] Snowflake? before = null,
- [FromQuery] Snowflake? after = null,
- [FromQuery(Name = "by-reporter")] Snowflake? byReporter = null,
- [FromQuery(Name = "by-target")] Snowflake? byTarget = null,
- [FromQuery(Name = "include-closed")] bool includeClosed = false
- )
- {
- limit = limit switch
- {
- > 100 => 100,
- < 0 => 100,
- null => 100,
- _ => limit,
- };
-
- IQueryable query = db
- .Reports.Include(r => r.Reporter)
- .Include(r => r.TargetUser)
- .Include(r => r.TargetMember);
-
- if (byTarget != null && await db.Users.AnyAsync(u => u.Id == byTarget.Value))
- query = query.Where(r => r.TargetUserId == byTarget.Value);
-
- if (byReporter != null && await db.Users.AnyAsync(u => u.Id == byReporter.Value))
- query = query.Where(r => r.ReporterId == byReporter.Value);
-
- if (before != null)
- query = query.Where(r => r.Id < before.Value).OrderByDescending(r => r.Id);
- else if (after != null)
- query = query.Where(r => r.Id > after.Value).OrderBy(r => r.Id);
- else
- query = query.OrderByDescending(r => r.Id);
-
- if (!includeClosed)
- query = query.Where(r => r.Status == ReportStatus.Open);
-
- List reports = await query.Take(limit!.Value).ToListAsync();
-
- return Ok(reports.Select(moderationRenderer.RenderReport));
- }
-
- [HttpGet("reports/{id}")]
- [Authorize("user.moderation")]
- [Limit(RequireModerator = true)]
- public async Task GetReportAsync(Snowflake id, CancellationToken ct = default)
- {
- Report? report = await db
- .Reports.Include(r => r.Reporter)
- .Include(r => r.TargetUser)
- .Include(r => r.TargetMember)
- .Include(r => r.AuditLogEntry)
- .FirstOrDefaultAsync(r => r.Id == id, ct);
- if (report == null)
- throw new ApiError.NotFound("No report with that ID found.");
-
- return Ok(
- new ReportDetailResponse(
- Report: moderationRenderer.RenderReport(report),
- User: await userRenderer.RenderUserAsync(
- report.TargetUser,
- renderMembers: false,
- ct: ct
- ),
- Member: report.TargetMember != null
- ? memberRenderer.RenderMember(report.TargetMember)
- : null,
- AuditLogEntry: report.AuditLogEntry != null
- ? moderationRenderer.RenderAuditLogEntry(report.AuditLogEntry)
- : null
- )
- );
- }
-
- [HttpPost("reports/{id}/ignore")]
- [Authorize("user.moderation")]
- [Limit(RequireModerator = true)]
- public async Task IgnoreReportAsync(
- Snowflake id,
- [FromBody] IgnoreReportRequest req
- )
- {
- Report? report = await db.Reports.FindAsync(id);
- if (report == null)
- throw new ApiError.NotFound("No report with that ID found.");
- if (report.Status != ReportStatus.Open)
- throw new ApiError.BadRequest("That report has already been handled.");
-
- AuditLogEntry entry = await moderationService.IgnoreReportAsync(
- CurrentUser!,
- report,
- req.Reason
- );
-
- return Ok(moderationRenderer.RenderAuditLogEntry(entry));
- }
-}
diff --git a/Foxnouns.Backend/Controllers/NotificationsController.cs b/Foxnouns.Backend/Controllers/NotificationsController.cs
deleted file mode 100644
index 873344c..0000000
--- a/Foxnouns.Backend/Controllers/NotificationsController.cs
+++ /dev/null
@@ -1,66 +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 Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using NodaTime;
-
-namespace Foxnouns.Backend.Controllers;
-
-[Route("/api/v2/notifications")]
-public class NotificationsController(
- DatabaseContext db,
- ModerationRendererService moderationRenderer,
- IClock clock
-) : ApiControllerBase
-{
- [HttpGet]
- [Authorize("user.moderation")]
- [Limit(UsableByDeletedUsers = true)]
- public async Task GetNotificationsAsync([FromQuery] bool all = false)
- {
- IQueryable query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
- if (!all)
- query = query.Where(n => n.AcknowledgedAt == null);
-
- List notifications = await query.OrderByDescending(n => n.Id).ToListAsync();
-
- return Ok(notifications.Select(moderationRenderer.RenderNotification));
- }
-
- [HttpPut("{id}/ack")]
- [Authorize("user.moderation")]
- [Limit(UsableByDeletedUsers = true)]
- public async Task AcknowledgeNotificationAsync(Snowflake id)
- {
- Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
- n.TargetId == CurrentUser!.Id && n.Id == id
- );
- if (notification == null)
- throw new ApiError.NotFound("Notification not found.");
-
- if (notification.AcknowledgedAt != null)
- return Ok(moderationRenderer.RenderNotification(notification));
-
- notification.AcknowledgedAt = clock.GetCurrentInstant();
- db.Update(notification);
- await db.SaveChangesAsync();
-
- return Ok(moderationRenderer.RenderNotification(notification));
- }
-}
diff --git a/Foxnouns.Backend/Controllers/SidController.cs b/Foxnouns.Backend/Controllers/SidController.cs
deleted file mode 100644
index 3a8db19..0000000
--- a/Foxnouns.Backend/Controllers/SidController.cs
+++ /dev/null
@@ -1,67 +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 System.Diagnostics.CodeAnalysis;
-using Foxnouns.Backend.Database;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-namespace Foxnouns.Backend.Controllers;
-
-[Route("/sid")]
-[SuppressMessage(
- "Performance",
- "CA1862:Use the \'StringComparison\' method overloads to perform case-insensitive string comparisons",
- Justification = "Not usable with EFCore"
-)]
-[ApiExplorerSettings(IgnoreApi = true)]
-public class SidController(Config config, DatabaseContext db) : ApiControllerBase
-{
- [HttpGet("{**id}")]
- public async Task ResolveSidAsync(string id, CancellationToken ct = default) =>
- id.Length switch
- {
- 5 => await ResolveUserSidAsync(id, ct),
- 6 => await ResolveMemberSidAsync(id, ct),
- _ => Redirect(config.BaseUrl),
- };
-
- private async Task ResolveUserSidAsync(string id, CancellationToken ct = default)
- {
- string? username = await db
- .Users.Where(u => u.Sid == id.ToLowerInvariant() && !u.Deleted)
- .Select(u => u.Username)
- .FirstOrDefaultAsync(ct);
- if (username == null)
- return Redirect(config.BaseUrl);
-
- return Redirect($"{config.BaseUrl}/@{username}");
- }
-
- private async Task ResolveMemberSidAsync(
- string id,
- CancellationToken ct = default
- )
- {
- var member = await db
- .Members.Include(m => m.User)
- .Where(m => m.Sid == id.ToLowerInvariant() && !m.User.Deleted)
- .Select(m => new { m.Name, m.User.Username })
- .FirstOrDefaultAsync(ct);
- if (member == null)
- return Redirect(config.BaseUrl);
-
- return Redirect($"{config.BaseUrl}/@{member.Username}/{member.Name}");
- }
-}
diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs
index ed9a48f..26ae497 100644
--- a/Foxnouns.Backend/Controllers/UsersController.cs
+++ b/Foxnouns.Backend/Controllers/UsersController.cs
@@ -1,329 +1,34 @@
-// 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 EntityFramework.Exceptions.Common;
+using System.Diagnostics;
using Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto;
-using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
-using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Storage;
-using NodaTime;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/users")]
-public class UsersController(
- DatabaseContext db,
- ILogger logger,
- UserRendererService userRenderer,
- ISnowflakeGenerator snowflakeGenerator,
- IClock clock,
- ValidationService validationService
-) : ApiControllerBase
+public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase
{
- private readonly ILogger _logger = logger.ForContext();
-
[HttpGet("{userRef}")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- [Limit(UsableByDeletedUsers = true)]
- public async Task GetUserAsync(string userRef, CancellationToken ct = default)
+ public async Task GetUser(string userRef)
{
- User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
- return Ok(
- await userRenderer.RenderUserAsync(
- user,
- CurrentUser,
- CurrentToken,
- renderMembers: true,
- renderAuthMethods: true,
- renderSettings: true,
- ct: ct
- )
- );
+ var user = await db.ResolveUserAsync(userRef);
+ return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
+ }
+
+ [HttpGet("@me")]
+ [Authorize("identify")]
+ public async Task GetMe()
+ {
+ var user = await db.ResolveUserAsync(CurrentUser!.Id);
+ return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
}
[HttpPatch("@me")]
- [Authorize("user.update")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task UpdateUserAsync(
- [FromBody] UpdateUserRequest req,
- CancellationToken ct = default
- )
+ public Task UpdateUser([FromBody] UpdateUserRequest req)
{
- await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync(ct);
- User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
- var errors = new List<(string, ValidationError?)>();
-
- if (req.Username != null && req.Username != user.Username)
- {
- errors.Add(("username", validationService.ValidateUsername(req.Username)));
- user.Username = req.Username;
- }
-
- if (req.HasProperty(nameof(req.DisplayName)))
- {
- errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName)));
- user.DisplayName = req.DisplayName;
- }
-
- if (req.HasProperty(nameof(req.Bio)))
- {
- errors.Add(("bio", validationService.ValidateBio(req.Bio)));
- user.Bio = req.Bio;
- }
-
- if (req.HasProperty(nameof(req.Links)))
- {
- errors.AddRange(validationService.ValidateLinks(req.Links));
- user.Links = req.Links ?? [];
- }
-
- if (req.Names != null)
- {
- errors.AddRange(
- validationService.ValidateFieldEntries(
- req.Names,
- CurrentUser!.CustomPreferences,
- "names"
- )
- );
- user.Names = req.Names.ToList();
- }
-
- if (req.Pronouns != null)
- {
- errors.AddRange(
- validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
- );
- user.Pronouns = req.Pronouns.ToList();
- }
-
- if (req.Fields != null)
- {
- errors.AddRange(
- validationService.ValidateFields(
- req.Fields.ToList(),
- CurrentUser!.CustomPreferences
- )
- );
- user.Fields = req.Fields.ToList();
- }
-
- if (req.Flags != null)
- {
- ValidationError? flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags);
- if (flagError != null)
- errors.Add(("flags", flagError));
- }
-
- if (req.HasProperty(nameof(req.Avatar)))
- errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar)));
-
- if (req.HasProperty(nameof(req.MemberTitle)))
- {
- if (string.IsNullOrEmpty(req.MemberTitle))
- {
- user.MemberTitle = null;
- }
- else
- {
- errors.Add(
- ("member_title", validationService.ValidateDisplayName(req.MemberTitle))
- );
- user.MemberTitle = req.MemberTitle;
- }
- }
-
- if (req.HasProperty(nameof(req.MemberListHidden)))
- user.ListHidden = req.MemberListHidden == true;
-
- if (req.HasProperty(nameof(req.Timezone)))
- {
- if (string.IsNullOrEmpty(req.Timezone))
- {
- user.Timezone = null;
- }
- else
- {
- if (TimeZoneInfo.TryFindSystemTimeZoneById(req.Timezone, out _))
- {
- user.Timezone = req.Timezone;
- }
- else
- {
- errors.Add(
- (
- "timezone",
- ValidationError.GenericValidationError("Invalid timezone", req.Timezone)
- )
- );
- }
- }
- }
-
- ValidationUtils.Validate(errors);
- // This is fired off regardless of whether the transaction is committed
- // (atomic operations are hard when combined with background jobs)
- // so it's in a separate block to the validation above.
- if (req.HasProperty(nameof(req.Avatar)))
- {
- UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
- }
-
- user.LastActive = clock.GetCurrentInstant();
-
- try
- {
- await db.SaveChangesAsync(ct);
- }
- catch (UniqueConstraintException)
- {
- _logger.Debug(
- "Could not update user {Id} due to name conflict ({CurrentName} / {NewName})",
- user.Id,
- user.Username,
- req.Username
- );
- throw new ApiError.BadRequest(
- "That username is already taken.",
- "username",
- req.Username
- );
- }
-
- await tx.CommitAsync(ct);
- return Ok(
- await userRenderer.RenderUserAsync(
- user,
- CurrentUser,
- renderMembers: false,
- renderAuthMethods: false,
- ct: ct
- )
- );
+ throw new NotImplementedException();
}
- [HttpPatch("@me/custom-preferences")]
- [Authorize("user.update")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task UpdateCustomPreferencesAsync(
- [FromBody] List req,
- CancellationToken ct = default
- )
- {
- ValidationUtils.Validate(ValidationUtils.ValidateCustomPreferences(req));
-
- User user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
- var preferences = user
- .CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
- .ToDictionary();
-
- foreach (CustomPreferenceUpdateRequest r in req)
- {
- if (r.Id != null && preferences.ContainsKey(r.Id.Value))
- {
- preferences[r.Id.Value] = new User.CustomPreference
- {
- Favourite = r.Favourite,
- Icon = r.Icon,
- Muted = r.Muted,
- Size = r.Size,
- Tooltip = r.Tooltip,
- LegacyId = preferences[r.Id.Value].LegacyId,
- };
- }
- else
- {
- preferences[snowflakeGenerator.GenerateSnowflake()] = new User.CustomPreference
- {
- Favourite = r.Favourite,
- Icon = r.Icon,
- Muted = r.Muted,
- Size = r.Size,
- Tooltip = r.Tooltip,
- LegacyId = Guid.NewGuid(),
- };
- }
- }
-
- user.CustomPreferences = preferences;
- user.LastActive = clock.GetCurrentInstant();
- await db.SaveChangesAsync(ct);
-
- return Ok(user.CustomPreferences);
- }
-
- [HttpPatch("@me/settings")]
- [Authorize("user.read_hidden", "user.update")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task UpdateUserSettingsAsync(
- [FromBody] UpdateUserSettingsRequest req,
- CancellationToken ct = default
- )
- {
- User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
-
- if (req.HasProperty(nameof(req.DarkMode)))
- user.Settings.DarkMode = req.DarkMode;
- if (req.HasProperty(nameof(req.LastReadNotice)))
- user.Settings.LastReadNotice = req.LastReadNotice;
-
- user.LastActive = clock.GetCurrentInstant();
- db.Update(user);
- await db.SaveChangesAsync(ct);
-
- return Ok(user.Settings);
- }
-
- [HttpPost("@me/reroll-sid")]
- [Authorize("user.update")]
- [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
- public async Task RerollSidAsync()
- {
- Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
- if (CurrentUser!.LastSidReroll > minTimeAgo)
- throw new ApiError.BadRequest("Cannot reroll short ID yet");
-
- // Using ExecuteUpdateAsync here as the new short ID is generated by the database
- await db
- .Users.Where(u => u.Id == CurrentUser.Id)
- .ExecuteUpdateAsync(s =>
- s.SetProperty(u => u.Sid, _ => db.FindFreeUserSid())
- .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
- .SetProperty(u => u.LastActive, clock.GetCurrentInstant())
- );
-
- // Get the user's new sid
- string newSid = await db
- .Users.Where(u => u.Id == CurrentUser.Id)
- .Select(u => u.Sid)
- .FirstAsync();
-
- User user = await db.ResolveUserAsync(CurrentUser.Id);
- return Ok(
- await userRenderer.RenderUserAsync(
- user,
- CurrentUser,
- CurrentToken,
- false,
- overrideSid: newSid
- )
- );
- }
-}
+ public record UpdateUserRequest(string? Username, string? DisplayName);
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Controllers/V1/V1ReadController.cs b/Foxnouns.Backend/Controllers/V1/V1ReadController.cs
deleted file mode 100644
index 327f03e..0000000
--- a/Foxnouns.Backend/Controllers/V1/V1ReadController.cs
+++ /dev/null
@@ -1,120 +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 Foxnouns.Backend.Database;
-using Foxnouns.Backend.Database.Models;
-using Foxnouns.Backend.Dto.V1;
-using Foxnouns.Backend.Middleware;
-using Foxnouns.Backend.Services.V1;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-namespace Foxnouns.Backend.Controllers.V1;
-
-[Route("/api/v1")]
-public class V1ReadController(
- UsersV1Service usersV1Service,
- MembersV1Service membersV1Service,
- DatabaseContext db
-) : ApiControllerBase
-{
- [HttpGet("users/@me")]
- [Authorize("identify")]
- public async Task GetMeAsync(CancellationToken ct = default)
- {
- User user = await usersV1Service.ResolveUserAsync("@me", CurrentToken, ct);
- return Ok(await usersV1Service.RenderCurrentUserAsync(user, ct));
- }
-
- [HttpGet("users/{userRef}")]
- public async Task GetUserAsync(string userRef, CancellationToken ct = default)
- {
- User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
- return Ok(
- await usersV1Service.RenderUserAsync(
- user,
- CurrentToken,
- renderMembers: true,
- renderFlags: true,
- ct: ct
- )
- );
- }
-
- [HttpGet("members/{id}")]
- public async Task GetMemberAsync(string id, CancellationToken ct = default)
- {
- Member member = await membersV1Service.ResolveMemberAsync(id, ct);
- return Ok(
- await membersV1Service.RenderMemberAsync(
- member,
- CurrentToken,
- renderFlags: true,
- ct: ct
- )
- );
- }
-
- [HttpGet("users/{userRef}/members")]
- public async Task GetUserMembersAsync(
- string userRef,
- CancellationToken ct = default
- )
- {
- User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
- List members = await db
- .Members.Where(m => m.UserId == user.Id)
- .OrderBy(m => m.Name)
- .ToListAsync(ct);
-
- List responses = [];
- foreach (Member member in members)
- {
- responses.Add(
- await membersV1Service.RenderMemberAsync(
- member,
- CurrentToken,
- user,
- renderFlags: true,
- ct: ct
- )
- );
- }
-
- return Ok(responses);
- }
-
- [HttpGet("users/{userRef}/members/{memberRef}")]
- public async Task GetUserMemberAsync(
- string userRef,
- string memberRef,
- CancellationToken ct = default
- )
- {
- Member member = await membersV1Service.ResolveMemberAsync(
- userRef,
- memberRef,
- CurrentToken,
- ct
- );
- return Ok(
- await membersV1Service.RenderMemberAsync(
- member,
- CurrentToken,
- renderFlags: true,
- ct: ct
- )
- );
- }
-}
diff --git a/Foxnouns.Backend/Database/BaseModel.cs b/Foxnouns.Backend/Database/BaseModel.cs
index a6ea612..d0dcbca 100644
--- a/Foxnouns.Backend/Database/BaseModel.cs
+++ b/Foxnouns.Backend/Database/BaseModel.cs
@@ -1,20 +1,6 @@
-// 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.Database;
public abstract class BaseModel
{
public required Snowflake Id { get; init; } = SnowflakeGenerator.Instance.GenerateSnowflake();
-}
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs
index 2bbcbc7..f9ec686 100644
--- a/Foxnouns.Backend/Database/DatabaseContext.cs
+++ b/Foxnouns.Backend/Database/DatabaseContext.cs
@@ -1,19 +1,3 @@
-// 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 System.Diagnostics.CodeAnalysis;
-using EntityFramework.Exceptions.PostgreSQL;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -23,57 +7,35 @@ using Npgsql;
namespace Foxnouns.Backend.Database;
-public class DatabaseContext(DbContextOptions options) : DbContext(options)
+public class DatabaseContext : DbContext
{
- private static string GenerateConnectionString(Config.DatabaseConfig config) =>
- new NpgsqlConnectionStringBuilder(config.Url)
+ private readonly NpgsqlDataSource _dataSource;
+
+ public DbSet Users { get; set; }
+ public DbSet Members { get; set; }
+ public DbSet AuthMethods { get; set; }
+ public DbSet FediverseApplications { get; set; }
+ public DbSet Tokens { get; set; }
+ public DbSet Applications { get; set; }
+
+ public DatabaseContext(Config config)
+ {
+ var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{
- Pooling = config.EnablePooling ?? true,
- Timeout = config.Timeout ?? 5,
- MaxPoolSize = config.MaxPoolSize ?? 50,
- MinPoolSize = 0,
- ConnectionPruningInterval = 10,
- ConnectionIdleLifetime = 10,
+ Timeout = config.Database.Timeout ?? 5,
+ MaxPoolSize = config.Database.MaxPoolSize ?? 50,
}.ConnectionString;
- public static NpgsqlDataSource BuildDataSource(Config config)
- {
- var dataSourceBuilder = new NpgsqlDataSourceBuilder(
- GenerateConnectionString(config.Database)
- );
+ var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder.UseNodaTime();
- dataSourceBuilder.UseJsonNet();
- return dataSourceBuilder.Build();
+ _dataSource = dataSourceBuilder.Build();
}
- public static DbContextOptionsBuilder BuildOptions(
- DbContextOptionsBuilder options,
- NpgsqlDataSource dataSource,
- ILoggerFactory? loggerFactory
- ) =>
- options
- .ConfigureWarnings(c => c.Ignore(CoreEventId.SaveChangesFailed))
- .UseNpgsql(dataSource, o => o.UseNodaTime())
- .UseLoggerFactory(loggerFactory)
- .UseSnakeCaseNamingConvention()
- .UseExceptionProcessor();
-
- public DbSet Users { get; init; } = null!;
- public DbSet Members { get; init; } = null!;
- public DbSet AuthMethods { get; init; } = null!;
- public DbSet FediverseApplications { get; init; } = null!;
- public DbSet Tokens { get; init; } = null!;
- public DbSet Applications { get; init; } = null!;
- public DbSet DataExports { get; init; } = null!;
-
- public DbSet PrideFlags { get; init; } = null!;
- public DbSet UserFlags { get; init; } = null!;
- public DbSet MemberFlags { get; init; } = null!;
-
- public DbSet Reports { get; init; } = null!;
- public DbSet AuditLog { get; init; } = null!;
- public DbSet Notifications { get; init; } = null!;
- public DbSet Notices { get; init; } = null!;
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder
+ .ConfigureWarnings(c => c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning))
+ .UseNpgsql(_dataSource, o => o.UseNodaTime())
+ .UseSnakeCaseNamingConvention();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
@@ -84,116 +46,31 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity().HasIndex(u => u.Username).IsUnique();
- 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(d => d.Filename).IsUnique();
- // Two indexes on auth_methods, one for fediverse auth and one for all other types.
- modelBuilder
- .Entity()
- .HasIndex(m => new
- {
- m.AuthType,
- m.RemoteId,
- m.FediverseApplicationId,
- })
- .HasFilter("fediverse_application_id IS NOT NULL")
- .IsUnique();
+ modelBuilder.Entity()
+ .OwnsOne(u => u.Fields, f => f.ToJson())
+ .OwnsOne(u => u.Names, n => n.ToJson())
+ .OwnsOne(u => u.Pronouns, p => p.ToJson());
- modelBuilder
- .Entity()
- .HasIndex(m => new { m.AuthType, m.RemoteId })
- .HasFilter("fediverse_application_id IS NULL")
- .IsUnique();
-
- modelBuilder
- .Entity()
- .HasOne(e => e.Report)
- .WithOne(e => e.AuditLogEntry)
- .OnDelete(DeleteBehavior.SetNull);
-
- modelBuilder.Entity().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
- modelBuilder.Entity().Property(u => u.Fields).HasColumnType("jsonb");
- modelBuilder.Entity().Property(u => u.Names).HasColumnType("jsonb");
- modelBuilder.Entity().Property(u => u.Pronouns).HasColumnType("jsonb");
- modelBuilder.Entity().Property(u => u.CustomPreferences).HasColumnType("jsonb");
- modelBuilder.Entity().Property(u => u.Settings).HasColumnType("jsonb");
-
- modelBuilder
- .Entity()
- .Property(m => m.Sid)
- .HasDefaultValueSql("find_free_member_sid()");
- modelBuilder.Entity().Property(m => m.Fields).HasColumnType("jsonb");
- modelBuilder.Entity().Property(m => m.Names).HasColumnType("jsonb");
- modelBuilder.Entity().Property(m => m.Pronouns).HasColumnType("jsonb");
-
- modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude();
- modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude();
-
- modelBuilder
- .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!)
- .HasName("find_free_user_sid");
-
- modelBuilder
- .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
- .HasName("find_free_member_sid");
-
- // Indexes for legacy IDs for APIv1
- modelBuilder.Entity().HasIndex(u => u.LegacyId).IsUnique();
- modelBuilder.Entity().HasIndex(m => m.LegacyId).IsUnique();
- modelBuilder.Entity().HasIndex(f => f.LegacyId).IsUnique();
-
- // a UUID is not an xid, but this should always be set by the application anyway.
- // we're just setting it here to shut EFCore up because squashing migrations is for nerds
- modelBuilder
- .Entity()
- .Property(u => u.LegacyId)
- .HasDefaultValueSql("gen_random_uuid()");
- modelBuilder
- .Entity()
- .Property(m => m.LegacyId)
- .HasDefaultValueSql("gen_random_uuid()");
- modelBuilder
- .Entity()
- .Property(f => f.LegacyId)
- .HasDefaultValueSql("gen_random_uuid()");
+ modelBuilder.Entity()
+ .OwnsOne(m => m.Fields, f => f.ToJson())
+ .OwnsOne(m => m.Names, n => n.ToJson())
+ .OwnsOne(m => m.Pronouns, p => p.ToJson());
}
-
- ///
- /// Dummy method that calls find_free_user_sid() when used in an EF Core query.
- ///
- public string FindFreeUserSid() => throw new NotSupportedException();
-
- ///
- /// Dummy method that calls find_free_member_sid() when used in an EF Core query.
- ///
- public string FindFreeMemberSid() => throw new NotSupportedException();
}
-[SuppressMessage(
- "ReSharper",
- "UnusedType.Global",
- Justification = "Used by EF Core's migration generator"
-)]
public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory
{
public DatabaseContext CreateDbContext(string[] args)
{
// Read the configuration file
- Config config =
- new ConfigurationBuilder()
- .AddConfiguration()
- .Build()
- // Get the configuration as our config class
- .Get() ?? new Config();
+ var config = new ConfigurationBuilder()
+ .AddConfiguration()
+ .Build()
+ // Get the configuration as our config class
+ .Get() ?? new();
- NpgsqlDataSource dataSource = DatabaseContext.BuildDataSource(config);
-
- DbContextOptions options = DatabaseContext
- .BuildOptions(new DbContextOptionsBuilder(), dataSource, null)
- .Options;
-
- return new DatabaseContext(options);
+ return new DatabaseContext(config);
}
-}
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
index d804dfe..40e333c 100644
--- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
+++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
@@ -1,205 +1,87 @@
-// 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 System.Security.Cryptography;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
using Microsoft.EntityFrameworkCore;
-using NodaTime;
namespace Foxnouns.Backend.Database;
public static class DatabaseQueryExtensions
{
- public static async Task ResolveUserAsync(
- this DatabaseContext context,
- string userRef,
- Token? token,
- CancellationToken ct = default
- )
+ public static async Task ResolveUserAsync(this DatabaseContext context, string userRef)
{
- if (userRef == "@me")
- {
- // Not filtering deleted users, as a suspended user should still be able to look at their own profile.
- return token != null
- ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct)
- : throw new ApiError.Unauthorized(
- "This endpoint requires an authenticated user.",
- ErrorCode.AuthenticationRequired
- );
- }
-
User? user;
- if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
+ if (Snowflake.TryParse(userRef, out var snowflake))
{
- user = await context
- .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
- .FirstOrDefaultAsync(u => u.Id == snowflake, ct);
- if (user != null)
- return user;
+ user = await context.Users
+ .FirstOrDefaultAsync(u => u.Id == snowflake);
+ if (user != null) return user;
}
- user = await context
- .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
- .FirstOrDefaultAsync(u => u.Username == userRef, ct);
- if (user != null)
- return user;
- throw new ApiError.NotFound(
- "No user with that ID or username found.",
- ErrorCode.UserNotFound
- );
+ user = await context.Users
+ .FirstOrDefaultAsync(u => u.Username == userRef);
+ if (user != null) return user;
+ throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound);
}
- public static async Task ResolveUserAsync(
- this DatabaseContext context,
- Snowflake id,
- CancellationToken ct = default
- )
+ public static async Task ResolveUserAsync(this DatabaseContext context, Snowflake id)
{
- User? user = await context
- .Users.Where(u => !u.Deleted)
- .FirstOrDefaultAsync(u => u.Id == id, ct);
- if (user != null)
- return user;
- throw new ApiError.NotFound("No user with that ID found.", ErrorCode.UserNotFound);
+ var user = await context.Users
+ .FirstOrDefaultAsync(u => u.Id == id);
+ if (user != null) return user;
+ throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound);
}
- public static async Task ResolveMemberAsync(
- this DatabaseContext context,
- Snowflake id,
- CancellationToken ct = default
- )
+ public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake id)
{
- Member? member = await context
- .Members.Include(m => m.User)
- .Where(m => !m.User.Deleted)
- .FirstOrDefaultAsync(m => m.Id == id, ct);
- if (member != null)
- return member;
- throw new ApiError.NotFound("No member with that ID found.", ErrorCode.MemberNotFound);
+ var member = await context.Members
+ .Include(m => m.User)
+ .FirstOrDefaultAsync(m => m.Id == id);
+ if (member != null) return member;
+ throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound);
}
- public static async Task ResolveMemberAsync(
- this DatabaseContext context,
- string userRef,
- string memberRef,
- Token? token,
- CancellationToken ct = default
- )
+ public static async Task ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef)
{
- User user = await context.ResolveUserAsync(userRef, token, ct);
- return await context.ResolveMemberAsync(user.Id, memberRef, token, ct);
+ var user = await context.ResolveUserAsync(userRef);
+ return await context.ResolveMemberAsync(user.Id, memberRef);
}
- public static async Task ResolveMemberAsync(
- this DatabaseContext context,
- Snowflake userId,
- string memberRef,
- Token? token = null,
- CancellationToken ct = default
- )
+ public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake userId,
+ string memberRef)
{
Member? member;
- if (Snowflake.TryParse(memberRef, out Snowflake? snowflake))
+ if (Snowflake.TryParse(memberRef, out var snowflake))
{
- member = await context
- .Members.Include(m => m.User)
- .Include(m => m.ProfileFlags)
- // Return members if their user isn't deleted or the user querying it is the member's owner
- .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
- .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
- if (member != null)
- return member;
+ member = await context.Members
+ .Include(m => m.User)
+ .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId);
+ if (member != null) return member;
}
- member = await context
- .Members.Include(m => m.User)
- .Include(m => m.ProfileFlags)
- // Return members if their user isn't deleted or the user querying it is the member's owner
- .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
- .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
- if (member != null)
- return member;
- throw new ApiError.NotFound(
- "No member with that ID or name found.",
- ErrorCode.MemberNotFound
- );
+ member = await context.Members
+ .Include(m => m.User)
+ .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId);
+ if (member != null) return member;
+ throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound);
}
- public static async Task GetFrontendApplicationAsync(
- this DatabaseContext context,
- CancellationToken ct = default
- )
+ public static async Task GetFrontendApplicationAsync(this DatabaseContext context)
{
- Application? app = await context.Applications.FirstOrDefaultAsync(
- a => a.Id == new Snowflake(0),
- ct
- );
- if (app != null)
- return app;
+ var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0));
+ if (app != null) return app;
app = new Application
{
Id = new Snowflake(0),
ClientId = RandomNumberGenerator.GetHexString(32, true),
- ClientSecret = AuthUtils.RandomToken(),
+ ClientSecret = OauthUtils.RandomToken(48),
Name = "pronouns.cc",
Scopes = ["*"],
RedirectUris = [],
};
context.Add(app);
- await context.SaveChangesAsync(ct);
+ await context.SaveChangesAsync();
return app;
}
-
- public static async Task GetToken(
- this DatabaseContext context,
- byte[] rawToken,
- CancellationToken ct = default
- )
- {
- byte[] hash = SHA512.HashData(rawToken);
-
- Token? oauthToken = await context
- .Tokens.Include(t => t.Application)
- .Include(t => t.User)
- .FirstOrDefaultAsync(
- t =>
- t.Hash == hash
- && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant()
- && !t.ManuallyExpired,
- ct
- );
-
- return oauthToken;
- }
-
- public static async Task GetTokenUserId(
- this DatabaseContext context,
- byte[] rawToken,
- CancellationToken ct = default
- )
- {
- byte[] hash = SHA512.HashData(rawToken);
- return await context
- .Tokens.Where(t =>
- t.Hash == hash
- && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant()
- && !t.ManuallyExpired
- )
- .Select(t => t.UserId)
- .FirstOrDefaultAsync(ct);
- }
-}
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Database/DatabaseServiceExtensions.cs b/Foxnouns.Backend/Database/DatabaseServiceExtensions.cs
deleted file mode 100644
index 282d454..0000000
--- a/Foxnouns.Backend/Database/DatabaseServiceExtensions.cs
+++ /dev/null
@@ -1,36 +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 Npgsql;
-using Serilog;
-
-namespace Foxnouns.Backend.Database;
-
-public static class DatabaseServiceExtensions
-{
- public static IServiceCollection AddFoxnounsDatabase(
- this IServiceCollection serviceCollection,
- Config config
- )
- {
- NpgsqlDataSource dataSource = DatabaseContext.BuildDataSource(config);
- ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(dispose: false);
-
- serviceCollection.AddDbContext(options =>
- DatabaseContext.BuildOptions(options, dataSource, loggerFactory)
- );
-
- return serviceCollection;
- }
-}
diff --git a/Foxnouns.Backend/Database/FlagQueryExtensions.cs b/Foxnouns.Backend/Database/FlagQueryExtensions.cs
deleted file mode 100644
index 953c94a..0000000
--- a/Foxnouns.Backend/Database/FlagQueryExtensions.cs
+++ /dev/null
@@ -1,96 +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 Foxnouns.Backend.Database.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace Foxnouns.Backend.Database;
-
-public static class FlagQueryExtensions
-{
- private static async Task> GetFlagsAsync(
- this DatabaseContext db,
- Snowflake userId
- ) => await db.PrideFlags.Where(f => f.UserId == userId).OrderBy(f => f.Id).ToListAsync();
-
- ///
- /// Sets the user's profile flags to the given IDs. Returns a validation error if any of the flag IDs are unknown
- /// or if too many IDs are given. Duplicates are allowed.
- ///
- public static async Task SetUserFlagsAsync(
- this DatabaseContext db,
- Snowflake userId,
- Snowflake[] flagIds
- )
- {
- List currentFlags = await db
- .UserFlags.Where(f => f.UserId == userId)
- .ToListAsync();
- foreach (UserFlag flag in currentFlags)
- db.UserFlags.Remove(flag);
-
- // If there's no new flags to set, we're done
- if (flagIds.Length == 0)
- return null;
- if (flagIds.Length > 100)
- return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length);
-
- List flags = await db.GetFlagsAsync(userId);
- Snowflake[] unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
- if (unknownFlagIds.Length != 0)
- return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds);
-
- IEnumerable userFlags = flagIds.Select(id => new UserFlag
- {
- PrideFlagId = id,
- UserId = userId,
- });
- db.UserFlags.AddRange(userFlags);
-
- return null;
- }
-
- public static async Task SetMemberFlagsAsync(
- this DatabaseContext db,
- Snowflake userId,
- Snowflake memberId,
- Snowflake[] flagIds
- )
- {
- List currentFlags = await db
- .MemberFlags.Where(f => f.MemberId == memberId)
- .ToListAsync();
- foreach (MemberFlag flag in currentFlags)
- db.MemberFlags.Remove(flag);
-
- if (flagIds.Length == 0)
- return null;
- if (flagIds.Length > 100)
- return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length);
-
- List flags = await db.GetFlagsAsync(userId);
- Snowflake[] unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
- if (unknownFlagIds.Length != 0)
- return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds);
-
- IEnumerable memberFlags = flagIds.Select(id => new MemberFlag
- {
- PrideFlagId = id,
- MemberId = memberId,
- });
- db.MemberFlags.AddRange(memberFlags);
-
- return null;
- }
-}
diff --git a/Foxnouns.Backend/Database/ISnowflakeGenerator.cs b/Foxnouns.Backend/Database/ISnowflakeGenerator.cs
index 62b0dda..dd76ab3 100644
--- a/Foxnouns.Backend/Database/ISnowflakeGenerator.cs
+++ b/Foxnouns.Backend/Database/ISnowflakeGenerator.cs
@@ -1,17 +1,3 @@
-// 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;
@@ -19,4 +5,4 @@ namespace Foxnouns.Backend.Database;
public interface ISnowflakeGenerator
{
Snowflake GenerateSnowflake(Instant? time = null);
-}
+}
\ No newline at end of file
diff --git a/Foxnouns.Backend/Database/Migrations/20240527132444_Init.Designer.cs b/Foxnouns.Backend/Database/Migrations/20240527132444_Init.Designer.cs
new file mode 100644
index 0000000..5274ef0
--- /dev/null
+++ b/Foxnouns.Backend/Database/Migrations/20240527132444_Init.Designer.cs
@@ -0,0 +1,412 @@
+//
+using Foxnouns.Backend.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Foxnouns.Backend.Database.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20240527132444_Init")]
+ partial class Init
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AuthType")
+ .HasColumnType("integer")
+ .HasColumnName("auth_type");
+
+ b.Property("FediverseApplicationId")
+ .HasColumnType("bigint")
+ .HasColumnName("fediverse_application_id");
+
+ b.Property("RemoteId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("remote_id");
+
+ b.Property("RemoteUsername")
+ .HasColumnType("text")
+ .HasColumnName("remote_username");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_auth_methods");
+
+ b.HasIndex("FediverseApplicationId")
+ .HasDatabaseName("ix_auth_methods_fediverse_application_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_auth_methods_user_id");
+
+ b.ToTable("auth_methods", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_id");
+
+ b.Property("ClientSecret")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_secret");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("domain");
+
+ b.Property("InstanceType")
+ .HasColumnType("integer")
+ .HasColumnName("instance_type");
+
+ b.HasKey("Id")
+ .HasName("pk_fediverse_applications");
+
+ b.ToTable("fediverse_applications", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("Bio")
+ .HasColumnType("text")
+ .HasColumnName("bio");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("Links")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("links");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Unlisted")
+ .HasColumnType("boolean")
+ .HasColumnName("unlisted");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_members");
+
+ b.HasIndex("UserId", "Name")
+ .IsUnique()
+ .HasDatabaseName("ix_members_user_id_name");
+
+ b.ToTable("members", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("ManuallyExpired")
+ .HasColumnType("boolean")
+ .HasColumnName("manually_expired");
+
+ b.Property("Scopes")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("scopes");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tokens_user_id");
+
+ b.ToTable("tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("Bio")
+ .HasColumnType("text")
+ .HasColumnName("bio");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("Links")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("links");
+
+ b.Property("MemberTitle")
+ .HasColumnType("text")
+ .HasColumnName("member_title");
+
+ b.Property("Role")
+ .HasColumnType("integer")
+ .HasColumnName("role");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("Username")
+ .IsUnique()
+ .HasDatabaseName("ix_users_username");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
+ .WithMany()
+ .HasForeignKey("FediverseApplicationId")
+ .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("AuthMethods")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_auth_methods_users_user_id");
+
+ b.Navigation("FediverseApplication");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("Members")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_members_users_user_id");
+
+ b.OwnsOne("System.Collections.Generic.List", "Fields", b1 =>
+ {
+ b1.Property("MemberId")
+ .HasColumnType("bigint");
+
+ b1.Property("Capacity")
+ .HasColumnType("integer");
+
+ b1.HasKey("MemberId");
+
+ b1.ToTable("members");
+
+ b1.ToJson("fields");
+
+ b1.WithOwner()
+ .HasForeignKey("MemberId")
+ .HasConstraintName("fk_members_members_id");
+ });
+
+ b.OwnsOne("System.Collections.Generic.List", "Names", b1 =>
+ {
+ b1.Property("MemberId")
+ .HasColumnType("bigint");
+
+ b1.Property