Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

417 changed files with 2579 additions and 32375 deletions

View file

@ -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
}
}
}

View file

@ -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/*

View file

@ -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

16
.gitignore vendored
View file

@ -1,19 +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
out/
build/

View file

@ -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

View file

@ -1,34 +0,0 @@
{
"$schema": "https://alirezanet.github.io/Husky.Net/schema.json",
"tasks": [
{
"name": "run-prettier",
"command": "pnpm",
"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"
]
}
]
}

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.intellij.csharpier">
<option name="customPath" value="" />
<option name="runOnSave" value="true" />
</component>
</project>

View file

@ -1,61 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View file

@ -1,10 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders>
<Path>Foxnouns.Frontend</Path>
<Path>migrators/go-exporter</Path>
</attachedFolders>
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>

View file

@ -1,40 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://" />
<option value="http://0.0.0.0" />
<option value="http://127.0.0.1" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://java.sun.com/" />
<option value="http://javafx.com/fxml" />
<option value="http://javafx.com/javafx/" />
<option value="http://json-schema.org/draft" />
<option value="http://localhost" />
<option value="http://maven.apache.org/POM/" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://primefaces.org/ui" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://tiles.apache.org/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.w3.org/" />
<option value="http://xmlns.jcp.org/" />
</list>
</option>
</inspection_tool>
<inspection_tool class="RequiredAttributes" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myAdditionalRequiredHtmlAttributes" value="column" />
</inspection_tool>
</profile>
</component>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<files-pattern value="**/*.{js,ts,jsx,tsx,html,vue,svelte}" />
<option name="fix-on-save" value="true" />
</component>
</project>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
<option name="myRunOnReformat" value="true" />
<option name="myFilesPattern" value="**/*.{js,ts,jsx,tsx,vue,astro,svelte,html}" />
</component>
</project>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

0
.noai
View file

View file

@ -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.

View file

@ -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"]

View file

@ -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.

View file

@ -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 <https://www.gnu.org/licenses/>.
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";
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
// 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;
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<AuthController>();
[HttpPost("urls")]
[ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<IActionResult> 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<AuthMethodResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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<IActionResult> DeleteAuthMethodAsync(Snowflake id)
{
List<AuthMethod> 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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<DiscordAuthController>();
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
RemoteAuthService.RemoteUser? remoteUser =
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
$"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<IActionResult> 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<IActionResult> 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."
);
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<EmailAuthController>();
[HttpPost("register/init")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
{
CheckRequirements();
List<AuthMethod> 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<IActionResult> 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.");
}
/// <summary>
/// Checks whether the context's IP address is rate limited from dispatching emails.
/// </summary>
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;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<FediverseAuthController>();
[HttpGet]
[ProducesResponseType<SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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<AuthResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
$"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<IActionResult> 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<IActionResult> 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
);
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<GoogleAuthController>();
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
RemoteAuthService.RemoteUser? remoteUser =
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"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<IActionResult> 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<IActionResult> 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.");
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<TumblrAuthController>();
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
RemoteAuthService.RemoteUser? remoteUser =
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"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<IActionResult> 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<IActionResult> 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.");
}
}
}

View file

@ -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<IActionResult> 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);
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<DeleteUserController>();
[HttpPost("delete")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<ExportsController>();
[HttpGet]
public async Task<IActionResult> 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<IActionResult> 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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
{
List<PrideFlag> 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<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
public async Task<IActionResult> 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<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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<IActionResult> 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;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<ControllerActionDescriptor>();
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<EndpointDataSource>();
if (endpointDataSource == null)
return null;
IEnumerable<RouteEndpoint> endpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>();
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<HttpMethodAttribute>();
if (
httpMethodAttribute?.HttpMethods.Any(x =>
x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase)
) == false
)
{
continue;
}
return endpoint;
}
return null;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<MembersController>();
[HttpGet]
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(await memberRenderer.RenderUserMembersAsync(user, CurrentToken));
}
[HttpGet("{memberRef}")]
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> 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<MemberResponse>(StatusCodes.Status200OK)]
[Authorize("member.create")]
public async Task<IActionResult> 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<MemberResponse>(statusCode: StatusCodes.Status200OK)]
[Authorize("member.update")]
public async Task<IActionResult> 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<IActionResult> 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<MemberResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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));
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<MetaResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<IActionResult> 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();
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<AuditLogEntry> 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<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
}
[HttpGet("moderators")]
public async Task<IActionResult> 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);
}
}

View file

@ -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<IActionResult> 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<IActionResult> QueryUserAsync(Snowflake id, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(id, ct);
bool showSensitiveData = await moderationService.ShowSensitiveDataAsync(
CurrentUser!,
user,
ct
);
List<AuthMethod> 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<IActionResult> 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));
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<IActionResult> 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));
}
}

View file

@ -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<IActionResult> GetNoticesAsync(CancellationToken ct = default)
{
List<Notice> notices = await db
.Notices.Include(n => n.Author)
.OrderByDescending(n => n.Id)
.ToListAsync(ct);
return Ok(notices.Select(RenderNotice));
}
[HttpPost]
public async Task<IActionResult> 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)
);
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<ReportsController>();
private Snowflake MaxReportId() =>
Snowflake.FromInstant(clock.GetCurrentInstant() - Duration.FromHours(12));
[HttpPost("report-user/{id}")]
[Authorize("user.moderation")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<Report> 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<Report> 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<IActionResult> 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<IActionResult> 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));
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
{
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
if (!all)
query = query.Where(n => n.AcknowledgedAt == null);
List<Notification> 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<IActionResult> 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));
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<IActionResult> 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<IActionResult> 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}");
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<UsersController>();
[HttpGet("{userRef}")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
public async Task<IActionResult> 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<IActionResult> GetMe()
{
var user = await db.ResolveUserAsync(CurrentUser!.Id);
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
}
[HttpPatch("@me")]
[Authorize("user.update")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateUserAsync(
[FromBody] UpdateUserRequest req,
CancellationToken ct = default
)
public Task<IActionResult> 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<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateCustomPreferencesAsync(
[FromBody] List<CustomPreferenceUpdateRequest> 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<UserSettings>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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<UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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);
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetUserMembersAsync(
string userRef,
CancellationToken ct = default
)
{
User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
List<Member> members = await db
.Members.Where(m => m.UserId == user.Id)
.OrderBy(m => m.Name)
.ToListAsync(ct);
List<MemberResponse> 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<IActionResult> 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
)
);
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database;
public abstract class BaseModel
{
public required Snowflake Id { get; init; } = SnowflakeGenerator.Instance.GenerateSnowflake();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<User> Users { get; set; }
public DbSet<Member> Members { get; set; }
public DbSet<AuthMethod> AuthMethods { get; set; }
public DbSet<FediverseApplication> FediverseApplications { get; set; }
public DbSet<Token> Tokens { get; set; }
public DbSet<Application> 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<User> Users { get; init; } = null!;
public DbSet<Member> Members { get; init; } = null!;
public DbSet<AuthMethod> AuthMethods { get; init; } = null!;
public DbSet<FediverseApplication> FediverseApplications { get; init; } = null!;
public DbSet<Token> Tokens { get; init; } = null!;
public DbSet<Application> Applications { get; init; } = null!;
public DbSet<DataExport> DataExports { get; init; } = null!;
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
public DbSet<UserFlag> UserFlags { get; init; } = null!;
public DbSet<MemberFlag> MemberFlags { get; init; } = null!;
public DbSet<Report> Reports { get; init; } = null!;
public DbSet<AuditLogEntry> AuditLog { get; init; } = null!;
public DbSet<Notification> Notifications { get; init; } = null!;
public DbSet<Notice> 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<User>().HasIndex(u => u.Username).IsUnique();
modelBuilder.Entity<User>().HasIndex(u => u.Sid).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
modelBuilder
.Entity<AuthMethod>()
.HasIndex(m => new
{
m.AuthType,
m.RemoteId,
m.FediverseApplicationId,
})
.HasFilter("fediverse_application_id IS NOT NULL")
.IsUnique();
modelBuilder.Entity<User>()
.OwnsOne(u => u.Fields, f => f.ToJson())
.OwnsOne(u => u.Names, n => n.ToJson())
.OwnsOne(u => u.Pronouns, p => p.ToJson());
modelBuilder
.Entity<AuthMethod>()
.HasIndex(m => new { m.AuthType, m.RemoteId })
.HasFilter("fediverse_application_id IS NULL")
.IsUnique();
modelBuilder
.Entity<AuditLogEntry>()
.HasOne(e => e.Report)
.WithOne(e => e.AuditLogEntry)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Pronouns).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.CustomPreferences).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Settings).HasColumnType("jsonb");
modelBuilder
.Entity<Member>()
.Property(m => m.Sid)
.HasDefaultValueSql("find_free_member_sid()");
modelBuilder.Entity<Member>().Property(m => m.Fields).HasColumnType("jsonb");
modelBuilder.Entity<Member>().Property(m => m.Names).HasColumnType("jsonb");
modelBuilder.Entity<Member>().Property(m => m.Pronouns).HasColumnType("jsonb");
modelBuilder.Entity<UserFlag>().Navigation(f => f.PrideFlag).AutoInclude();
modelBuilder.Entity<MemberFlag>().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<User>().HasIndex(u => u.LegacyId).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.LegacyId).IsUnique();
modelBuilder.Entity<PrideFlag>().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<User>()
.Property(u => u.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<Member>()
.Property(m => m.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<PrideFlag>()
.Property(f => f.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder.Entity<Member>()
.OwnsOne(m => m.Fields, f => f.ToJson())
.OwnsOne(m => m.Names, n => n.ToJson())
.OwnsOne(m => m.Pronouns, p => p.ToJson());
}
/// <summary>
/// Dummy method that calls <c>find_free_user_sid()</c> when used in an EF Core query.
/// </summary>
public string FindFreeUserSid() => throw new NotSupportedException();
/// <summary>
/// Dummy method that calls <c>find_free_member_sid()</c> when used in an EF Core query.
/// </summary>
public string FindFreeMemberSid() => throw new NotSupportedException();
}
[SuppressMessage(
"ReSharper",
"UnusedType.Global",
Justification = "Used by EF Core's migration generator"
)]
public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<DatabaseContext>
{
public DatabaseContext CreateDbContext(string[] args)
{
// Read the configuration file
Config config =
new ConfigurationBuilder()
.AddConfiguration()
.Build()
// Get the configuration as our config class
.Get<Config>() ?? new Config();
var config = new ConfigurationBuilder()
.AddConfiguration()
.Build()
// Get the configuration as our config class
.Get<Config>() ?? new();
NpgsqlDataSource dataSource = DatabaseContext.BuildDataSource(config);
DbContextOptions options = DatabaseContext
.BuildOptions(new DbContextOptionsBuilder(), dataSource, null)
.Options;
return new DatabaseContext(options);
return new DatabaseContext(config);
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<User> ResolveUserAsync(
this DatabaseContext context,
string userRef,
Token? token,
CancellationToken ct = default
)
public static async Task<User> 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<User> ResolveUserAsync(
this DatabaseContext context,
Snowflake id,
CancellationToken ct = default
)
public static async Task<User> 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<Member> ResolveMemberAsync(
this DatabaseContext context,
Snowflake id,
CancellationToken ct = default
)
public static async Task<Member> 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<Member> ResolveMemberAsync(
this DatabaseContext context,
string userRef,
string memberRef,
Token? token,
CancellationToken ct = default
)
public static async Task<Member> 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<Member> ResolveMemberAsync(
this DatabaseContext context,
Snowflake userId,
string memberRef,
Token? token = null,
CancellationToken ct = default
)
public static async Task<Member> 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<Application> GetFrontendApplicationAsync(
this DatabaseContext context,
CancellationToken ct = default
)
public static async Task<Application> 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<Token?> 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<Snowflake?> 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);
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<DatabaseContext>(options =>
DatabaseContext.BuildOptions(options, dataSource, loggerFactory)
);
return serviceCollection;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Database;
public static class FlagQueryExtensions
{
private static async Task<List<PrideFlag>> GetFlagsAsync(
this DatabaseContext db,
Snowflake userId
) => await db.PrideFlags.Where(f => f.UserId == userId).OrderBy(f => f.Id).ToListAsync();
/// <summary>
/// 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.
/// </summary>
public static async Task<ValidationError?> SetUserFlagsAsync(
this DatabaseContext db,
Snowflake userId,
Snowflake[] flagIds
)
{
List<UserFlag> 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<PrideFlag> 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<UserFlag> userFlags = flagIds.Select(id => new UserFlag
{
PrideFlagId = id,
UserId = userId,
});
db.UserFlags.AddRange(userFlags);
return null;
}
public static async Task<ValidationError?> SetMemberFlagsAsync(
this DatabaseContext db,
Snowflake userId,
Snowflake memberId,
Snowflake[] flagIds
)
{
List<MemberFlag> 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<PrideFlag> 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<MemberFlag> memberFlags = flagIds.Select(id => new MemberFlag
{
PrideFlagId = id,
MemberId = memberId,
});
db.MemberFlags.AddRange(memberFlags);
return null;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
using NodaTime;
namespace Foxnouns.Backend.Database;
@ -19,4 +5,4 @@ namespace Foxnouns.Backend.Database;
public interface ISnowflakeGenerator
{
Snowflake GenerateSnowflake(Instant? time = null);
}
}

View file

@ -0,0 +1,412 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("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<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
@ -7,8 +6,6 @@ using NodaTime;
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240527132444_Init")]
public partial class Init : Migration
{
/// <inheritdoc />
@ -22,10 +19,12 @@ namespace Foxnouns.Backend.Database.Migrations
domain = table.Column<string>(type: "text", nullable: false),
client_id = table.Column<string>(type: "text", nullable: false),
client_secret = table.Column<string>(type: "text", nullable: false),
instance_type = table.Column<int>(type: "integer", nullable: false),
instance_type = table.Column<int>(type: "integer", nullable: false)
},
constraints: table => table.PrimaryKey("pk_fediverse_applications", x => x.id)
);
constraints: table =>
{
table.PrimaryKey("pk_fediverse_applications", x => x.id);
});
migrationBuilder.CreateTable(
name: "users",
@ -41,10 +40,12 @@ namespace Foxnouns.Backend.Database.Migrations
role = table.Column<int>(type: "integer", nullable: false),
fields = table.Column<string>(type: "jsonb", nullable: false),
names = table.Column<string>(type: "jsonb", nullable: false),
pronouns = table.Column<string>(type: "jsonb", nullable: false),
pronouns = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table => table.PrimaryKey("pk_users", x => x.id)
);
constraints: table =>
{
table.PrimaryKey("pk_users", x => x.id);
});
migrationBuilder.CreateTable(
name: "auth_methods",
@ -55,7 +56,7 @@ namespace Foxnouns.Backend.Database.Migrations
remote_id = table.Column<string>(type: "text", nullable: false),
remote_username = table.Column<string>(type: "text", nullable: true),
user_id = table.Column<long>(type: "bigint", nullable: false),
fediverse_application_id = table.Column<long>(type: "bigint", nullable: true),
fediverse_application_id = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
@ -64,17 +65,14 @@ namespace Foxnouns.Backend.Database.Migrations
name: "fk_auth_methods_fediverse_applications_fediverse_application_id",
column: x => x.fediverse_application_id,
principalTable: "fediverse_applications",
principalColumn: "id"
);
principalColumn: "id");
table.ForeignKey(
name: "fk_auth_methods_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "members",
@ -90,7 +88,7 @@ namespace Foxnouns.Backend.Database.Migrations
user_id = table.Column<long>(type: "bigint", nullable: false),
fields = table.Column<string>(type: "jsonb", nullable: false),
names = table.Column<string>(type: "jsonb", nullable: false),
pronouns = table.Column<string>(type: "jsonb", nullable: false),
pronouns = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table =>
{
@ -100,23 +98,18 @@ namespace Foxnouns.Backend.Database.Migrations
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tokens",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
expires_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
scopes = table.Column<string[]>(type: "text[]", nullable: false),
manually_expired = table.Column<bool>(type: "boolean", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
@ -126,56 +119,53 @@ namespace Foxnouns.Backend.Database.Migrations
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_auth_methods_fediverse_application_id",
table: "auth_methods",
column: "fediverse_application_id"
);
column: "fediverse_application_id");
migrationBuilder.CreateIndex(
name: "ix_auth_methods_user_id",
table: "auth_methods",
column: "user_id"
);
column: "user_id");
// EF Core doesn't support creating indexes on arbitrary expressions, so we have to create it manually.
// Due to historical reasons (I made a mistake while writing the initial migration for the Go version)
// only members have case-insensitive names.
migrationBuilder.Sql(
"CREATE UNIQUE INDEX ix_members_user_id_name ON members (user_id, lower(name))"
);
migrationBuilder.Sql("CREATE UNIQUE INDEX ix_members_user_id_name ON members (user_id, lower(name))");
migrationBuilder.CreateIndex(
name: "ix_tokens_user_id",
table: "tokens",
column: "user_id"
);
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_users_username",
table: "users",
column: "username",
unique: true
);
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "auth_methods");
migrationBuilder.DropTable(
name: "auth_methods");
migrationBuilder.DropTable(name: "members");
migrationBuilder.DropTable(
name: "members");
migrationBuilder.DropTable(name: "tokens");
migrationBuilder.DropTable(
name: "tokens");
migrationBuilder.DropTable(name: "fediverse_applications");
migrationBuilder.DropTable(
name: "fediverse_applications");
migrationBuilder.DropTable(name: "users");
migrationBuilder.DropTable(
name: "users");
}
}
}

View file

@ -0,0 +1,470 @@
// <auto-generated />
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("20240528125310_AddApplications")]
partial class AddApplications
{
/// <inheritdoc />
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.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("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<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,13 +1,10 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240528125310_AddApplications")]
public partial class AddApplications : Migration
{
/// <inheritdoc />
@ -18,16 +15,14 @@ namespace Foxnouns.Backend.Database.Migrations
table: "tokens",
type: "bigint",
nullable: false,
defaultValue: 0L
);
defaultValue: 0L);
migrationBuilder.AddColumn<byte[]>(
name: "hash",
table: "tokens",
type: "bytea",
nullable: false,
defaultValue: Array.Empty<byte>()
);
defaultValue: new byte[0]);
migrationBuilder.CreateTable(
name: "applications",
@ -38,16 +33,17 @@ namespace Foxnouns.Backend.Database.Migrations
client_secret = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
scopes = table.Column<string[]>(type: "text[]", nullable: false),
redirect_uris = table.Column<string[]>(type: "text[]", nullable: false),
redirect_uris = table.Column<string[]>(type: "text[]", nullable: false)
},
constraints: table => table.PrimaryKey("pk_applications", x => x.id)
);
constraints: table =>
{
table.PrimaryKey("pk_applications", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_tokens_application_id",
table: "tokens",
column: "application_id"
);
column: "application_id");
migrationBuilder.AddForeignKey(
name: "fk_tokens_applications_application_id",
@ -55,8 +51,7 @@ namespace Foxnouns.Backend.Database.Migrations
column: "application_id",
principalTable: "applications",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
@ -64,16 +59,22 @@ namespace Foxnouns.Backend.Database.Migrations
{
migrationBuilder.DropForeignKey(
name: "fk_tokens_applications_application_id",
table: "tokens"
);
table: "tokens");
migrationBuilder.DropTable(name: "applications");
migrationBuilder.DropTable(
name: "applications");
migrationBuilder.DropIndex(name: "ix_tokens_application_id", table: "tokens");
migrationBuilder.DropIndex(
name: "ix_tokens_application_id",
table: "tokens");
migrationBuilder.DropColumn(name: "application_id", table: "tokens");
migrationBuilder.DropColumn(
name: "application_id",
table: "tokens");
migrationBuilder.DropColumn(name: "hash", table: "tokens");
migrationBuilder.DropColumn(
name: "hash",
table: "tokens");
}
}
}

View file

@ -0,0 +1,474 @@
// <auto-generated />
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("20240528145744_AddListHidden")]
partial class AddListHidden
{
/// <inheritdoc />
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.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<bool>("ListHidden")
.HasColumnType("boolean")
.HasColumnName("list_hidden");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("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<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,13 +1,10 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240528145744_AddListHidden")]
public partial class AddListHidden : Migration
{
/// <inheritdoc />
@ -18,14 +15,15 @@ namespace Foxnouns.Backend.Database.Migrations
table: "users",
type: "boolean",
nullable: false,
defaultValue: false
);
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "list_hidden", table: "users");
migrationBuilder.DropColumn(
name: "list_hidden",
table: "users");
}
}
}

View file

@ -0,0 +1,478 @@
// <auto-generated />
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("20240604142522_AddPassword")]
partial class AddPassword
{
/// <inheritdoc />
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.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<bool>("ListHidden")
.HasColumnType("boolean")
.HasColumnName("list_hidden");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<string>("Password")
.HasColumnType("text")
.HasColumnName("password");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("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<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,13 +1,10 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240604142522_AddPassword")]
public partial class AddPassword : Migration
{
/// <inheritdoc />
@ -17,14 +14,15 @@ namespace Foxnouns.Backend.Database.Migrations
name: "password",
table: "users",
type: "text",
nullable: true
);
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "password", table: "users");
migrationBuilder.DropColumn(
name: "password",
table: "users");
}
}
}

View file

@ -1,52 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240611225328_AddTemporaryKeyCache")]
public partial class AddTemporaryKeyCache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "temporary_keys",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
key = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(type: "text", nullable: false),
expires = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table => table.PrimaryKey("pk_temporary_keys", x => x.id)
);
migrationBuilder.CreateIndex(
name: "ix_temporary_keys_key",
table: "temporary_keys",
column: "key",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "temporary_keys");
}
}
}

View file

@ -1,32 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240712233806_AddUserLastActive")]
public partial class AddUserLastActive : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Instant>(
name: "last_active",
table: "users",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "last_active", table: "users");
}
}
}

View file

@ -1,50 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240713000719_AddDeleted")]
public partial class AddDeleted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "deleted",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false
);
migrationBuilder.AddColumn<Instant>(
name: "deleted_at",
table: "users",
type: "timestamp with time zone",
nullable: true
);
migrationBuilder.AddColumn<long>(
name: "deleted_by",
table: "users",
type: "bigint",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "deleted", table: "users");
migrationBuilder.DropColumn(name: "deleted_at", table: "users");
migrationBuilder.DropColumn(name: "deleted_by", table: "users");
}
}
}

View file

@ -1,34 +0,0 @@
using System;
using System.Collections.Generic;
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240821210355_AddCustomPreferences")]
public partial class AddCustomPreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Dictionary<Guid, User.CustomPreference>>(
name: "custom_preferences",
table: "users",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "custom_preferences", table: "users");
}
}
}

View file

@ -1,32 +0,0 @@
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240905191709_AddUserSettings")]
public partial class AddUserSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<UserSettings>(
name: "settings",
table: "users",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "settings", table: "users");
}
}
}

View file

@ -1,102 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240926124950_AddSids")]
public partial class AddSids : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "sid",
table: "users",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "last_sid_reroll",
table: "users",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() - '1 hour'::interval"
);
migrationBuilder.AddColumn<string>(
name: "sid",
table: "members",
type: "text",
nullable: true
);
migrationBuilder.CreateIndex(
name: "ix_users_sid",
table: "users",
column: "sid",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_members_sid",
table: "members",
column: "sid",
unique: true
);
migrationBuilder.Sql(
@"create function generate_sid(len int) returns text as $$
select string_agg(substr('abcdefghijklmnopqrstuvwxyz', ceil(random() * 26)::integer, 1), '') from generate_series(1, len)
$$ language sql volatile;
"
);
migrationBuilder.Sql(
@"create function find_free_user_sid() returns text as $$
declare new_sid text;
begin
loop
new_sid := generate_sid(5);
if not exists (select 1 from users where sid = new_sid) then return new_sid; end if;
end loop;
end
$$ language plpgsql volatile;
"
);
migrationBuilder.Sql(
@"create function find_free_member_sid() returns text as $$
declare new_sid text;
begin
loop
new_sid := generate_sid(6);
if not exists (select 1 from members where sid = new_sid) then return new_sid; end if;
end loop;
end
$$ language plpgsql volatile;"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("drop function find_free_member_sid;");
migrationBuilder.Sql("drop function find_free_user_sid;");
migrationBuilder.Sql("drop function generate_sid;");
migrationBuilder.DropIndex(name: "ix_users_sid", table: "users");
migrationBuilder.DropIndex(name: "ix_members_sid", table: "members");
migrationBuilder.DropColumn(name: "sid", table: "users");
migrationBuilder.DropColumn(name: "last_sid_reroll", table: "users");
migrationBuilder.DropColumn(name: "sid", table: "members");
}
}
}

View file

@ -1,64 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240926130208_NonNullableSids")]
public partial class NonNullableSids : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "users",
type: "text",
nullable: false,
defaultValueSql: "find_free_user_sid()",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "members",
type: "text",
nullable: false,
defaultValueSql: "find_free_member_sid()",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "users",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldDefaultValueSql: "find_free_user_sid()"
);
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "members",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldDefaultValueSql: "find_free_member_sid()"
);
}
}
}

View file

@ -1,147 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240926180037_AddFlags")]
public partial class AddFlags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "pride_flags",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false),
hash = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_pride_flags", x => x.id);
table.ForeignKey(
name: "fk_pride_flags_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "member_flags",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
member_id = table.Column<long>(type: "bigint", nullable: false),
pride_flag_id = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_member_flags", x => x.id);
table.ForeignKey(
name: "fk_member_flags_members_member_id",
column: x => x.member_id,
principalTable: "members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "fk_member_flags_pride_flags_pride_flag_id",
column: x => x.pride_flag_id,
principalTable: "pride_flags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "user_flags",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
user_id = table.Column<long>(type: "bigint", nullable: false),
pride_flag_id = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_user_flags", x => x.id);
table.ForeignKey(
name: "fk_user_flags_pride_flags_pride_flag_id",
column: x => x.pride_flag_id,
principalTable: "pride_flags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "fk_user_flags_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "ix_member_flags_member_id",
table: "member_flags",
column: "member_id"
);
migrationBuilder.CreateIndex(
name: "ix_member_flags_pride_flag_id",
table: "member_flags",
column: "pride_flag_id"
);
migrationBuilder.CreateIndex(
name: "ix_pride_flags_user_id",
table: "pride_flags",
column: "user_id"
);
migrationBuilder.CreateIndex(
name: "ix_user_flags_pride_flag_id",
table: "user_flags",
column: "pride_flag_id"
);
migrationBuilder.CreateIndex(
name: "ix_user_flags_user_id",
table: "user_flags",
column: "user_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "member_flags");
migrationBuilder.DropTable(name: "user_flags");
migrationBuilder.DropTable(name: "pride_flags");
}
}
}

View file

@ -1,40 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241006125003_AddFediverseAccessTokens")]
public partial class AddFediverseAccessTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "access_token",
table: "fediverse_applications",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "token_valid_until",
table: "fediverse_applications",
type: "timestamp with time zone",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications");
migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications");
}
}
}

View file

@ -1,40 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241123210306_RemoveFediverseApplicationTokens")]
public partial class RemoveFediverseApplicationTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications");
migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "access_token",
table: "fediverse_applications",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "token_valid_until",
table: "fediverse_applications",
type: "timestamp with time zone",
nullable: true
);
}
}
}

View file

@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241124201309_AddUserTimezone")]
public partial class AddUserTimezone : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "timezone",
table: "users",
type: "text",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "timezone", table: "users");
}
}
}

View file

@ -1,47 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241128202508_AddAuthMethodUniqueIndex")]
public partial class AddAuthMethodUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id" },
unique: true,
filter: "fediverse_application_id IS NULL"
);
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id", "fediverse_application_id" },
unique: true,
filter: "fediverse_application_id IS NOT NULL"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods"
);
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods"
);
}
}
}

View file

@ -1,57 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241202153736_AddDataExports")]
public partial class AddDataExports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "data_exports",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false),
filename = table.Column<string>(type: "text", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_data_exports", x => x.id);
table.ForeignKey(
name: "fk_data_exports_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "ix_data_exports_filename",
table: "data_exports",
column: "filename",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_data_exports_user_id",
table: "data_exports",
column: "user_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "data_exports");
}
}
}

View file

@ -1,41 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241209134148_NullableFlagHash")]
public partial class NullableFlagHash : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "hash",
table: "pride_flags",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "hash",
table: "pride_flags",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
}
}
}

View file

@ -1,53 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241211193653_AddSentEmailCache")]
public partial class AddSentEmailCache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sent_emails",
columns: table => new
{
id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
email = table.Column<string>(type: "text", nullable: false),
sent_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("pk_sent_emails", x => x.id);
}
);
migrationBuilder.CreateIndex(
name: "ix_sent_emails_email_sent_at",
table: "sent_emails",
columns: new[] { "email", "sent_at" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "sent_emails");
}
}
}

View file

@ -1,161 +0,0 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241217010207_AddReports")]
public partial class AddReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase().Annotation("Npgsql:PostgresExtension:hstore", ",,");
migrationBuilder.CreateTable(
name: "notifications",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
target_id = table.Column<long>(type: "bigint", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
message = table.Column<string>(type: "text", nullable: true),
localization_key = table.Column<string>(type: "text", nullable: true),
localization_params = table.Column<Dictionary<string, string>>(
type: "hstore",
nullable: false
),
acknowledged_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("pk_notifications", x => x.id);
table.ForeignKey(
name: "fk_notifications_users_target_id",
column: x => x.target_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "reports",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
reporter_id = table.Column<long>(type: "bigint", nullable: false),
target_user_id = table.Column<long>(type: "bigint", nullable: false),
target_member_id = table.Column<long>(type: "bigint", nullable: true),
status = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<int>(type: "integer", nullable: false),
target_type = table.Column<int>(type: "integer", nullable: false),
target_snapshot = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_reports", x => x.id);
table.ForeignKey(
name: "fk_reports_members_target_member_id",
column: x => x.target_member_id,
principalTable: "members",
principalColumn: "id"
);
table.ForeignKey(
name: "fk_reports_users_reporter_id",
column: x => x.reporter_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "fk_reports_users_target_user_id",
column: x => x.target_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "audit_log",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
moderator_id = table.Column<long>(type: "bigint", nullable: false),
moderator_username = table.Column<string>(type: "text", nullable: false),
target_user_id = table.Column<long>(type: "bigint", nullable: true),
target_username = table.Column<string>(type: "text", nullable: true),
target_member_id = table.Column<long>(type: "bigint", nullable: true),
target_member_name = table.Column<string>(type: "text", nullable: true),
report_id = table.Column<long>(type: "bigint", nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<string>(type: "text", nullable: true),
cleared_fields = table.Column<string[]>(type: "text[]", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_audit_log", x => x.id);
table.ForeignKey(
name: "fk_audit_log_reports_report_id",
column: x => x.report_id,
principalTable: "reports",
principalColumn: "id"
);
}
);
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id"
);
migrationBuilder.CreateIndex(
name: "ix_notifications_target_id",
table: "notifications",
column: "target_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_reporter_id",
table: "reports",
column: "reporter_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_target_member_id",
table: "reports",
column: "target_member_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_target_user_id",
table: "reports",
column: "target_user_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "audit_log");
migrationBuilder.DropTable(name: "notifications");
migrationBuilder.DropTable(name: "reports");
migrationBuilder.AlterDatabase().OldAnnotation("Npgsql:PostgresExtension:hstore", ",,");
}
}
}

View file

@ -1,51 +0,0 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241217195351_AddFediAppForceRefresh")]
public partial class AddFediAppForceRefresh : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Dictionary<string, string>>(
name: "localization_params",
table: "notifications",
type: "hstore",
nullable: false,
oldClrType: typeof(Dictionary<string, string>),
oldType: "hstore",
oldNullable: true
);
migrationBuilder.AddColumn<bool>(
name: "force_refresh",
table: "fediverse_applications",
type: "boolean",
nullable: false,
defaultValue: false
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "force_refresh", table: "fediverse_applications");
migrationBuilder.AlterColumn<Dictionary<string, string>>(
name: "localization_params",
table: "notifications",
type: "hstore",
nullable: true,
oldClrType: typeof(Dictionary<string, string>),
oldType: "hstore"
);
}
}
}

View file

@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241218195457_AddContextToReports")]
public partial class AddContextToReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "context",
table: "reports",
type: "text",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "context", table: "reports");
}
}
}

View file

@ -1,65 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241218201855_MakeAuditLogReportsNullable")]
public partial class MakeAuditLogReportsNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log"
);
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id",
unique: true
);
migrationBuilder.AddForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log",
column: "report_id",
principalTable: "reports",
principalColumn: "id",
onDelete: ReferentialAction.SetNull
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log"
);
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id"
);
migrationBuilder.AddForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log",
column: "report_id",
principalTable: "reports",
principalColumn: "id"
);
}
}
}

View file

@ -1,78 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241225155818_AddLegacyIds")]
public partial class AddLegacyIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "users",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "pride_flags",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "members",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.CreateIndex(
name: "ix_users_legacy_id",
table: "users",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_pride_flags_legacy_id",
table: "pride_flags",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_members_legacy_id",
table: "members",
column: "legacy_id",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "ix_users_legacy_id", table: "users");
migrationBuilder.DropIndex(name: "ix_pride_flags_legacy_id", table: "pride_flags");
migrationBuilder.DropIndex(name: "ix_members_legacy_id", table: "members");
migrationBuilder.DropColumn(name: "legacy_id", table: "users");
migrationBuilder.DropColumn(name: "legacy_id", table: "pride_flags");
migrationBuilder.DropColumn(name: "legacy_id", table: "members");
}
}
}

View file

@ -1,55 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250304155708_RemoveTemporaryKeys")]
public partial class RemoveTemporaryKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "temporary_keys");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "temporary_keys",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
expires = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
key = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(type: "text", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_temporary_keys", x => x.id);
}
);
migrationBuilder.CreateIndex(
name: "ix_temporary_keys_key",
table: "temporary_keys",
column: "key",
unique: true
);
}
}
}

View file

@ -1,915 +0,0 @@
// <auto-generated />
using System.Collections.Generic;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
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("20250329131053_AddNotices")]
partial class AddNotices
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.PrimitiveCollection<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.PrimitiveCollection<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.PrimitiveCollection<string[]>("ClearedFields")
.HasColumnType("text[]")
.HasColumnName("cleared_fields");
b.Property<long>("ModeratorId")
.HasColumnType("bigint")
.HasColumnName("moderator_id");
b.Property<string>("ModeratorUsername")
.IsRequired()
.HasColumnType("text")
.HasColumnName("moderator_username");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<long?>("ReportId")
.HasColumnType("bigint")
.HasColumnName("report_id");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetMemberName")
.HasColumnType("text")
.HasColumnName("target_member_name");
b.Property<long?>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.Property<string>("TargetUsername")
.HasColumnType("text")
.HasColumnName("target_username");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_audit_log");
b.HasIndex("ReportId")
.IsUnique()
.HasDatabaseName("ix_audit_log_report_id");
b.ToTable("audit_log", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("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.HasIndex("AuthType", "RemoteId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id")
.HasFilter("fediverse_application_id IS NULL");
b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
.HasFilter("fediverse_application_id IS NOT NULL");
b.ToTable("auth_methods", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_data_exports");
b.HasIndex("Filename")
.IsUnique()
.HasDatabaseName("ix_data_exports_filename");
b.HasIndex("UserId")
.HasDatabaseName("ix_data_exports_user_id");
b.ToTable("data_exports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<bool>("ForceRefresh")
.HasColumnType("boolean")
.HasColumnName("force_refresh");
b.Property<int>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_member_sid()");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_members_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_members_sid");
b.HasIndex("UserId", "Name")
.IsUnique()
.HasDatabaseName("ix_members_user_id_name");
b.ToTable("members", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("MemberId")
.HasColumnType("bigint")
.HasColumnName("member_id");
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.HasKey("Id")
.HasName("pk_member_flags");
b.HasIndex("MemberId")
.HasDatabaseName("ix_member_flags_member_id");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_member_flags_pride_flag_id");
b.ToTable("member_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<Instant>("EndTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("end_time");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message");
b.Property<Instant>("StartTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("start_time");
b.HasKey("Id")
.HasName("pk_notices");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_notices_author_id");
b.ToTable("notices", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<Instant?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("LocalizationKey")
.HasColumnType("text")
.HasColumnName("localization_key");
b.Property<Dictionary<string, string>>("LocalizationParams")
.IsRequired()
.HasColumnType("hstore")
.HasColumnName("localization_params");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<long>("TargetId")
.HasColumnType("bigint")
.HasColumnName("target_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_notifications");
b.HasIndex("TargetId")
.HasDatabaseName("ix_notifications_target_id");
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_pride_flags");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_pride_flags_legacy_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_pride_flags_user_id");
b.ToTable("pride_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Context")
.HasColumnType("text")
.HasColumnName("context");
b.Property<int>("Reason")
.HasColumnType("integer")
.HasColumnName("reason");
b.Property<long>("ReporterId")
.HasColumnType("bigint")
.HasColumnName("reporter_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetSnapshot")
.HasColumnType("text")
.HasColumnName("target_snapshot");
b.Property<int>("TargetType")
.HasColumnType("integer")
.HasColumnName("target_type");
b.Property<long>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.HasKey("Id")
.HasName("pk_reports");
b.HasIndex("ReporterId")
.HasDatabaseName("ix_reports_reporter_id");
b.HasIndex("TargetMemberId")
.HasDatabaseName("ix_reports_target_member_id");
b.HasIndex("TargetUserId")
.HasDatabaseName("ix_reports_target_user_id");
b.ToTable("reports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.PrimitiveCollection<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<Dictionary<Snowflake, User.CustomPreference>>("CustomPreferences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("custom_preferences");
b.Property<bool>("Deleted")
.HasColumnType("boolean")
.HasColumnName("deleted");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasColumnName("deleted_by");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<Instant>("LastActive")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_active");
b.Property<Instant>("LastSidReroll")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<bool>("ListHidden")
.HasColumnType("boolean")
.HasColumnName("list_hidden");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<string>("Password")
.HasColumnType("text")
.HasColumnName("password");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<UserSettings>("Settings")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_user_sid()");
b.Property<string>("Timezone")
.HasColumnType("text")
.HasColumnName("timezone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_users_sid");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_flags");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_user_flags_pride_flag_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_flags_user_id");
b.ToTable("user_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
.WithOne("AuditLogEntry")
.HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_audit_log_reports_report_id");
b.Navigation("Report");
});
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.DataExport", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("DataExports")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_data_exports_users_user_id");
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.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Member", null)
.WithMany("ProfileFlags")
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_members_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
.WithMany()
.HasForeignKey("PrideFlagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_pride_flags_pride_flag_id");
b.Navigation("PrideFlag");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notices_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
.WithMany()
.HasForeignKey("TargetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifications_users_target_id");
b.Navigation("Target");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.WithMany("Flags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pride_flags_users_user_id");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
.WithMany()
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_reporter_id");
b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
.WithMany()
.HasForeignKey("TargetMemberId")
.HasConstraintName("fk_reports_members_target_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_target_user_id");
b.Navigation("Reporter");
b.Navigation("TargetMember");
b.Navigation("TargetUser");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
.WithMany()
.HasForeignKey("PrideFlagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_pride_flags_pride_flag_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.WithMany("ProfileFlags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_users_user_id");
b.Navigation("PrideFlag");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Navigation("ProfileFlags");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Navigation("AuditLogEntry");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("DataExports");
b.Navigation("Flags");
b.Navigation("Members");
b.Navigation("ProfileFlags");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,56 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
public partial class AddNotices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notices",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
message = table.Column<string>(type: "text", nullable: false),
start_time = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
end_time = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
author_id = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_notices", x => x.id);
table.ForeignKey(
name: "fk_notices_users_author_id",
column: x => x.author_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "ix_notices_author_id",
table: "notices",
column: "author_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "notices");
}
}
}

View file

@ -1,7 +1,5 @@
// <auto-generated />
using System.Collections.Generic;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@ -19,10 +17,9 @@ namespace Foxnouns.Backend.Database.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("ProductVersion", "8.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
@ -46,12 +43,12 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("name");
b.PrimitiveCollection<string[]>("RedirectUris")
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.PrimitiveCollection<string[]>("Scopes")
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
@ -62,63 +59,6 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.PrimitiveCollection<string[]>("ClearedFields")
.HasColumnType("text[]")
.HasColumnName("cleared_fields");
b.Property<long>("ModeratorId")
.HasColumnType("bigint")
.HasColumnName("moderator_id");
b.Property<string>("ModeratorUsername")
.IsRequired()
.HasColumnType("text")
.HasColumnName("moderator_username");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<long?>("ReportId")
.HasColumnType("bigint")
.HasColumnName("report_id");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetMemberName")
.HasColumnType("text")
.HasColumnName("target_member_name");
b.Property<long?>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.Property<string>("TargetUsername")
.HasColumnType("text")
.HasColumnName("target_username");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_audit_log");
b.HasIndex("ReportId")
.IsUnique()
.HasDatabaseName("ix_audit_log_report_id");
b.ToTable("audit_log", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
@ -155,47 +95,9 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id");
b.HasIndex("AuthType", "RemoteId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id")
.HasFilter("fediverse_application_id IS NULL");
b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
.HasFilter("fediverse_application_id IS NOT NULL");
b.ToTable("auth_methods", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_data_exports");
b.HasIndex("Filename")
.IsUnique()
.HasDatabaseName("ix_data_exports_filename");
b.HasIndex("UserId")
.HasDatabaseName("ix_data_exports_user_id");
b.ToTable("data_exports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{
b.Property<long>("Id")
@ -217,10 +119,6 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("domain");
b.Property<bool>("ForceRefresh")
.HasColumnType("boolean")
.HasColumnName("force_refresh");
b.Property<int>("InstanceType")
.HasColumnType("integer")
.HasColumnName("instance_type");
@ -249,19 +147,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
@ -271,23 +157,6 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("name");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_member_sid()");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
@ -299,14 +168,6 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_members_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_members_sid");
b.HasIndex("UserId", "Name")
.IsUnique()
.HasDatabaseName("ix_members_user_id_name");
@ -314,203 +175,6 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("members", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("MemberId")
.HasColumnType("bigint")
.HasColumnName("member_id");
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.HasKey("Id")
.HasName("pk_member_flags");
b.HasIndex("MemberId")
.HasDatabaseName("ix_member_flags_member_id");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_member_flags_pride_flag_id");
b.ToTable("member_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<Instant>("EndTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("end_time");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message");
b.Property<Instant>("StartTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("start_time");
b.HasKey("Id")
.HasName("pk_notices");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_notices_author_id");
b.ToTable("notices", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<Instant?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("LocalizationKey")
.HasColumnType("text")
.HasColumnName("localization_key");
b.Property<Dictionary<string, string>>("LocalizationParams")
.IsRequired()
.HasColumnType("hstore")
.HasColumnName("localization_params");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<long>("TargetId")
.HasColumnType("bigint")
.HasColumnName("target_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_notifications");
b.HasIndex("TargetId")
.HasDatabaseName("ix_notifications_target_id");
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_pride_flags");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_pride_flags_legacy_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_pride_flags_user_id");
b.ToTable("pride_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Context")
.HasColumnType("text")
.HasColumnName("context");
b.Property<int>("Reason")
.HasColumnType("integer")
.HasColumnName("reason");
b.Property<long>("ReporterId")
.HasColumnType("bigint")
.HasColumnName("reporter_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetSnapshot")
.HasColumnType("text")
.HasColumnName("target_snapshot");
b.Property<int>("TargetType")
.HasColumnType("integer")
.HasColumnName("target_type");
b.Property<long>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.HasKey("Id")
.HasName("pk_reports");
b.HasIndex("ReporterId")
.HasDatabaseName("ix_reports_reporter_id");
b.HasIndex("TargetMemberId")
.HasDatabaseName("ix_reports_target_member_id");
b.HasIndex("TargetUserId")
.HasDatabaseName("ix_reports_target_user_id");
b.ToTable("reports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.Property<long>("Id")
@ -534,7 +198,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.PrimitiveCollection<string[]>("Scopes")
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
@ -569,48 +233,11 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("bio");
b.Property<Dictionary<Snowflake, User.CustomPreference>>("CustomPreferences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("custom_preferences");
b.Property<bool>("Deleted")
.HasColumnType("boolean")
.HasColumnName("deleted");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasColumnName("deleted_by");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<Instant>("LastActive")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_active");
b.Property<Instant>("LastSidReroll")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
@ -623,40 +250,14 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<string>("Password")
.HasColumnType("text")
.HasColumnName("password");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<UserSettings>("Settings")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_user_sid()");
b.Property<string>("Timezone")
.HasColumnType("text")
.HasColumnName("timezone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
@ -665,14 +266,6 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_users_sid");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
@ -680,46 +273,6 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_flags");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_user_flags_pride_flag_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_flags_user_id");
b.ToTable("user_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
.WithOne("AuditLogEntry")
.HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_audit_log_reports_report_id");
b.Navigation("Report");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
@ -739,18 +292,6 @@ namespace Foxnouns.Backend.Database.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("DataExports")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_data_exports_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
@ -760,90 +301,75 @@ namespace Foxnouns.Backend.Database.Migrations
.IsRequired()
.HasConstraintName("fk_members_users_user_id");
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Member", null)
.WithMany("ProfileFlags")
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_members_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
.WithMany()
.HasForeignKey("PrideFlagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_pride_flags_pride_flag_id");
b.Navigation("PrideFlag");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notices_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
.WithMany()
.HasForeignKey("TargetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifications_users_target_id");
b.Navigation("Target");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.WithMany("Flags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pride_flags_users_user_id");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
.WithMany()
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_reporter_id");
b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
.WithMany()
.HasForeignKey("TargetMemberId")
.HasConstraintName("fk_reports_members_target_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_target_user_id");
b.Navigation("Reporter");
b.Navigation("TargetMember");
b.Navigation("TargetUser");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
@ -865,46 +391,83 @@ namespace Foxnouns.Backend.Database.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
.WithMany()
.HasForeignKey("PrideFlagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_pride_flags_pride_flag_id");
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.WithMany("ProfileFlags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_users_user_id");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b.Navigation("PrideFlag");
});
b1.HasKey("UserId")
.HasName("pk_users");
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Navigation("ProfileFlags");
});
b1.ToTable("users");
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Navigation("AuditLogEntry");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("DataExports");
b.Navigation("Flags");
b.Navigation("Members");
b.Navigation("ProfileFlags");
});
#pragma warning restore 612, 618
}

View file

@ -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 <https://www.gnu.org/licenses/>.
using System.Security.Cryptography;
using Foxnouns.Backend.Utils;
@ -23,32 +9,22 @@ public class Application : BaseModel
public required string ClientSecret { get; init; }
public required string Name { get; init; }
public required string[] Scopes { get; init; }
public required string[] RedirectUris { get; init; }
public required string[] RedirectUris { get; set; }
public static Application Create(
ISnowflakeGenerator snowflakeGenerator,
string name,
string[] scopes,
string[] redirectUrls
)
public static Application Create(ISnowflakeGenerator snowflakeGenerator, string name, string[] scopes,
string[] redirectUrls)
{
string clientId = RandomNumberGenerator.GetHexString(32, true);
string clientSecret = AuthUtils.RandomToken();
var clientId = RandomNumberGenerator.GetHexString(32, true);
var clientSecret = OauthUtils.RandomToken();
if (scopes.Except(AuthUtils.ApplicationScopes).Any())
if (scopes.Except(OauthUtils.ApplicationScopes).Any())
{
throw new ArgumentException(
"Invalid scopes passed to Application.Create",
nameof(scopes)
);
throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes));
}
if (redirectUrls.Any(s => !AuthUtils.ValidateRedirectUri(s)))
if (redirectUrls.Any(s => !OauthUtils.ValidateRedirectUri(s)))
{
throw new ArgumentException(
"Invalid redirect URLs passed to Application.Create",
nameof(redirectUrls)
);
throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls));
}
return new Application
@ -58,7 +34,7 @@ public class Application : BaseModel
ClientSecret = clientSecret,
Name = name,
Scopes = scopes,
RedirectUris = redirectUrls,
RedirectUris = redirectUrls
};
}
}
}

View file

@ -1,45 +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 <https://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations.Schema;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Database.Models;
public class AuditLogEntry : BaseModel
{
public Snowflake ModeratorId { get; init; }
public string ModeratorUsername { get; init; } = string.Empty;
public Snowflake? TargetUserId { get; init; }
public string? TargetUsername { get; init; }
public Snowflake? TargetMemberId { get; init; }
public string? TargetMemberName { get; init; }
public Snowflake? ReportId { get; init; }
public Report? Report { get; init; }
public AuditLogEntryType Type { get; init; }
public string? Reason { get; init; }
public string[]? ClearedFields { get; init; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum AuditLogEntryType
{
IgnoreReport,
WarnUser,
WarnUserAndClearProfile,
SuspendUser,
QuerySensitiveUserData,
}

View file

@ -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 <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models;
public class AuthMethod : BaseModel
@ -34,4 +20,4 @@ public enum AuthType
Tumblr,
Fediverse,
Email,
}
}

View file

@ -1,26 +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 <https://www.gnu.org/licenses/>.
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class DataExport : BaseModel
{
public Snowflake UserId { get; init; }
public User User { get; init; } = null!;
public required string Filename { get; init; }
public static readonly Duration Expiration = Duration.FromDays(15);
}

View file

@ -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 <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models;
public class FediverseApplication : BaseModel
@ -20,11 +6,10 @@ public class FediverseApplication : BaseModel
public required string ClientId { get; set; }
public required string ClientSecret { get; set; }
public required FediverseInstanceType InstanceType { get; set; }
public bool ForceRefresh { get; set; }
}
public enum FediverseInstanceType
{
MastodonApi,
MisskeyApi,
}
MisskeyApi
}

View file

@ -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 <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models;
public class Field
@ -29,4 +15,4 @@ public class FieldEntry
public class Pronoun : FieldEntry
{
public string? DisplayText { get; set; }
}
}

View file

@ -1,24 +1,8 @@
// 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 <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models;
public class Member : BaseModel
{
public required string Name { get; set; }
public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; }
public string? Bio { get; set; }
public string? Avatar { get; set; }
@ -29,8 +13,6 @@ public class Member : BaseModel
public List<Pronoun> Pronouns { get; set; } = [];
public List<Field> Fields { get; set; } = [];
public List<MemberFlag> ProfileFlags { get; set; } = [];
public Snowflake UserId { get; init; }
public User User { get; init; } = null!;
}
}

View file

@ -1,13 +0,0 @@
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class Notice : BaseModel
{
public required string Message { get; set; }
public required Instant StartTime { get; set; }
public required Instant EndTime { get; set; }
public Snowflake AuthorId { get; init; }
public User Author { get; init; } = null!;
}

View file

@ -1,41 +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 <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class Notification : BaseModel
{
public Snowflake TargetId { get; init; }
public User Target { get; init; } = null!;
public NotificationType Type { get; init; }
public string? Message { get; init; }
public string? LocalizationKey { get; init; }
public Dictionary<string, string> LocalizationParams { get; init; } = [];
public Instant? AcknowledgedAt { get; set; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum NotificationType
{
Notice,
Warning,
Suspension,
}

View file

@ -1,42 +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 <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models;
public class PrideFlag : BaseModel
{
public required Snowflake UserId { get; init; }
public required string LegacyId { get; init; }
// A null hash means the flag hasn't been processed yet.
public string? Hash { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
}
public class UserFlag
{
public long Id { get; init; }
public required Snowflake UserId { get; init; }
public required Snowflake PrideFlagId { get; init; }
public PrideFlag PrideFlag { get; init; } = null!;
}
public class MemberFlag
{
public long Id { get; init; }
public required Snowflake MemberId { get; init; }
public required Snowflake PrideFlagId { get; init; }
public PrideFlag PrideFlag { get; init; } = null!;
}

View file

@ -1,76 +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 <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Database.Models;
public class Report : BaseModel
{
public Snowflake ReporterId { get; init; }
public User Reporter { get; init; } = null!;
public Snowflake TargetUserId { get; init; }
public User TargetUser { get; init; } = null!;
public Snowflake? TargetMemberId { get; init; }
public Member? TargetMember { get; init; }
public ReportStatus Status { get; set; }
public ReportReason Reason { get; init; }
public string? Context { get; init; }
public ReportTargetType TargetType { get; init; }
public string? TargetSnapshot { get; init; }
public AuditLogEntry? AuditLogEntry { get; set; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportTargetType
{
User,
Member,
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportStatus
{
Open,
Closed,
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportReason
{
Totalitarianism,
HateSpeech,
Racism,
Homophobia,
Transphobia,
Queerphobia,
Exclusionism,
Sexism,
Ableism,
ChildPornography,
PedophiliaAdvocacy,
Harassment,
Impersonation,
Doxxing,
EncouragingSelfHarm,
Spam,
Trolling,
Advertisement,
CopyrightViolation,
}

View file

@ -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 <https://www.gnu.org/licenses/>.
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
@ -28,4 +14,4 @@ public class Token : BaseModel
public Snowflake ApplicationId { get; set; }
public Application Application { get; set; } = null!;
}
}

View file

@ -1,81 +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 <https://www.gnu.org/licenses/>.
// ReSharper disable UnusedAutoPropertyAccessor.Global
using System.ComponentModel.DataAnnotations.Schema;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class User : BaseModel
{
public required string Username { get; set; }
public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; }
public string? Bio { get; set; }
public string? MemberTitle { get; set; }
public string? Avatar { get; set; }
public string[] Links { get; set; } = [];
public bool ListHidden { get; set; }
public string? Timezone { get; set; }
public List<FieldEntry> Names { get; set; } = [];
public List<Pronoun> Pronouns { get; set; } = [];
public List<Field> Fields { get; set; } = [];
public Dictionary<Snowflake, CustomPreference> CustomPreferences { get; set; } = [];
public List<PrideFlag> Flags { get; set; } = [];
public List<UserFlag> ProfileFlags { get; set; } = [];
public UserRole Role { get; set; } = UserRole.User;
public string? Password { get; set; } // Password may be null if the user doesn't authenticate with an email address
public List<Member> Members { get; } = [];
public List<AuthMethod> AuthMethods { get; } = [];
public List<DataExport> DataExports { get; } = [];
public UserSettings Settings { get; set; } = new();
public required Instant LastActive { get; set; }
public Instant LastSidReroll { get; set; }
public bool Deleted { get; set; }
public Instant? DeletedAt { get; set; }
public Snowflake? DeletedBy { get; set; }
[NotMapped]
public bool? SelfDelete => Deleted ? DeletedBy != null : null;
public class CustomPreference
{
public required string Icon { get; set; }
public required string Tooltip { get; set; }
public bool Muted { get; set; }
public bool Favourite { get; set; }
// This type is generally serialized directly, so the converter is applied here.
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public PreferenceSize Size { get; set; }
public Guid LegacyId { get; init; } = Guid.NewGuid();
}
public static readonly Duration DeleteAfter = Duration.FromDays(30);
public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180);
}
public enum UserRole
@ -83,17 +26,4 @@ public enum UserRole
User,
Moderator,
Admin,
}
public enum PreferenceSize
{
Large,
Normal,
Small,
}
public class UserSettings
{
public bool? DarkMode { get; set; }
public Snowflake? LastReadNotice { get; set; }
}
}

View file

@ -1,32 +1,12 @@
// 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 <https://www.gnu.org/licenses/>.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Newtonsoft.Json;
using NodaTime;
using JsonSerializer = Newtonsoft.Json.JsonSerializer;
namespace Foxnouns.Backend.Database;
[JsonConverter(typeof(JsonConverter))]
[System.Text.Json.Serialization.JsonConverter(typeof(SystemJsonConverter))]
[TypeConverter(typeof(TypeConverter))]
public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
public readonly struct Snowflake(ulong value)
{
public const long Epoch = 1_640_995_200_000; // 2022-01-01 at 00:00:00 UTC
public readonly ulong Value = value;
@ -57,102 +37,47 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
public short Increment => (short)(Value & 0xFFF);
public static bool operator <(Snowflake arg1, Snowflake arg2) => arg1.Value < arg2.Value;
public static bool operator >(Snowflake arg1, Snowflake arg2) => arg1.Value > arg2.Value;
public static bool operator ==(Snowflake arg1, Snowflake arg2) => arg1.Value == arg2.Value;
public static bool operator !=(Snowflake arg1, Snowflake arg2) => arg1.Value != arg2.Value;
public static implicit operator ulong(Snowflake s) => s.Value;
public static implicit operator long(Snowflake s) => (long)s.Value;
public static implicit operator Snowflake(ulong n) => new(n);
public static implicit operator Snowflake(long n) => new((ulong)n);
public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake)
{
snowflake = null;
if (!ulong.TryParse(input, out ulong res))
return false;
if (!ulong.TryParse(input, out var res)) return false;
snowflake = new Snowflake(res);
return true;
}
public static Snowflake FromInstant(Instant instant) =>
new((ulong)(instant.ToUnixTimeMilliseconds() - Epoch) << 22);
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
public bool Equals(Snowflake other) => Value == other.Value;
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
/// <summary>
/// An Entity Framework ValueConverter for Snowflakes to longs.
/// </summary>
// ReSharper disable once ClassNeverInstantiated.Global
public class ValueConverter() : ValueConverter<Snowflake, long>(x => x, x => x);
public class ValueConverter() : ValueConverter<Snowflake, long>(
convertToProviderExpression: x => x,
convertFromProviderExpression: x => x
);
private class SystemJsonConverter : System.Text.Json.Serialization.JsonConverter<Snowflake>
private class JsonConverter : JsonConverter<Snowflake>
{
public override Snowflake Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
) => ulong.Parse(reader.GetString()!);
public override void Write(
Utf8JsonWriter writer,
Snowflake value,
JsonSerializerOptions options
) => writer.WriteStringValue(value.Value.ToString());
}
private class JsonConverter : JsonConverter<Snowflake?>
{
public override void WriteJson(
JsonWriter writer,
Snowflake? value,
JsonSerializer serializer
)
public override void WriteJson(JsonWriter writer, Snowflake value, JsonSerializer serializer)
{
if (value != null)
writer.WriteValue(value.Value.ToString());
else
writer.WriteNull();
writer.WriteValue(value.Value.ToString());
}
public override Snowflake? ReadJson(
JsonReader reader,
Type objectType,
Snowflake? existingValue,
public override Snowflake ReadJson(JsonReader reader, Type objectType, Snowflake existingValue,
bool hasExistingValue,
JsonSerializer serializer
) =>
reader.TokenType is not (JsonToken.None or JsonToken.Null)
? ulong.Parse((string)reader.Value!)
: null;
JsonSerializer serializer)
{
return ulong.Parse((string)reader.Value!);
}
}
private class TypeConverter : System.ComponentModel.TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) =>
sourceType == typeof(string);
public override bool CanConvertTo(
ITypeDescriptorContext? context,
[NotNullWhen(true)] Type? destinationType
) => destinationType == typeof(Snowflake);
public override object? ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
) => TryParse((string)value, out Snowflake? snowflake) ? snowflake : null;
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
using NodaTime;
namespace Foxnouns.Backend.Database;
@ -42,21 +28,18 @@ public class SnowflakeGenerator : ISnowflakeGenerator
public Snowflake GenerateSnowflake(Instant? time = null)
{
time ??= SystemClock.Instance.GetCurrentInstant();
long increment = Interlocked.Increment(ref _increment);
int threadId = Environment.CurrentManagedThreadId % 32;
long timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch;
var increment = Interlocked.Increment(ref _increment);
var threadId = Environment.CurrentManagedThreadId % 32;
var timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch;
return (timestamp << 22)
| (uint)(_processId << 17)
| (uint)(threadId << 12)
| (increment % 4096);
return (timestamp << 22) | (uint)(_processId << 17) | (uint)(threadId << 12) | (increment % 4096);
}
}
public static class SnowflakeGeneratorServiceExtensions
{
public static IServiceCollection AddSnowflakeGenerator(
this IServiceCollection services,
int? processId = null
) => services.AddSingleton<ISnowflakeGenerator>(new SnowflakeGenerator(processId));
}
public static IServiceCollection AddSnowflakeGenerator(this IServiceCollection services, int? processId = null)
{
return services.AddSingleton<ISnowflakeGenerator>(new SnowflakeGenerator(processId));
}
}

View file

@ -1,30 +0,0 @@
#!/bin/bash
set -e
# Original script by zotan for Iceshrimp.NET
# Source: https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh
if [[ $(uname) == "Darwin" ]]; then
SED="gsed"
else
SED="sed"
fi
import="using Microsoft.EntityFrameworkCore.Infrastructure;"
dbc=" [DbContext(typeof(DatabaseContext))]"
for file in $(find "$(dirname $0)/Migrations" -name '*.Designer.cs'); do
echo "$file"
csfile="${file%.Designer.cs}.cs"
if [[ ! -f $csfile ]]; then
echo "$csfile doesn't exist, exiting"
exit 1
fi
lineno=$($SED -n '/^{/=' "$csfile")
((lineno+=2))
migr=$(grep "\[Migration" "$file")
$SED -i "${lineno}i \\$migr" "$csfile"
$SED -i "${lineno}i \\$dbc" "$csfile"
$SED -i "2i $import" "$csfile"
rm "$file"
done

View file

@ -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 <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
// ReSharper disable ClassNeverInstantiated.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Dto;
public record CallbackResponse(
bool HasAccount,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserResponse? User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
);
public record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr);
public record AuthResponse(UserResponse User, string Token, Instant ExpiresAt);
public record SingleUrlResponse(string Url);
public record AddOauthAccountResponse(
Snowflake Id,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
string RemoteId,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername
);
public record OauthRegisterRequest(string Ticket, string Username);
public record CallbackRequest(string Code, string State);
public record EmailLoginRequest(string Email, string Password);
public record EmailRegisterRequest(string Email);
public record EmailCompleteRegistrationRequest(string Ticket, string Username, string Password);
public record EmailCallbackRequest(string State);
public record EmailChangePasswordRequest(string Current, string New);
public record EmailForgotPasswordRequest(string Email);
public record EmailResetPasswordRequest(string State, string Password);
public record FediverseCallbackRequest(string Instance, string Code, string? State = null);

View file

@ -1,21 +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 <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
using NodaTime;
namespace Foxnouns.Backend.Dto;
public record DataExportResponse(string? Url, Instant? ExpiresAt);

View file

@ -1,31 +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 <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
// ReSharper disable UnusedAutoPropertyAccessor.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Utils;
namespace Foxnouns.Backend.Dto;
public record PrideFlagResponse(Snowflake Id, string? ImageUrl, string Name, string? Description);
public record CreateFlagRequest(string Name, string Image, string? Description);
public class UpdateFlagRequest : PatchRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
}

Some files were not shown because too many files have changed in this diff Show more