Compare commits
69 commits
v2024.12.0
...
main
Author | SHA1 | Date | |
---|---|---|---|
b0431ff962 | |||
b07f4b75c0 | |||
22be49976a | |||
3527acb8ba | |||
978b8e100e | |||
f00f5b400e | |||
f5f0416346 | |||
5d452824cd | |||
bba322bd22 | |||
200e648772 | |||
790b39f730 | |||
7d0df67c06 | |||
dd9d35249c | |||
f99d10ecf0 | |||
7759225428 | |||
cd24196cd1 | |||
7d6d4631b8 | |||
a248536789 | |||
218c756a70 | |||
7ea6c62d67 | |||
64ea25e89e | |||
f1f777ff82 | |||
a72c0f41c3 | |||
6fe816404f | |||
d1faf1ddee | |||
92bf933c10 | |||
c8e4078b35 | |||
0c6e3bf38f | |||
30146556f5 | |||
c47fc41437 | |||
373d97e70a | |||
74800b46ef | |||
32e0c09d06 | |||
6bb01f0bf1 | |||
cacd3a30b7 | |||
a0ba712632 | |||
83b62b4845 | |||
045964ffb7 | |||
8edbc8bf1d | |||
db22e35f0d | |||
9d3d46bf33 | |||
12eddb9949 | |||
8713279d3d | |||
dc9c11ec52 | |||
53006ea313 | |||
49e9eabea0 | |||
5077bd6a0b | |||
3f0edc4374 | |||
7468aa20ab | |||
fe1cf7ce8a | |||
478ba2a406 | |||
78afb8b9c4 | |||
e908e67ca6 | |||
d182b07482 | |||
2281b3e478 | |||
140419a1ca | |||
7791c91960 | |||
5e7df2e074 | |||
e24c4f9b00 | |||
3f8f6d0f23 | |||
661c3eab0f | |||
96725cc304 | |||
8a2ffd7d69 | |||
546e900204 | |||
bd21eeebcf | |||
05913a3b2f | |||
1fb1d8dd14 | |||
ddd96e415a | |||
397ffc2d5e |
211 changed files with 8565 additions and 1507 deletions
|
@ -3,14 +3,14 @@
|
||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"husky": {
|
"husky": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"commands": [
|
"commands": [
|
||||||
"husky"
|
"husky"
|
||||||
],
|
],
|
||||||
"rollForward": false
|
"rollForward": false
|
||||||
},
|
},
|
||||||
"csharpier": {
|
"csharpier": {
|
||||||
"version": "0.29.2",
|
"version": "0.30.6",
|
||||||
"commands": [
|
"commands": [
|
||||||
"dotnet-csharpier"
|
"dotnet-csharpier"
|
||||||
],
|
],
|
||||||
|
|
|
@ -21,3 +21,4 @@
|
||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
static-pages/*
|
||||||
|
|
|
@ -7,7 +7,7 @@ resharper_not_accessed_positional_property_local_highlighting = none
|
||||||
|
|
||||||
# Microsoft .NET properties
|
# Microsoft .NET properties
|
||||||
csharp_new_line_before_members_in_object_initializers = false
|
csharp_new_line_before_members_in_object_initializers = false
|
||||||
csharp_preferred_modifier_order = public, internal, protected, private, file, new, required, abstract, virtual, sealed, static, override, extern, unsafe, volatile, async, readonly:suggestion
|
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 properties
|
||||||
resharper_align_multiline_binary_expressions_chain = false
|
resharper_align_multiline_binary_expressions_chain = false
|
||||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -6,9 +6,14 @@ config.ini
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
proxy-config.json
|
proxy-config.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea/.idea.Foxnouns.NET/.idea/dataSources.xml
|
||||||
|
.idea/.idea.Foxnouns.NET/.idea/sqldialects.xml
|
||||||
|
|
||||||
docker/config.ini
|
docker/config.ini
|
||||||
docker/proxy-config.json
|
docker/proxy-config.json
|
||||||
docker/frontend.env
|
docker/frontend.env
|
||||||
|
|
||||||
Foxnouns.DataMigrator/apps.json
|
Foxnouns.DataMigrator/apps.json
|
||||||
|
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
|
|
@ -4,14 +4,31 @@
|
||||||
{
|
{
|
||||||
"name": "run-prettier",
|
"name": "run-prettier",
|
||||||
"command": "pnpm",
|
"command": "pnpm",
|
||||||
"args": ["format"],
|
"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"
|
"pathMode": "absolute"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "run-csharpier",
|
"name": "run-csharpier",
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
"args": [ "csharpier", "${staged}" ],
|
"args": [
|
||||||
"include": [ "**/*.cs" ]
|
"csharpier",
|
||||||
|
"${staged}"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"**/*.cs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
27
DOCKER.md
27
DOCKER.md
|
@ -1,10 +1,29 @@
|
||||||
# Running with Docker
|
# 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.
|
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.
|
2. Copy `docker/proxy-config.example.json` to `docker/proxy-config.json`, and do the same.
|
||||||
3. Copy `docker/frontend.example.env` to `docker/frontend.env`, and do th esame.
|
3. Run with `docker compose up -f docker-compose.prebuilt.yml`
|
||||||
4. Build with `docker compose build`
|
|
||||||
5. Run with `docker compose up`
|
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,
|
The Caddy server will listen on `localhost:5004` for the frontend and API,
|
||||||
and on `localhost:5005` for the profile URL shortener.
|
and on `localhost:5005` for the profile URL shortener.
|
||||||
|
|
|
@ -26,11 +26,11 @@ public class Config
|
||||||
public string MediaBaseUrl { get; init; } = null!;
|
public string MediaBaseUrl { get; init; } = null!;
|
||||||
|
|
||||||
public string Address => $"http://{Host}:{Port}";
|
public string Address => $"http://{Host}:{Port}";
|
||||||
public string MetricsAddress => $"http://{Host}:{Logging.MetricsPort}";
|
|
||||||
|
|
||||||
public LoggingConfig Logging { get; init; } = new();
|
public LoggingConfig Logging { get; init; } = new();
|
||||||
public DatabaseConfig Database { get; init; } = new();
|
public DatabaseConfig Database { get; init; } = new();
|
||||||
public StorageConfig Storage { get; init; } = new();
|
public StorageConfig Storage { get; init; } = new();
|
||||||
|
public LimitsConfig Limits { get; init; } = new();
|
||||||
public EmailAuthConfig EmailAuth { get; init; } = new();
|
public EmailAuthConfig EmailAuth { get; init; } = new();
|
||||||
public DiscordAuthConfig DiscordAuth { get; init; } = new();
|
public DiscordAuthConfig DiscordAuth { get; init; } = new();
|
||||||
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
||||||
|
@ -54,6 +54,7 @@ public class Config
|
||||||
public bool? EnablePooling { get; init; }
|
public bool? EnablePooling { get; init; }
|
||||||
public int? Timeout { get; init; }
|
public int? Timeout { get; init; }
|
||||||
public int? MaxPoolSize { get; init; }
|
public int? MaxPoolSize { get; init; }
|
||||||
|
public string Redis { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StorageConfig
|
public class StorageConfig
|
||||||
|
@ -93,4 +94,22 @@ public class Config
|
||||||
public string? ClientId { get; init; }
|
public string? ClientId { get; init; }
|
||||||
public string? ClientSecret { 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ public class AuthController(
|
||||||
config.GoogleAuth.Enabled,
|
config.GoogleAuth.Enabled,
|
||||||
config.TumblrAuth.Enabled
|
config.TumblrAuth.Enabled
|
||||||
);
|
);
|
||||||
string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct));
|
string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync());
|
||||||
string? discord = null;
|
string? discord = null;
|
||||||
string? google = null;
|
string? google = null;
|
||||||
string? tumblr = null;
|
string? tumblr = null;
|
||||||
|
|
|
@ -56,7 +56,7 @@ public class EmailAuthController(
|
||||||
if (!req.Email.Contains('@'))
|
if (!req.Email.Contains('@'))
|
||||||
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
|
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
|
||||||
|
|
||||||
string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null, ct);
|
string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null);
|
||||||
|
|
||||||
// If there's already a user with that email address, pretend we sent an email but actually ignore it
|
// If there's already a user with that email address, pretend we sent an email but actually ignore it
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -94,8 +94,7 @@ public class FediverseAuthController(
|
||||||
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
|
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
|
||||||
{
|
{
|
||||||
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
||||||
$"fediverse:{req.Ticket}",
|
$"fediverse:{req.Ticket}"
|
||||||
true
|
|
||||||
);
|
);
|
||||||
if (ticketData == null)
|
if (ticketData == null)
|
||||||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||||
|
|
89
Foxnouns.Backend/Controllers/DeleteUserController.cs
Normal file
89
Foxnouns.Backend/Controllers/DeleteUserController.cs
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,7 +12,6 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Queuing.Interfaces;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Dto;
|
using Foxnouns.Backend.Dto;
|
||||||
|
@ -26,14 +25,10 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
[Route("/api/internal/data-exports")]
|
[Route("/api/internal/data-exports")]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public class ExportsController(
|
public class ExportsController(ILogger logger, Config config, IClock clock, DatabaseContext db)
|
||||||
ILogger logger,
|
: ApiControllerBase
|
||||||
Config config,
|
|
||||||
IClock clock,
|
|
||||||
DatabaseContext db,
|
|
||||||
IQueue queue
|
|
||||||
) : ApiControllerBase
|
|
||||||
{
|
{
|
||||||
private static readonly Duration MinimumTimeBetween = Duration.FromDays(1);
|
private static readonly Duration MinimumTimeBetween = Duration.FromDays(1);
|
||||||
private readonly ILogger _logger = logger.ForContext<ExportsController>();
|
private readonly ILogger _logger = logger.ForContext<ExportsController>();
|
||||||
|
@ -57,7 +52,7 @@ public class ExportsController(
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ExportUrl(Snowflake userId, string filename) =>
|
private string ExportUrl(Snowflake userId, string filename) =>
|
||||||
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip";
|
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}/data-export.zip";
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> QueueDataExportAsync()
|
public async Task<IActionResult> QueueDataExportAsync()
|
||||||
|
@ -79,10 +74,7 @@ public class ExportsController(
|
||||||
throw new ApiError.BadRequest("You can't request a new data export so soon.");
|
throw new ApiError.BadRequest("You can't request a new data export so soon.");
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.QueueInvocableWithPayload<CreateDataExportInvocable, CreateDataExportPayload>(
|
CreateDataExportJob.Enqueue(CurrentUser.Id);
|
||||||
new CreateDataExportPayload(CurrentUser.Id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Queuing.Interfaces;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Dto;
|
using Foxnouns.Backend.Dto;
|
||||||
|
@ -22,6 +21,7 @@ using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using XidNet;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
|
@ -29,12 +29,11 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
public class FlagsController(
|
public class FlagsController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator
|
||||||
IQueue queue
|
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
[Authorize("user.read_flags")]
|
[Authorize("user.read_flags")]
|
||||||
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||||
|
@ -64,6 +63,7 @@ public class FlagsController(
|
||||||
var flag = new PrideFlag
|
var flag = new PrideFlag
|
||||||
{
|
{
|
||||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
LegacyId = Xid.NewXid().ToString(),
|
||||||
UserId = CurrentUser!.Id,
|
UserId = CurrentUser!.Id,
|
||||||
Name = req.Name,
|
Name = req.Name,
|
||||||
Description = req.Description,
|
Description = req.Description,
|
||||||
|
@ -72,10 +72,7 @@ public class FlagsController(
|
||||||
db.Add(flag);
|
db.Add(flag);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image));
|
||||||
new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image)
|
|
||||||
);
|
|
||||||
|
|
||||||
return Accepted(userRenderer.RenderPrideFlag(flag));
|
return Accepted(userRenderer.RenderPrideFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,8 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
||||||
{
|
{
|
||||||
if (template.StartsWith("api/v2"))
|
if (template.StartsWith("api/v2"))
|
||||||
template = template["api/v2".Length..];
|
template = template["api/v2".Length..];
|
||||||
|
else if (template.StartsWith("api/v1"))
|
||||||
|
template = template["api/v1".Length..];
|
||||||
template = PathVarRegex()
|
template = PathVarRegex()
|
||||||
.Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}`
|
.Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}`
|
||||||
.Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}`
|
.Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}`
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Queuing.Interfaces;
|
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
@ -26,6 +25,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using XidNet;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
|
@ -36,15 +36,16 @@ public class MembersController(
|
||||||
MemberRendererService memberRenderer,
|
MemberRendererService memberRenderer,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
ObjectStorageService objectStorageService,
|
ObjectStorageService objectStorageService,
|
||||||
IQueue queue,
|
IClock clock,
|
||||||
IClock clock
|
ValidationService validationService,
|
||||||
|
Config config
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
@ -53,7 +54,7 @@ public class MembersController(
|
||||||
|
|
||||||
[HttpGet("{memberRef}")]
|
[HttpGet("{memberRef}")]
|
||||||
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetMemberAsync(
|
public async Task<IActionResult> GetMemberAsync(
|
||||||
string userRef,
|
string userRef,
|
||||||
string memberRef,
|
string memberRef,
|
||||||
|
@ -64,8 +65,6 @@ public class MembersController(
|
||||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
public const int MaxMemberCount = 500;
|
|
||||||
|
|
||||||
[HttpPost("/api/v2/users/@me/members")]
|
[HttpPost("/api/v2/users/@me/members")]
|
||||||
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
[Authorize("member.create")]
|
[Authorize("member.create")]
|
||||||
|
@ -76,31 +75,32 @@ public class MembersController(
|
||||||
{
|
{
|
||||||
ValidationUtils.Validate(
|
ValidationUtils.Validate(
|
||||||
[
|
[
|
||||||
("name", ValidationUtils.ValidateMemberName(req.Name)),
|
("name", validationService.ValidateMemberName(req.Name)),
|
||||||
("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)),
|
("display_name", validationService.ValidateDisplayName(req.DisplayName)),
|
||||||
("bio", ValidationUtils.ValidateBio(req.Bio)),
|
("bio", validationService.ValidateBio(req.Bio)),
|
||||||
("avatar", ValidationUtils.ValidateAvatar(req.Avatar)),
|
("avatar", validationService.ValidateAvatar(req.Avatar)),
|
||||||
.. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
|
.. validationService.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
|
||||||
.. ValidationUtils.ValidateFieldEntries(
|
.. validationService.ValidateFieldEntries(
|
||||||
req.Names?.ToArray(),
|
req.Names?.ToArray(),
|
||||||
CurrentUser!.CustomPreferences,
|
CurrentUser!.CustomPreferences,
|
||||||
"names"
|
"names"
|
||||||
),
|
),
|
||||||
.. ValidationUtils.ValidatePronouns(
|
.. validationService.ValidatePronouns(
|
||||||
req.Pronouns?.ToArray(),
|
req.Pronouns?.ToArray(),
|
||||||
CurrentUser!.CustomPreferences
|
CurrentUser!.CustomPreferences
|
||||||
),
|
),
|
||||||
.. ValidationUtils.ValidateLinks(req.Links),
|
.. validationService.ValidateLinks(req.Links),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
|
int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
|
||||||
if (memberCount >= MaxMemberCount)
|
if (memberCount >= config.Limits.MaxMemberCount)
|
||||||
throw new ApiError.BadRequest("Maximum number of members reached");
|
throw new ApiError.BadRequest("Maximum number of members reached");
|
||||||
|
|
||||||
var member = new Member
|
var member = new Member
|
||||||
{
|
{
|
||||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
LegacyId = Xid.NewXid().ToString(),
|
||||||
User = CurrentUser!,
|
User = CurrentUser!,
|
||||||
Name = req.Name,
|
Name = req.Name,
|
||||||
DisplayName = req.DisplayName,
|
DisplayName = req.DisplayName,
|
||||||
|
@ -121,6 +121,9 @@ public class MembersController(
|
||||||
CurrentUser!.Id
|
CurrentUser!.Id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CurrentUser.LastActive = clock.GetCurrentInstant();
|
||||||
|
db.Update(CurrentUser);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
@ -137,9 +140,7 @@ public class MembersController(
|
||||||
|
|
||||||
if (req.Avatar != null)
|
if (req.Avatar != null)
|
||||||
{
|
{
|
||||||
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
|
||||||
new AvatarUpdatePayload(member.Id, req.Avatar)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||||
|
@ -161,25 +162,25 @@ public class MembersController(
|
||||||
// These should only take effect when a member's name is changed, not on other changes.
|
// These should only take effect when a member's name is changed, not on other changes.
|
||||||
if (req.Name != null && req.Name != member.Name)
|
if (req.Name != null && req.Name != member.Name)
|
||||||
{
|
{
|
||||||
errors.Add(("name", ValidationUtils.ValidateMemberName(req.Name)));
|
errors.Add(("name", validationService.ValidateMemberName(req.Name)));
|
||||||
member.Name = req.Name;
|
member.Name = req.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.DisplayName)))
|
if (req.HasProperty(nameof(req.DisplayName)))
|
||||||
{
|
{
|
||||||
errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)));
|
errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName)));
|
||||||
member.DisplayName = req.DisplayName;
|
member.DisplayName = req.DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.Bio)))
|
if (req.HasProperty(nameof(req.Bio)))
|
||||||
{
|
{
|
||||||
errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio)));
|
errors.Add(("bio", validationService.ValidateBio(req.Bio)));
|
||||||
member.Bio = req.Bio;
|
member.Bio = req.Bio;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.Links)))
|
if (req.HasProperty(nameof(req.Links)))
|
||||||
{
|
{
|
||||||
errors.AddRange(ValidationUtils.ValidateLinks(req.Links));
|
errors.AddRange(validationService.ValidateLinks(req.Links));
|
||||||
member.Links = req.Links ?? [];
|
member.Links = req.Links ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,7 +190,7 @@ public class MembersController(
|
||||||
if (req.Names != null)
|
if (req.Names != null)
|
||||||
{
|
{
|
||||||
errors.AddRange(
|
errors.AddRange(
|
||||||
ValidationUtils.ValidateFieldEntries(
|
validationService.ValidateFieldEntries(
|
||||||
req.Names,
|
req.Names,
|
||||||
CurrentUser!.CustomPreferences,
|
CurrentUser!.CustomPreferences,
|
||||||
"names"
|
"names"
|
||||||
|
@ -201,7 +202,7 @@ public class MembersController(
|
||||||
if (req.Pronouns != null)
|
if (req.Pronouns != null)
|
||||||
{
|
{
|
||||||
errors.AddRange(
|
errors.AddRange(
|
||||||
ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
|
validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
|
||||||
);
|
);
|
||||||
member.Pronouns = req.Pronouns.ToList();
|
member.Pronouns = req.Pronouns.ToList();
|
||||||
}
|
}
|
||||||
|
@ -209,7 +210,10 @@ public class MembersController(
|
||||||
if (req.Fields != null)
|
if (req.Fields != null)
|
||||||
{
|
{
|
||||||
errors.AddRange(
|
errors.AddRange(
|
||||||
ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences)
|
validationService.ValidateFields(
|
||||||
|
req.Fields.ToList(),
|
||||||
|
CurrentUser!.CustomPreferences
|
||||||
|
)
|
||||||
);
|
);
|
||||||
member.Fields = req.Fields.ToList();
|
member.Fields = req.Fields.ToList();
|
||||||
}
|
}
|
||||||
|
@ -226,7 +230,7 @@ public class MembersController(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.Avatar)))
|
if (req.HasProperty(nameof(req.Avatar)))
|
||||||
errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar)));
|
errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar)));
|
||||||
|
|
||||||
ValidationUtils.Validate(errors);
|
ValidationUtils.Validate(errors);
|
||||||
// This is fired off regardless of whether the transaction is committed
|
// This is fired off regardless of whether the transaction is committed
|
||||||
|
@ -234,11 +238,12 @@ public class MembersController(
|
||||||
// so it's in a separate block to the validation above.
|
// so it's in a separate block to the validation above.
|
||||||
if (req.HasProperty(nameof(req.Avatar)))
|
if (req.HasProperty(nameof(req.Avatar)))
|
||||||
{
|
{
|
||||||
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
|
||||||
new AvatarUpdatePayload(member.Id, req.Avatar)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CurrentUser.LastActive = clock.GetCurrentInstant();
|
||||||
|
db.Update(CurrentUser);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
|
@ -12,20 +12,24 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// 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.Dto;
|
||||||
|
using Foxnouns.Backend.Services.Caching;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
[Route("/api/v2/meta")]
|
[Route("/api/v2/meta")]
|
||||||
public class MetaController : ApiControllerBase
|
public partial class MetaController(Config config, NoticeCacheService noticeCache)
|
||||||
|
: ApiControllerBase
|
||||||
{
|
{
|
||||||
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
|
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)]
|
||||||
public IActionResult GetMeta() =>
|
public async Task<IActionResult> GetMeta(CancellationToken ct = default) =>
|
||||||
Ok(
|
Ok(
|
||||||
new MetaResponse(
|
new MetaResponse(
|
||||||
Repository,
|
Repository,
|
||||||
|
@ -39,16 +43,43 @@ public class MetaController : ApiControllerBase
|
||||||
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
||||||
),
|
),
|
||||||
new LimitsResponse(
|
new LimitsResponse(
|
||||||
MembersController.MaxMemberCount,
|
config.Limits.MaxMemberCount,
|
||||||
ValidationUtils.MaxBioLength,
|
config.Limits.MaxBioLength,
|
||||||
ValidationUtils.MaxCustomPreferences,
|
ValidationUtils.MaxCustomPreferences,
|
||||||
AuthUtils.MaxAuthMethodsPerType,
|
AuthUtils.MaxAuthMethodsPerType,
|
||||||
FlagsController.MaxFlagCount
|
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")]
|
[HttpGet("/api/v2/coffee")]
|
||||||
public IActionResult BrewCoffee() =>
|
public IActionResult BrewCoffee() =>
|
||||||
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
StatusCode(StatusCodes.Status418ImATeapot, "Sorry, I'm a teapot!");
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^[a-z\-_]+$")]
|
||||||
|
private static partial Regex PageRegex();
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,9 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
|
||||||
public async Task<IActionResult> GetAuditLogAsync(
|
public async Task<IActionResult> GetAuditLogAsync(
|
||||||
[FromQuery] AuditLogEntryType? type = null,
|
[FromQuery] AuditLogEntryType? type = null,
|
||||||
[FromQuery] int? limit = null,
|
[FromQuery] int? limit = null,
|
||||||
[FromQuery] Snowflake? before = null
|
[FromQuery] Snowflake? before = null,
|
||||||
|
[FromQuery] Snowflake? after = null,
|
||||||
|
[FromQuery(Name = "by-moderator")] Snowflake? byModerator = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
limit = limit switch
|
limit = limit switch
|
||||||
|
@ -41,15 +43,36 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
|
||||||
_ => limit,
|
_ => limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
IQueryable<AuditLogEntry> query = db.AuditLog.OrderByDescending(e => e.Id);
|
IQueryable<AuditLogEntry> query = db
|
||||||
|
.AuditLog.Include(e => e.Report)
|
||||||
|
.OrderByDescending(e => e.Id);
|
||||||
|
|
||||||
if (before != null)
|
if (before != null)
|
||||||
query = query.Where(e => e.Id < before.Value);
|
query = query.Where(e => e.Id < before.Value);
|
||||||
|
else if (after != null)
|
||||||
|
query = query.Where(e => e.Id > after.Value);
|
||||||
|
|
||||||
if (type != null)
|
if (type != null)
|
||||||
query = query.Where(e => e.Type == type);
|
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();
|
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
|
||||||
|
|
||||||
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
96
Foxnouns.Backend/Controllers/Moderation/LookupController.cs
Normal file
96
Foxnouns.Backend/Controllers/Moderation/LookupController.cs
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
77
Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
Normal file
77
Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Dto;
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -49,6 +50,8 @@ public class ReportsController(
|
||||||
[FromBody] CreateReportRequest req
|
[FromBody] CreateReportRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
|
||||||
|
|
||||||
User target = await db.ResolveUserAsync(id);
|
User target = await db.ResolveUserAsync(id);
|
||||||
|
|
||||||
if (target.Id == CurrentUser!.Id)
|
if (target.Id == CurrentUser!.Id)
|
||||||
|
@ -96,6 +99,7 @@ public class ReportsController(
|
||||||
TargetUserId = target.Id,
|
TargetUserId = target.Id,
|
||||||
TargetMemberId = null,
|
TargetMemberId = null,
|
||||||
Reason = req.Reason,
|
Reason = req.Reason,
|
||||||
|
Context = req.Context,
|
||||||
TargetType = ReportTargetType.User,
|
TargetType = ReportTargetType.User,
|
||||||
TargetSnapshot = snapshot,
|
TargetSnapshot = snapshot,
|
||||||
};
|
};
|
||||||
|
@ -112,6 +116,8 @@ public class ReportsController(
|
||||||
[FromBody] CreateReportRequest req
|
[FromBody] CreateReportRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
|
||||||
|
|
||||||
Member target = await db.ResolveMemberAsync(id);
|
Member target = await db.ResolveMemberAsync(id);
|
||||||
|
|
||||||
if (target.User.Id == CurrentUser!.Id)
|
if (target.User.Id == CurrentUser!.Id)
|
||||||
|
@ -158,6 +164,7 @@ public class ReportsController(
|
||||||
TargetUserId = target.User.Id,
|
TargetUserId = target.User.Id,
|
||||||
TargetMemberId = target.Id,
|
TargetMemberId = target.Id,
|
||||||
Reason = req.Reason,
|
Reason = req.Reason,
|
||||||
|
Context = req.Context,
|
||||||
TargetType = ReportTargetType.Member,
|
TargetType = ReportTargetType.Member,
|
||||||
TargetSnapshot = snapshot,
|
TargetSnapshot = snapshot,
|
||||||
};
|
};
|
||||||
|
@ -213,7 +220,40 @@ public class ReportsController(
|
||||||
return Ok(reports.Select(moderationRenderer.RenderReport));
|
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")]
|
[HttpPost("reports/{id}/ignore")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
[Limit(RequireModerator = true)]
|
[Limit(RequireModerator = true)]
|
||||||
public async Task<IActionResult> IgnoreReportAsync(
|
public async Task<IActionResult> IgnoreReportAsync(
|
||||||
Snowflake id,
|
Snowflake id,
|
||||||
|
|
|
@ -1,3 +1,17 @@
|
||||||
|
// 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;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
|
@ -17,7 +31,7 @@ public class NotificationsController(
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize("user.moderation")]
|
[Authorize("user.moderation")]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
|
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
|
||||||
{
|
{
|
||||||
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
|
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
|
||||||
|
@ -31,7 +45,7 @@ public class NotificationsController(
|
||||||
|
|
||||||
[HttpPut("{id}/ack")]
|
[HttpPut("{id}/ack")]
|
||||||
[Authorize("user.moderation")]
|
[Authorize("user.moderation")]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
|
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
|
||||||
{
|
{
|
||||||
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
|
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Queuing.Interfaces;
|
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
@ -34,20 +33,28 @@ public class UsersController(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
IQueue queue,
|
IClock clock,
|
||||||
IClock clock
|
ValidationService validationService
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
||||||
|
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
return Ok(
|
return Ok(
|
||||||
await userRenderer.RenderUserAsync(user, CurrentUser, CurrentToken, true, true, ct: ct)
|
await userRenderer.RenderUserAsync(
|
||||||
|
user,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentToken,
|
||||||
|
renderMembers: true,
|
||||||
|
renderAuthMethods: true,
|
||||||
|
renderSettings: true,
|
||||||
|
ct: ct
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,32 +72,32 @@ public class UsersController(
|
||||||
|
|
||||||
if (req.Username != null && req.Username != user.Username)
|
if (req.Username != null && req.Username != user.Username)
|
||||||
{
|
{
|
||||||
errors.Add(("username", ValidationUtils.ValidateUsername(req.Username)));
|
errors.Add(("username", validationService.ValidateUsername(req.Username)));
|
||||||
user.Username = req.Username;
|
user.Username = req.Username;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.DisplayName)))
|
if (req.HasProperty(nameof(req.DisplayName)))
|
||||||
{
|
{
|
||||||
errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)));
|
errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName)));
|
||||||
user.DisplayName = req.DisplayName;
|
user.DisplayName = req.DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.Bio)))
|
if (req.HasProperty(nameof(req.Bio)))
|
||||||
{
|
{
|
||||||
errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio)));
|
errors.Add(("bio", validationService.ValidateBio(req.Bio)));
|
||||||
user.Bio = req.Bio;
|
user.Bio = req.Bio;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.Links)))
|
if (req.HasProperty(nameof(req.Links)))
|
||||||
{
|
{
|
||||||
errors.AddRange(ValidationUtils.ValidateLinks(req.Links));
|
errors.AddRange(validationService.ValidateLinks(req.Links));
|
||||||
user.Links = req.Links ?? [];
|
user.Links = req.Links ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.Names != null)
|
if (req.Names != null)
|
||||||
{
|
{
|
||||||
errors.AddRange(
|
errors.AddRange(
|
||||||
ValidationUtils.ValidateFieldEntries(
|
validationService.ValidateFieldEntries(
|
||||||
req.Names,
|
req.Names,
|
||||||
CurrentUser!.CustomPreferences,
|
CurrentUser!.CustomPreferences,
|
||||||
"names"
|
"names"
|
||||||
|
@ -102,7 +109,7 @@ public class UsersController(
|
||||||
if (req.Pronouns != null)
|
if (req.Pronouns != null)
|
||||||
{
|
{
|
||||||
errors.AddRange(
|
errors.AddRange(
|
||||||
ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
|
validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
|
||||||
);
|
);
|
||||||
user.Pronouns = req.Pronouns.ToList();
|
user.Pronouns = req.Pronouns.ToList();
|
||||||
}
|
}
|
||||||
|
@ -110,7 +117,10 @@ public class UsersController(
|
||||||
if (req.Fields != null)
|
if (req.Fields != null)
|
||||||
{
|
{
|
||||||
errors.AddRange(
|
errors.AddRange(
|
||||||
ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences)
|
validationService.ValidateFields(
|
||||||
|
req.Fields.ToList(),
|
||||||
|
CurrentUser!.CustomPreferences
|
||||||
|
)
|
||||||
);
|
);
|
||||||
user.Fields = req.Fields.ToList();
|
user.Fields = req.Fields.ToList();
|
||||||
}
|
}
|
||||||
|
@ -123,7 +133,7 @@ public class UsersController(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.Avatar)))
|
if (req.HasProperty(nameof(req.Avatar)))
|
||||||
errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar)));
|
errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar)));
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.MemberTitle)))
|
if (req.HasProperty(nameof(req.MemberTitle)))
|
||||||
{
|
{
|
||||||
|
@ -133,7 +143,9 @@ public class UsersController(
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
errors.Add(("member_title", ValidationUtils.ValidateDisplayName(req.MemberTitle)));
|
errors.Add(
|
||||||
|
("member_title", validationService.ValidateDisplayName(req.MemberTitle))
|
||||||
|
);
|
||||||
user.MemberTitle = req.MemberTitle;
|
user.MemberTitle = req.MemberTitle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -171,11 +183,11 @@ public class UsersController(
|
||||||
// so it's in a separate block to the validation above.
|
// so it's in a separate block to the validation above.
|
||||||
if (req.HasProperty(nameof(req.Avatar)))
|
if (req.HasProperty(nameof(req.Avatar)))
|
||||||
{
|
{
|
||||||
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
|
||||||
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.LastActive = clock.GetCurrentInstant();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
@ -222,7 +234,7 @@ public class UsersController(
|
||||||
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
||||||
.ToDictionary();
|
.ToDictionary();
|
||||||
|
|
||||||
foreach (CustomPreferenceUpdateRequest? r in req)
|
foreach (CustomPreferenceUpdateRequest r in req)
|
||||||
{
|
{
|
||||||
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
|
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
|
||||||
{
|
{
|
||||||
|
@ -233,6 +245,7 @@ public class UsersController(
|
||||||
Muted = r.Muted,
|
Muted = r.Muted,
|
||||||
Size = r.Size,
|
Size = r.Size,
|
||||||
Tooltip = r.Tooltip,
|
Tooltip = r.Tooltip,
|
||||||
|
LegacyId = preferences[r.Id.Value].LegacyId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
@ -244,25 +257,18 @@ public class UsersController(
|
||||||
Muted = r.Muted,
|
Muted = r.Muted,
|
||||||
Size = r.Size,
|
Size = r.Size,
|
||||||
Tooltip = r.Tooltip,
|
Tooltip = r.Tooltip,
|
||||||
|
LegacyId = Guid.NewGuid(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user.CustomPreferences = preferences;
|
user.CustomPreferences = preferences;
|
||||||
|
user.LastActive = clock.GetCurrentInstant();
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return Ok(user.CustomPreferences);
|
return Ok(user.CustomPreferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("@me/settings")]
|
|
||||||
[Authorize("user.read_hidden")]
|
|
||||||
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> GetUserSettingsAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
|
||||||
return Ok(user.Settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("@me/settings")]
|
[HttpPatch("@me/settings")]
|
||||||
[Authorize("user.read_hidden", "user.update")]
|
[Authorize("user.read_hidden", "user.update")]
|
||||||
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
||||||
|
@ -275,7 +281,10 @@ public class UsersController(
|
||||||
|
|
||||||
if (req.HasProperty(nameof(req.DarkMode)))
|
if (req.HasProperty(nameof(req.DarkMode)))
|
||||||
user.Settings.DarkMode = 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);
|
db.Update(user);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
|
120
Foxnouns.Backend/Controllers/V1/V1ReadController.cs
Normal file
120
Foxnouns.Backend/Controllers/V1/V1ReadController.cs
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,7 +64,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
public DbSet<FediverseApplication> FediverseApplications { get; init; } = null!;
|
public DbSet<FediverseApplication> FediverseApplications { get; init; } = null!;
|
||||||
public DbSet<Token> Tokens { get; init; } = null!;
|
public DbSet<Token> Tokens { get; init; } = null!;
|
||||||
public DbSet<Application> Applications { get; init; } = null!;
|
public DbSet<Application> Applications { get; init; } = null!;
|
||||||
public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!;
|
|
||||||
public DbSet<DataExport> DataExports { get; init; } = null!;
|
public DbSet<DataExport> DataExports { get; init; } = null!;
|
||||||
|
|
||||||
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
|
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
|
||||||
|
@ -74,6 +73,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
public DbSet<Report> Reports { get; init; } = null!;
|
public DbSet<Report> Reports { get; init; } = null!;
|
||||||
public DbSet<AuditLogEntry> AuditLog { get; init; } = null!;
|
public DbSet<AuditLogEntry> AuditLog { get; init; } = null!;
|
||||||
public DbSet<Notification> Notifications { get; init; } = null!;
|
public DbSet<Notification> Notifications { get; init; } = null!;
|
||||||
|
public DbSet<Notice> Notices { get; init; } = null!;
|
||||||
|
|
||||||
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||||
{
|
{
|
||||||
|
@ -87,7 +87,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
modelBuilder.Entity<User>().HasIndex(u => u.Sid).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 => new { m.UserId, m.Name }).IsUnique();
|
||||||
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
|
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
|
||||||
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
|
|
||||||
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
|
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
|
||||||
|
|
||||||
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
|
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
|
||||||
|
@ -108,6 +107,12 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
.HasFilter("fediverse_application_id IS NULL")
|
.HasFilter("fediverse_application_id IS NULL")
|
||||||
.IsUnique();
|
.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.Sid).HasDefaultValueSql("find_free_user_sid()");
|
||||||
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
|
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
|
||||||
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
|
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
|
||||||
|
@ -133,6 +138,26 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
|
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
|
||||||
.HasName("find_free_member_sid");
|
.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()");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
915
Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs
generated
Normal file
915
Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs
generated
Normal file
|
@ -0,0 +1,915 @@
|
||||||
|
// <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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
.HasAnnotation("ProductVersion", "9.0.2")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
|
||||||
|
@ -113,6 +113,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasName("pk_audit_log");
|
.HasName("pk_audit_log");
|
||||||
|
|
||||||
b.HasIndex("ReportId")
|
b.HasIndex("ReportId")
|
||||||
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_audit_log_report_id");
|
.HasDatabaseName("ix_audit_log_report_id");
|
||||||
|
|
||||||
b.ToTable("audit_log", (string)null);
|
b.ToTable("audit_log", (string)null);
|
||||||
|
@ -253,6 +254,13 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("fields");
|
.HasColumnName("fields");
|
||||||
|
|
||||||
|
b.Property<string>("LegacyId")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("legacy_id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("Links")
|
b.PrimitiveCollection<string[]>("Links")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
|
@ -291,6 +299,10 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_members");
|
.HasName("pk_members");
|
||||||
|
|
||||||
|
b.HasIndex("LegacyId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_members_legacy_id");
|
||||||
|
|
||||||
b.HasIndex("Sid")
|
b.HasIndex("Sid")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_members_sid");
|
.HasDatabaseName("ix_members_sid");
|
||||||
|
@ -331,6 +343,38 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("member_flags", (string)null);
|
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 =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
@ -385,6 +429,13 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("hash");
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
b.Property<string>("LegacyId")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("legacy_id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
|
@ -397,6 +448,10 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_pride_flags");
|
.HasName("pk_pride_flags");
|
||||||
|
|
||||||
|
b.HasIndex("LegacyId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_pride_flags_legacy_id");
|
||||||
|
|
||||||
b.HasIndex("UserId")
|
b.HasIndex("UserId")
|
||||||
.HasDatabaseName("ix_pride_flags_user_id");
|
.HasDatabaseName("ix_pride_flags_user_id");
|
||||||
|
|
||||||
|
@ -409,6 +464,10 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("Context")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("context");
|
||||||
|
|
||||||
b.Property<int>("Reason")
|
b.Property<int>("Reason")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("reason");
|
.HasColumnName("reason");
|
||||||
|
@ -452,39 +511,6 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("reports", (string)null);
|
b.ToTable("reports", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b =>
|
|
||||||
{
|
|
||||||
b.Property<long>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("bigint")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
|
||||||
|
|
||||||
b.Property<Instant>("Expires")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("expires");
|
|
||||||
|
|
||||||
b.Property<string>("Key")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("key");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("value");
|
|
||||||
|
|
||||||
b.HasKey("Id")
|
|
||||||
.HasName("pk_temporary_keys");
|
|
||||||
|
|
||||||
b.HasIndex("Key")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_temporary_keys_key");
|
|
||||||
|
|
||||||
b.ToTable("temporary_keys", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
@ -577,6 +603,13 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_sid_reroll");
|
.HasColumnName("last_sid_reroll");
|
||||||
|
|
||||||
|
b.Property<string>("LegacyId")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("legacy_id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("Links")
|
b.PrimitiveCollection<string[]>("Links")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
|
@ -632,6 +665,10 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_users");
|
.HasName("pk_users");
|
||||||
|
|
||||||
|
b.HasIndex("LegacyId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_users_legacy_id");
|
||||||
|
|
||||||
b.HasIndex("Sid")
|
b.HasIndex("Sid")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_users_sid");
|
.HasDatabaseName("ix_users_sid");
|
||||||
|
@ -675,8 +712,9 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
|
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
|
||||||
.WithMany()
|
.WithOne("AuditLogEntry")
|
||||||
.HasForeignKey("ReportId")
|
.HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
.HasConstraintName("fk_audit_log_reports_report_id");
|
.HasConstraintName("fk_audit_log_reports_report_id");
|
||||||
|
|
||||||
b.Navigation("Report");
|
b.Navigation("Report");
|
||||||
|
@ -744,6 +782,18 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.Navigation("PrideFlag");
|
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 =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
|
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
|
||||||
|
@ -839,6 +889,11 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.Navigation("ProfileFlags");
|
b.Navigation("ProfileFlags");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("AuditLogEntry");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("AuthMethods");
|
b.Navigation("AuthMethods");
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
@ -40,4 +41,5 @@ public enum AuditLogEntryType
|
||||||
WarnUser,
|
WarnUser,
|
||||||
WarnUserAndClearProfile,
|
WarnUserAndClearProfile,
|
||||||
SuspendUser,
|
SuspendUser,
|
||||||
|
QuerySensitiveUserData,
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ public class Member : BaseModel
|
||||||
{
|
{
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string Sid { get; set; } = string.Empty;
|
public string Sid { get; set; } = string.Empty;
|
||||||
|
public required string LegacyId { get; init; }
|
||||||
public string? DisplayName { get; set; }
|
public string? DisplayName { get; set; }
|
||||||
public string? Bio { get; set; }
|
public string? Bio { get; set; }
|
||||||
public string? Avatar { get; set; }
|
public string? Avatar { get; set; }
|
||||||
|
|
13
Foxnouns.Backend/Database/Models/Notice.cs
Normal file
13
Foxnouns.Backend/Database/Models/Notice.cs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
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!;
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ namespace Foxnouns.Backend.Database.Models;
|
||||||
public class PrideFlag : BaseModel
|
public class PrideFlag : BaseModel
|
||||||
{
|
{
|
||||||
public required Snowflake UserId { get; init; }
|
public required Snowflake UserId { get; init; }
|
||||||
|
public required string LegacyId { get; init; }
|
||||||
|
|
||||||
// A null hash means the flag hasn't been processed yet.
|
// A null hash means the flag hasn't been processed yet.
|
||||||
public string? Hash { get; set; }
|
public string? Hash { get; set; }
|
||||||
|
|
|
@ -29,9 +29,12 @@ public class Report : BaseModel
|
||||||
|
|
||||||
public ReportStatus Status { get; set; }
|
public ReportStatus Status { get; set; }
|
||||||
public ReportReason Reason { get; init; }
|
public ReportReason Reason { get; init; }
|
||||||
|
public string? Context { get; init; }
|
||||||
|
|
||||||
public ReportTargetType TargetType { get; init; }
|
public ReportTargetType TargetType { get; init; }
|
||||||
public string? TargetSnapshot { get; init; }
|
public string? TargetSnapshot { get; init; }
|
||||||
|
|
||||||
|
public AuditLogEntry? AuditLogEntry { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
|
|
|
@ -25,6 +25,7 @@ public class User : BaseModel
|
||||||
{
|
{
|
||||||
public required string Username { get; set; }
|
public required string Username { get; set; }
|
||||||
public string Sid { get; set; } = string.Empty;
|
public string Sid { get; set; } = string.Empty;
|
||||||
|
public required string LegacyId { get; init; }
|
||||||
public string? DisplayName { get; set; }
|
public string? DisplayName { get; set; }
|
||||||
public string? Bio { get; set; }
|
public string? Bio { get; set; }
|
||||||
public string? MemberTitle { get; set; }
|
public string? MemberTitle { get; set; }
|
||||||
|
@ -69,6 +70,8 @@ public class User : BaseModel
|
||||||
// This type is generally serialized directly, so the converter is applied here.
|
// This type is generally serialized directly, so the converter is applied here.
|
||||||
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
public PreferenceSize Size { get; set; }
|
public PreferenceSize Size { get; set; }
|
||||||
|
|
||||||
|
public Guid LegacyId { get; init; } = Guid.NewGuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static readonly Duration DeleteAfter = Duration.FromDays(30);
|
public static readonly Duration DeleteAfter = Duration.FromDays(30);
|
||||||
|
@ -92,4 +95,5 @@ public enum PreferenceSize
|
||||||
public class UserSettings
|
public class UserSettings
|
||||||
{
|
{
|
||||||
public bool? DarkMode { get; set; }
|
public bool? DarkMode { get; set; }
|
||||||
|
public Snowflake? LastReadNotice { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,24 +113,30 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
|
||||||
) => writer.WriteStringValue(value.Value.ToString());
|
) => writer.WriteStringValue(value.Value.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private class JsonConverter : JsonConverter<Snowflake>
|
private class JsonConverter : JsonConverter<Snowflake?>
|
||||||
{
|
{
|
||||||
public override void WriteJson(
|
public override void WriteJson(
|
||||||
JsonWriter writer,
|
JsonWriter writer,
|
||||||
Snowflake value,
|
Snowflake? value,
|
||||||
JsonSerializer serializer
|
JsonSerializer serializer
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
if (value != null)
|
||||||
writer.WriteValue(value.Value.ToString());
|
writer.WriteValue(value.Value.ToString());
|
||||||
|
else
|
||||||
|
writer.WriteNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Snowflake ReadJson(
|
public override Snowflake? ReadJson(
|
||||||
JsonReader reader,
|
JsonReader reader,
|
||||||
Type objectType,
|
Type objectType,
|
||||||
Snowflake existingValue,
|
Snowflake? existingValue,
|
||||||
bool hasExistingValue,
|
bool hasExistingValue,
|
||||||
JsonSerializer serializer
|
JsonSerializer serializer
|
||||||
) => ulong.Parse((string)reader.Value!);
|
) =>
|
||||||
|
reader.TokenType is not (JsonToken.None or JsonToken.Null)
|
||||||
|
? ulong.Parse((string)reader.Value!)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TypeConverter : System.ComponentModel.TypeConverter
|
private class TypeConverter : System.ComponentModel.TypeConverter
|
||||||
|
|
|
@ -14,6 +14,8 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
// ReSharper disable NotAccessedPositionalProperty.Global
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Dto;
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
public record MetaResponse(
|
public record MetaResponse(
|
||||||
|
@ -22,9 +24,12 @@ public record MetaResponse(
|
||||||
string Hash,
|
string Hash,
|
||||||
int Members,
|
int Members,
|
||||||
UserInfoResponse Users,
|
UserInfoResponse Users,
|
||||||
LimitsResponse Limits
|
LimitsResponse Limits,
|
||||||
|
MetaNoticeResponse? Notice
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record MetaNoticeResponse(Snowflake Id, string Message);
|
||||||
|
|
||||||
public record UserInfoResponse(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
|
public record UserInfoResponse(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
|
||||||
|
|
||||||
public record LimitsResponse(
|
public record LimitsResponse(
|
||||||
|
|
|
@ -16,8 +16,10 @@
|
||||||
// ReSharper disable NotAccessedPositionalProperty.Global
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Dto;
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
@ -29,10 +31,19 @@ public record ReportResponse(
|
||||||
PartialMember? TargetMember,
|
PartialMember? TargetMember,
|
||||||
ReportStatus Status,
|
ReportStatus Status,
|
||||||
ReportReason Reason,
|
ReportReason Reason,
|
||||||
|
string? Context,
|
||||||
ReportTargetType TargetType,
|
ReportTargetType TargetType,
|
||||||
JObject? Snapshot
|
JObject? Snapshot
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record ReportDetailResponse(
|
||||||
|
ReportResponse Report,
|
||||||
|
UserResponse User,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] MemberResponse? Member,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
AuditLogResponse? AuditLogEntry
|
||||||
|
);
|
||||||
|
|
||||||
public record AuditLogResponse(
|
public record AuditLogResponse(
|
||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
AuditLogEntity Moderator,
|
AuditLogEntity Moderator,
|
||||||
|
@ -40,12 +51,23 @@ public record AuditLogResponse(
|
||||||
AuditLogEntity? TargetUser,
|
AuditLogEntity? TargetUser,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
AuditLogEntity? TargetMember,
|
AuditLogEntity? TargetMember,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ReportId,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] PartialReport? Report,
|
||||||
AuditLogEntryType Type,
|
AuditLogEntryType Type,
|
||||||
string? Reason,
|
string? Reason,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record PartialReport(
|
||||||
|
Snowflake Id,
|
||||||
|
Snowflake ReporterId,
|
||||||
|
Snowflake TargetUserId,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
Snowflake? TargetMemberId,
|
||||||
|
ReportReason Reason,
|
||||||
|
string? Context,
|
||||||
|
ReportTargetType TargetType
|
||||||
|
);
|
||||||
|
|
||||||
public record NotificationResponse(
|
public record NotificationResponse(
|
||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
NotificationType Type,
|
NotificationType Type,
|
||||||
|
@ -57,19 +79,21 @@ public record NotificationResponse(
|
||||||
|
|
||||||
public record AuditLogEntity(Snowflake Id, string Username);
|
public record AuditLogEntity(Snowflake Id, string Username);
|
||||||
|
|
||||||
public record CreateReportRequest(ReportReason Reason);
|
public record CreateReportRequest(ReportReason Reason, string? Context = null);
|
||||||
|
|
||||||
public record IgnoreReportRequest(string? Reason = null);
|
public record IgnoreReportRequest(string? Reason = null);
|
||||||
|
|
||||||
public record WarnUserRequest(
|
public class WarnUserRequest
|
||||||
string Reason,
|
{
|
||||||
FieldsToClear[]? ClearFields = null,
|
public required string Reason { get; init; }
|
||||||
Snowflake? MemberId = null,
|
public FieldsToClear[]? ClearFields { get; init; }
|
||||||
Snowflake? ReportId = null
|
public Snowflake? MemberId { get; init; }
|
||||||
);
|
public Snowflake? ReportId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null);
|
public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null);
|
||||||
|
|
||||||
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
public enum FieldsToClear
|
public enum FieldsToClear
|
||||||
{
|
{
|
||||||
DisplayName,
|
DisplayName,
|
||||||
|
@ -82,3 +106,29 @@ public enum FieldsToClear
|
||||||
Flags,
|
Flags,
|
||||||
CustomPreferences,
|
CustomPreferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record QueryUsersRequest(string Query, bool Fuzzy);
|
||||||
|
|
||||||
|
public record QueryUserResponse(
|
||||||
|
UserResponse User,
|
||||||
|
bool MemberListHidden,
|
||||||
|
Instant LastActive,
|
||||||
|
Instant LastSidReroll,
|
||||||
|
bool Suspended,
|
||||||
|
bool Deleted,
|
||||||
|
bool ShowSensitiveData,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
IEnumerable<AuthMethodResponse>? AuthMethods
|
||||||
|
);
|
||||||
|
|
||||||
|
public record QuerySensitiveUserDataRequest(string Reason);
|
||||||
|
|
||||||
|
public record NoticeResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
string Message,
|
||||||
|
Instant StartTime,
|
||||||
|
Instant EndTime,
|
||||||
|
PartialUser Author
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CreateNoticeRequest(string Message, Instant? StartTime, Instant EndTime);
|
||||||
|
|
|
@ -36,7 +36,7 @@ public record UserResponse(
|
||||||
IEnumerable<FieldEntry> Names,
|
IEnumerable<FieldEntry> Names,
|
||||||
IEnumerable<Pronoun> Pronouns,
|
IEnumerable<Pronoun> Pronouns,
|
||||||
IEnumerable<Field> Fields,
|
IEnumerable<Field> Fields,
|
||||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
Dictionary<Snowflake, CustomPreferenceResponse> CustomPreferences,
|
||||||
IEnumerable<PrideFlagResponse> Flags,
|
IEnumerable<PrideFlagResponse> Flags,
|
||||||
int? UtcOffset,
|
int? UtcOffset,
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
||||||
|
@ -49,7 +49,16 @@ public record UserResponse(
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Suspended,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Suspended,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserSettings? Settings
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CustomPreferenceResponse(
|
||||||
|
string Icon,
|
||||||
|
string Tooltip,
|
||||||
|
bool Muted,
|
||||||
|
bool Favourite,
|
||||||
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] PreferenceSize Size
|
||||||
);
|
);
|
||||||
|
|
||||||
public record AuthMethodResponse(
|
public record AuthMethodResponse(
|
||||||
|
@ -71,6 +80,7 @@ public record PartialUser(
|
||||||
public class UpdateUserSettingsRequest : PatchRequest
|
public class UpdateUserSettingsRequest : PatchRequest
|
||||||
{
|
{
|
||||||
public bool? DarkMode { get; init; }
|
public bool? DarkMode { get; init; }
|
||||||
|
public Snowflake? LastReadNotice { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CustomPreferenceUpdateRequest
|
public class CustomPreferenceUpdateRequest
|
||||||
|
|
59
Foxnouns.Backend/Dto/V1/Member.cs
Normal file
59
Foxnouns.Backend/Dto/V1/Member.cs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
// 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 Foxnouns.Backend.Database;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto.V1;
|
||||||
|
|
||||||
|
public record PartialMember(
|
||||||
|
string Id,
|
||||||
|
Snowflake IdNew,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? Avatar,
|
||||||
|
string[] Links,
|
||||||
|
FieldEntry[] Names,
|
||||||
|
PronounEntry[] Pronouns
|
||||||
|
);
|
||||||
|
|
||||||
|
public record MemberResponse(
|
||||||
|
string Id,
|
||||||
|
Snowflake IdNew,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? Avatar,
|
||||||
|
string[] Links,
|
||||||
|
FieldEntry[] Names,
|
||||||
|
PronounEntry[] Pronouns,
|
||||||
|
ProfileField[] Fields,
|
||||||
|
PrideFlag[] Flags,
|
||||||
|
PartialUser User,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PartialUser(
|
||||||
|
string Id,
|
||||||
|
Snowflake IdNew,
|
||||||
|
string Name,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Avatar,
|
||||||
|
Dictionary<Guid, CustomPreference> CustomPreferences
|
||||||
|
);
|
130
Foxnouns.Backend/Dto/V1/User.cs
Normal file
130
Foxnouns.Backend/Dto/V1/User.cs
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
// 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 Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Services.V1;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
using Newtonsoft.Json.Serialization;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto.V1;
|
||||||
|
|
||||||
|
public record UserResponse(
|
||||||
|
string Id,
|
||||||
|
Snowflake IdNew,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? MemberTitle,
|
||||||
|
string? Avatar,
|
||||||
|
string[] Links,
|
||||||
|
FieldEntry[] Names,
|
||||||
|
PronounEntry[] Pronouns,
|
||||||
|
ProfileField[] Fields,
|
||||||
|
PrideFlag[] Flags,
|
||||||
|
PartialMember[] Members,
|
||||||
|
int? UtcOffset,
|
||||||
|
Dictionary<Guid, CustomPreference> CustomPreferences
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CurrentUserResponse(
|
||||||
|
string Id,
|
||||||
|
Snowflake IdNew,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? MemberTitle,
|
||||||
|
string? Avatar,
|
||||||
|
string[] Links,
|
||||||
|
FieldEntry[] Names,
|
||||||
|
PronounEntry[] Pronouns,
|
||||||
|
ProfileField[] Fields,
|
||||||
|
PrideFlag[] Flags,
|
||||||
|
PartialMember[] Members,
|
||||||
|
int? UtcOffset,
|
||||||
|
Dictionary<Guid, CustomPreference> CustomPreferences,
|
||||||
|
Instant CreatedAt,
|
||||||
|
string? Timezone,
|
||||||
|
bool IsAdmin,
|
||||||
|
bool ListPrivate,
|
||||||
|
Instant LastSidReroll,
|
||||||
|
string? Discord,
|
||||||
|
string? DiscordUsername,
|
||||||
|
string? Google,
|
||||||
|
string? GoogleUsername,
|
||||||
|
string? Tumblr,
|
||||||
|
string? TumblrUsername,
|
||||||
|
string? Fediverse,
|
||||||
|
string? FediverseUsername,
|
||||||
|
string? FediverseInstance
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CustomPreference(
|
||||||
|
string Icon,
|
||||||
|
string Tooltip,
|
||||||
|
[property: JsonConverter(typeof(StringEnumConverter), typeof(SnakeCaseNamingStrategy))]
|
||||||
|
PreferenceSize Size,
|
||||||
|
bool Muted,
|
||||||
|
bool Favourite
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ProfileField(string Name, FieldEntry[] Entries)
|
||||||
|
{
|
||||||
|
public static ProfileField FromField(
|
||||||
|
Field field,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
|
) => new(field.Name, FieldEntry.FromEntries(field.Entries, customPreferences));
|
||||||
|
|
||||||
|
public static ProfileField[] FromFields(
|
||||||
|
IEnumerable<Field> fields,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
|
) => fields.Select(f => FromField(f, customPreferences)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FieldEntry(string Value, string Status)
|
||||||
|
{
|
||||||
|
public static FieldEntry[] FromEntries(
|
||||||
|
IEnumerable<Foxnouns.Backend.Database.Models.FieldEntry> entries,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
|
) =>
|
||||||
|
entries
|
||||||
|
.Select(e => new FieldEntry(
|
||||||
|
e.Value,
|
||||||
|
V1Utils.TranslateStatus(e.Status, customPreferences)
|
||||||
|
))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PronounEntry(string Pronouns, string? DisplayText, string Status)
|
||||||
|
{
|
||||||
|
public static PronounEntry[] FromPronouns(
|
||||||
|
IEnumerable<Pronoun> pronouns,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
|
) =>
|
||||||
|
pronouns
|
||||||
|
.Select(p => new PronounEntry(
|
||||||
|
p.Value,
|
||||||
|
p.DisplayText,
|
||||||
|
V1Utils.TranslateStatus(p.Status, customPreferences)
|
||||||
|
))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PrideFlag(string Id, Snowflake IdNew, string Hash, string Name, string? Description);
|
|
@ -164,6 +164,7 @@ public enum ErrorCode
|
||||||
GenericApiError,
|
GenericApiError,
|
||||||
UserNotFound,
|
UserNotFound,
|
||||||
MemberNotFound,
|
MemberNotFound,
|
||||||
|
PageNotFound,
|
||||||
AccountAlreadyLinked,
|
AccountAlreadyLinked,
|
||||||
LastAuthMethod,
|
LastAuthMethod,
|
||||||
InvalidReportTarget,
|
InvalidReportTarget,
|
||||||
|
|
|
@ -33,24 +33,20 @@ public static class ImageObjectExtensions
|
||||||
Snowflake id,
|
Snowflake id,
|
||||||
string hash,
|
string hash,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
) =>
|
) => await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateJob.Path(id, hash), ct);
|
||||||
await objectStorageService.RemoveObjectAsync(
|
|
||||||
MemberAvatarUpdateInvocable.Path(id, hash),
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
|
|
||||||
public static async Task DeleteUserAvatarAsync(
|
public static async Task DeleteUserAvatarAsync(
|
||||||
this ObjectStorageService objectStorageService,
|
this ObjectStorageService objectStorageService,
|
||||||
Snowflake id,
|
Snowflake id,
|
||||||
string hash,
|
string hash,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct);
|
) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateJob.Path(id, hash), ct);
|
||||||
|
|
||||||
public static async Task DeleteFlagAsync(
|
public static async Task DeleteFlagAsync(
|
||||||
this ObjectStorageService objectStorageService,
|
this ObjectStorageService objectStorageService,
|
||||||
string hash,
|
string hash,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct);
|
) => await objectStorageService.RemoveObjectAsync(CreateFlagJob.Path(hash), ct);
|
||||||
|
|
||||||
public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage(
|
public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage(
|
||||||
string uri,
|
string uri,
|
||||||
|
|
|
@ -23,23 +23,19 @@ namespace Foxnouns.Backend.Extensions;
|
||||||
|
|
||||||
public static class KeyCacheExtensions
|
public static class KeyCacheExtensions
|
||||||
{
|
{
|
||||||
public static async Task<string> GenerateAuthStateAsync(
|
public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheService)
|
||||||
this KeyCacheService keyCacheService,
|
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
string state = AuthUtils.RandomToken();
|
string state = AuthUtils.RandomToken();
|
||||||
await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct);
|
await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10));
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task ValidateAuthStateAsync(
|
public static async Task ValidateAuthStateAsync(
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
string state,
|
string state
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct);
|
string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}");
|
||||||
if (val == null)
|
if (val == null)
|
||||||
throw new ApiError.BadRequest("Invalid OAuth state");
|
throw new ApiError.BadRequest("Invalid OAuth state");
|
||||||
}
|
}
|
||||||
|
@ -47,63 +43,55 @@ public static class KeyCacheExtensions
|
||||||
public static async Task<string> GenerateRegisterEmailStateAsync(
|
public static async Task<string> GenerateRegisterEmailStateAsync(
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
string email,
|
string email,
|
||||||
Snowflake? userId = null,
|
Snowflake? userId = null
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
string state = AuthUtils.RandomToken();
|
string state = AuthUtils.RandomToken();
|
||||||
await keyCacheService.SetKeyAsync(
|
await keyCacheService.SetKeyAsync(
|
||||||
$"email_state:{state}",
|
$"email_state:{state}",
|
||||||
new RegisterEmailState(email, userId),
|
new RegisterEmailState(email, userId),
|
||||||
Duration.FromDays(1),
|
Duration.FromDays(1)
|
||||||
ct
|
|
||||||
);
|
);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<RegisterEmailState?> GetRegisterEmailStateAsync(
|
public static async Task<RegisterEmailState?> GetRegisterEmailStateAsync(
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
string state,
|
string state
|
||||||
CancellationToken ct = default
|
) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}");
|
||||||
) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", ct: ct);
|
|
||||||
|
|
||||||
public static async Task<string> GenerateAddExtraAccountStateAsync(
|
public static async Task<string> GenerateAddExtraAccountStateAsync(
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
AuthType authType,
|
AuthType authType,
|
||||||
Snowflake userId,
|
Snowflake userId,
|
||||||
string? instance = null,
|
string? instance = null
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
string state = AuthUtils.RandomToken();
|
string state = AuthUtils.RandomToken();
|
||||||
await keyCacheService.SetKeyAsync(
|
await keyCacheService.SetKeyAsync(
|
||||||
$"add_account:{state}",
|
$"add_account:{state}",
|
||||||
new AddExtraAccountState(authType, userId, instance),
|
new AddExtraAccountState(authType, userId, instance),
|
||||||
Duration.FromDays(1),
|
Duration.FromDays(1)
|
||||||
ct
|
|
||||||
);
|
);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<AddExtraAccountState?> GetAddExtraAccountStateAsync(
|
public static async Task<AddExtraAccountState?> GetAddExtraAccountStateAsync(
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
string state,
|
string state
|
||||||
CancellationToken ct = default
|
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true);
|
||||||
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct);
|
|
||||||
|
|
||||||
public static async Task<string> GenerateForgotPasswordStateAsync(
|
public static async Task<string> GenerateForgotPasswordStateAsync(
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
string email,
|
string email,
|
||||||
Snowflake userId,
|
Snowflake userId
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
string state = AuthUtils.RandomToken();
|
string state = AuthUtils.RandomToken();
|
||||||
await keyCacheService.SetKeyAsync(
|
await keyCacheService.SetKeyAsync(
|
||||||
$"forgot_password:{state}",
|
$"forgot_password:{state}",
|
||||||
new ForgotPasswordState(email, userId),
|
new ForgotPasswordState(email, userId),
|
||||||
Duration.FromHours(1),
|
Duration.FromHours(1)
|
||||||
ct
|
|
||||||
);
|
);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
@ -111,14 +99,8 @@ public static class KeyCacheExtensions
|
||||||
public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync(
|
public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync(
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
string state,
|
string state,
|
||||||
bool delete = true,
|
bool delete = true
|
||||||
CancellationToken ct = default
|
) => await keyCacheService.GetKeyAsync<ForgotPasswordState>($"forgot_password:{state}", delete);
|
||||||
) =>
|
|
||||||
await keyCacheService.GetKeyAsync<ForgotPasswordState>(
|
|
||||||
$"forgot_password:{state}",
|
|
||||||
delete,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RegisterEmailState(
|
public record RegisterEmailState(
|
||||||
|
|
|
@ -15,13 +15,18 @@
|
||||||
using Coravel;
|
using Coravel;
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Services.Auth;
|
using Foxnouns.Backend.Services.Auth;
|
||||||
|
using Foxnouns.Backend.Services.Caching;
|
||||||
|
using Foxnouns.Backend.Services.V1;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Http.Resilience;
|
||||||
using Minio;
|
using Minio;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using Polly;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
|
@ -50,9 +55,12 @@ public static class WebApplicationExtensions
|
||||||
"Microsoft.EntityFrameworkCore.Database.Command",
|
"Microsoft.EntityFrameworkCore.Database.Command",
|
||||||
config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal
|
config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal
|
||||||
)
|
)
|
||||||
|
// These spam the output even on INF level
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
||||||
|
// Hangfire's debug-level logs are extremely spammy for no reason
|
||||||
|
.MinimumLevel.Override("Hangfire", LogEventLevel.Information)
|
||||||
.WriteTo.Console(theme: AnsiConsoleTheme.Sixteen);
|
.WriteTo.Console(theme: AnsiConsoleTheme.Sixteen);
|
||||||
|
|
||||||
if (config.Logging.SeqLogUrl != null)
|
if (config.Logging.SeqLogUrl != null)
|
||||||
|
@ -96,6 +104,40 @@ public static class WebApplicationExtensions
|
||||||
builder.Host.ConfigureServices(
|
builder.Host.ConfigureServices(
|
||||||
(ctx, services) =>
|
(ctx, services) =>
|
||||||
{
|
{
|
||||||
|
// create a single HTTP client for all requests.
|
||||||
|
// it's also configured with a retry mechanism, so that requests aren't immediately lost to the void if they fail
|
||||||
|
services.AddSingleton<HttpClient>(_ =>
|
||||||
|
{
|
||||||
|
// ReSharper disable once SuggestVarOrType_Elsewhere
|
||||||
|
var retryPipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
|
||||||
|
.AddRetry(
|
||||||
|
new HttpRetryStrategyOptions
|
||||||
|
{
|
||||||
|
BackoffType = DelayBackoffType.Linear,
|
||||||
|
MaxRetryAttempts = 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var resilienceHandler = new ResilienceHandler(retryPipeline)
|
||||||
|
{
|
||||||
|
InnerHandler = new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
PooledConnectionLifetime = TimeSpan.FromMinutes(15),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var client = new HttpClient(resilienceHandler);
|
||||||
|
client.DefaultRequestHeaders.Remove("User-Agent");
|
||||||
|
client.DefaultRequestHeaders.Remove("Accept");
|
||||||
|
client.DefaultRequestHeaders.Add(
|
||||||
|
"User-Agent",
|
||||||
|
$"pronouns.cc/{BuildInfo.Version}"
|
||||||
|
);
|
||||||
|
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
return client;
|
||||||
|
});
|
||||||
|
|
||||||
services
|
services
|
||||||
.AddQueue()
|
.AddQueue()
|
||||||
.AddSmtpMailer(ctx.Configuration)
|
.AddSmtpMailer(ctx.Configuration)
|
||||||
|
@ -111,23 +153,28 @@ public static class WebApplicationExtensions
|
||||||
.AddSnowflakeGenerator()
|
.AddSnowflakeGenerator()
|
||||||
.AddSingleton<MailService>()
|
.AddSingleton<MailService>()
|
||||||
.AddSingleton<EmailRateLimiter>()
|
.AddSingleton<EmailRateLimiter>()
|
||||||
|
.AddSingleton<KeyCacheService>()
|
||||||
.AddScoped<UserRendererService>()
|
.AddScoped<UserRendererService>()
|
||||||
.AddScoped<MemberRendererService>()
|
.AddScoped<MemberRendererService>()
|
||||||
.AddScoped<ModerationRendererService>()
|
.AddScoped<ModerationRendererService>()
|
||||||
.AddScoped<ModerationService>()
|
.AddScoped<ModerationService>()
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<KeyCacheService>()
|
|
||||||
.AddScoped<RemoteAuthService>()
|
.AddScoped<RemoteAuthService>()
|
||||||
.AddScoped<FediverseAuthService>()
|
.AddScoped<FediverseAuthService>()
|
||||||
.AddScoped<ObjectStorageService>()
|
.AddScoped<ObjectStorageService>()
|
||||||
.AddTransient<DataCleanupService>()
|
.AddTransient<DataCleanupService>()
|
||||||
|
.AddTransient<ValidationService>()
|
||||||
|
.AddSingleton<NoticeCacheService>()
|
||||||
// Background services
|
// Background services
|
||||||
.AddHostedService<PeriodicTasksService>()
|
.AddHostedService<PeriodicTasksService>()
|
||||||
// Transient jobs
|
// Transient jobs
|
||||||
.AddTransient<MemberAvatarUpdateInvocable>()
|
.AddTransient<UserAvatarUpdateJob>()
|
||||||
.AddTransient<UserAvatarUpdateInvocable>()
|
.AddTransient<MemberAvatarUpdateJob>()
|
||||||
.AddTransient<CreateFlagInvocable>()
|
.AddTransient<CreateDataExportJob>()
|
||||||
.AddTransient<CreateDataExportInvocable>();
|
.AddTransient<CreateFlagJob>()
|
||||||
|
// Legacy services
|
||||||
|
.AddScoped<UsersV1Service>()
|
||||||
|
.AddScoped<MembersV1Service>();
|
||||||
|
|
||||||
if (!config.Logging.EnableMetrics)
|
if (!config.Logging.EnableMetrics)
|
||||||
services.AddHostedService<BackgroundMetricsCollectionService>();
|
services.AddHostedService<BackgroundMetricsCollectionService>();
|
||||||
|
@ -152,9 +199,6 @@ public static class WebApplicationExtensions
|
||||||
|
|
||||||
public static async Task Initialize(this WebApplication app, string[] args)
|
public static async Task Initialize(this WebApplication app, string[] args)
|
||||||
{
|
{
|
||||||
// Read version information from .version in the repository root
|
|
||||||
await BuildInfo.ReadBuildInfo();
|
|
||||||
|
|
||||||
app.Services.ConfigureQueue()
|
app.Services.ConfigureQueue()
|
||||||
.LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>());
|
.LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>());
|
||||||
|
|
||||||
|
|
|
@ -8,42 +8,48 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Coravel" Version="6.0.0"/>
|
<PackageReference Include="Coravel" Version="6.0.2"/>
|
||||||
<PackageReference Include="Coravel.Mailer" Version="7.0.0"/>
|
<PackageReference Include="Coravel.Mailer" Version="7.1.0"/>
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
|
||||||
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3"/>
|
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3"/>
|
||||||
|
<PackageReference Include="Hangfire" Version="1.8.18"/>
|
||||||
|
<PackageReference Include="Hangfire.Core" Version="1.8.18"/>
|
||||||
|
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.4"/>
|
||||||
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
|
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
|
||||||
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
|
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0"/>
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.2"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0"/>
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.2"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2"/>
|
||||||
<PackageReference Include="MimeKit" Version="4.9.0"/>
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.2.0"/>
|
||||||
<PackageReference Include="Minio" Version="6.0.3"/>
|
<PackageReference Include="MimeKit" Version="4.10.0"/>
|
||||||
|
<PackageReference Include="Minio" Version="6.0.4"/>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
||||||
<PackageReference Include="NodaTime" Version="3.2.0"/>
|
<PackageReference Include="NodaTime" Version="3.2.1"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2"/>
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.2"/>
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
|
||||||
<PackageReference Include="Npgsql.Json.NET" Version="9.0.2"/>
|
<PackageReference Include="Npgsql.Json.NET" Version="9.0.3"/>
|
||||||
<PackageReference Include="prometheus-net" Version="8.2.1"/>
|
<PackageReference Include="prometheus-net" Version="8.2.1"/>
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
||||||
<PackageReference Include="Roslynator.Analyzers" Version="4.12.9">
|
<PackageReference Include="Roslynator.Analyzers" Version="4.13.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="1.2.55"/>
|
<PackageReference Include="Scalar.AspNetCore" Version="2.0.26"/>
|
||||||
<PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/>
|
<PackageReference Include="Sentry.AspNetCore" Version="5.3.0"/>
|
||||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/>
|
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/>
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7"/>
|
||||||
<PackageReference Include="System.Text.Json" Version="9.0.0"/>
|
<PackageReference Include="StackExchange.Redis" Version="2.8.31"/>
|
||||||
|
<PackageReference Include="System.Text.Json" Version="9.0.2"/>
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
|
||||||
|
<PackageReference Include="Yort.Xid.Net" Version="2.0.1"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
|
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
|
||||||
|
|
|
@ -14,11 +14,11 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Coravel.Invocable;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Hangfire;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
@ -26,7 +26,8 @@ using NodaTime.Text;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
namespace Foxnouns.Backend.Jobs;
|
||||||
|
|
||||||
public class CreateDataExportInvocable(
|
public class CreateDataExportJob(
|
||||||
|
HttpClient client,
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
IClock clock,
|
IClock clock,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
|
@ -34,37 +35,40 @@ public class CreateDataExportInvocable(
|
||||||
ObjectStorageService objectStorageService,
|
ObjectStorageService objectStorageService,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
ILogger logger
|
ILogger logger
|
||||||
) : IInvocable, IInvocableWithPayload<CreateDataExportPayload>
|
)
|
||||||
{
|
{
|
||||||
private static readonly HttpClient Client = new();
|
private readonly ILogger _logger = logger.ForContext<CreateDataExportJob>();
|
||||||
private readonly ILogger _logger = logger.ForContext<CreateDataExportInvocable>();
|
|
||||||
public required CreateDataExportPayload Payload { get; set; }
|
|
||||||
|
|
||||||
public async Task Invoke()
|
public static void Enqueue(Snowflake userId)
|
||||||
|
{
|
||||||
|
BackgroundJob.Enqueue<CreateDataExportJob>(j => j.InvokeAsync(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(Snowflake userId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await InvokeAsync();
|
await InvokeAsyncInner(userId);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
_logger.Error(e, "Error generating data export for user {UserId}", Payload.UserId);
|
_logger.Error(e, "Error generating data export for user {UserId}", userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InvokeAsync()
|
private async Task InvokeAsyncInner(Snowflake userId)
|
||||||
{
|
{
|
||||||
User? user = await db
|
User? user = await db
|
||||||
.Users.Include(u => u.AuthMethods)
|
.Users.Include(u => u.AuthMethods)
|
||||||
.Include(u => u.Flags)
|
.Include(u => u.Flags)
|
||||||
.Include(u => u.ProfileFlags)
|
.Include(u => u.ProfileFlags)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(u => u.Id == Payload.UserId);
|
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
_logger.Warning(
|
_logger.Warning(
|
||||||
"Received create data export request for user {UserId} but no such user exists, deleted or otherwise. Ignoring request",
|
"Received create data export request for user {UserId} but no such user exists, deleted or otherwise. Ignoring request",
|
||||||
Payload.UserId
|
userId
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -197,7 +201,7 @@ public class CreateDataExportInvocable(
|
||||||
if (s3Path == null)
|
if (s3Path == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
HttpResponseMessage resp = await Client.GetAsync(s3Path);
|
HttpResponseMessage resp = await client.GetAsync(s3Path);
|
||||||
if (resp.StatusCode != HttpStatusCode.OK)
|
if (resp.StatusCode != HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
_logger.Warning("S3 path {S3Path} returned a non-200 status, not saving file", s3Path);
|
_logger.Warning("S3 path {S3Path} returned a non-200 status, not saving file", s3Path);
|
||||||
|
@ -220,5 +224,5 @@ public class CreateDataExportInvocable(
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExportPath(Snowflake userId, string b64) =>
|
private static string ExportPath(Snowflake userId, string b64) =>
|
||||||
$"data-exports/{userId}/{b64}.zip";
|
$"data-exports/{userId}/{b64}/data-export.zip";
|
||||||
}
|
}
|
|
@ -12,49 +12,53 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Invocable;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Hangfire;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
namespace Foxnouns.Backend.Jobs;
|
||||||
|
|
||||||
public class CreateFlagInvocable(
|
public class CreateFlagJob(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
ObjectStorageService objectStorageService,
|
ObjectStorageService objectStorageService,
|
||||||
ILogger logger
|
ILogger logger
|
||||||
) : IInvocable, IInvocableWithPayload<CreateFlagPayload>
|
)
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<CreateFlagInvocable>();
|
private readonly ILogger _logger = logger.ForContext<CreateFlagJob>();
|
||||||
public required CreateFlagPayload Payload { get; set; }
|
|
||||||
|
|
||||||
public async Task Invoke()
|
public static void Enqueue(CreateFlagPayload payload)
|
||||||
|
{
|
||||||
|
BackgroundJob.Enqueue<CreateFlagJob>(j => j.InvokeAsync(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(CreateFlagPayload payload)
|
||||||
{
|
{
|
||||||
_logger.Information(
|
_logger.Information(
|
||||||
"Creating flag {FlagId} for user {UserId} with image data length {DataLength}",
|
"Creating flag {FlagId} for user {UserId} with image data length {DataLength}",
|
||||||
Payload.Id,
|
payload.Id,
|
||||||
Payload.UserId,
|
payload.UserId,
|
||||||
Payload.ImageData.Length
|
payload.ImageData.Length
|
||||||
);
|
);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||||
f.Id == Payload.Id && f.UserId == Payload.UserId
|
f.Id == payload.Id && f.UserId == payload.UserId
|
||||||
);
|
);
|
||||||
if (flag == null)
|
if (flag == null)
|
||||||
{
|
{
|
||||||
_logger.Warning(
|
_logger.Warning(
|
||||||
"Got a flag create job for {FlagId} but it doesn't exist, aborting",
|
"Got a flag create job for {FlagId} but it doesn't exist, aborting",
|
||||||
Payload.Id
|
payload.Id
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||||
Payload.ImageData,
|
payload.ImageData,
|
||||||
256,
|
256,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
@ -68,7 +72,7 @@ public class CreateFlagInvocable(
|
||||||
}
|
}
|
||||||
catch (ArgumentException ae)
|
catch (ArgumentException ae)
|
||||||
{
|
{
|
||||||
_logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", Payload.Id, ae.Message);
|
_logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", payload.Id, ae.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
|
|
|
@ -12,29 +12,33 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Invocable;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Hangfire;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
namespace Foxnouns.Backend.Jobs;
|
||||||
|
|
||||||
public class MemberAvatarUpdateInvocable(
|
public class MemberAvatarUpdateJob(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
ObjectStorageService objectStorageService,
|
ObjectStorageService objectStorageService,
|
||||||
ILogger logger
|
ILogger logger
|
||||||
) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
|
)
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
|
private readonly ILogger _logger = logger.ForContext<MemberAvatarUpdateJob>();
|
||||||
public required AvatarUpdatePayload Payload { get; set; }
|
|
||||||
|
|
||||||
public async Task Invoke()
|
public static void Enqueue(AvatarUpdatePayload payload)
|
||||||
{
|
{
|
||||||
if (Payload.NewAvatar != null)
|
BackgroundJob.Enqueue<MemberAvatarUpdateJob>(j => j.InvokeAsync(payload));
|
||||||
await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar);
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(AvatarUpdatePayload payload)
|
||||||
|
{
|
||||||
|
if (payload.NewAvatar != null)
|
||||||
|
await UpdateMemberAvatarAsync(payload.Id, payload.NewAvatar);
|
||||||
else
|
else
|
||||||
await ClearMemberAvatarAsync(Payload.Id);
|
await ClearMemberAvatarAsync(payload.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar)
|
private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar)
|
|
@ -19,5 +19,3 @@ namespace Foxnouns.Backend.Jobs;
|
||||||
public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar);
|
public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar);
|
||||||
|
|
||||||
public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string ImageData);
|
public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string ImageData);
|
||||||
|
|
||||||
public record CreateDataExportPayload(Snowflake UserId);
|
|
||||||
|
|
|
@ -12,29 +12,33 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Invocable;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Hangfire;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
namespace Foxnouns.Backend.Jobs;
|
||||||
|
|
||||||
public class UserAvatarUpdateInvocable(
|
public class UserAvatarUpdateJob(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
ObjectStorageService objectStorageService,
|
ObjectStorageService objectStorageService,
|
||||||
ILogger logger
|
ILogger logger
|
||||||
) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
|
)
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
|
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateJob>();
|
||||||
public required AvatarUpdatePayload Payload { get; set; }
|
|
||||||
|
|
||||||
public async Task Invoke()
|
public static void Enqueue(AvatarUpdatePayload payload)
|
||||||
{
|
{
|
||||||
if (Payload.NewAvatar != null)
|
BackgroundJob.Enqueue<UserAvatarUpdateJob>(j => j.InvokeAsync(payload));
|
||||||
await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar);
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(AvatarUpdatePayload payload)
|
||||||
|
{
|
||||||
|
if (payload.NewAvatar != null)
|
||||||
|
await UpdateUserAvatarAsync(payload.Id, payload.NewAvatar);
|
||||||
else
|
else
|
||||||
await ClearUserAvatarAsync(Payload.Id);
|
await ClearUserAvatarAsync(payload.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar)
|
private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar)
|
|
@ -41,7 +41,7 @@ public class LimitMiddleware : IMiddleware
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token?.User.Deleted == true && !attribute.UsableBySuspendedUsers)
|
if (token?.User.Deleted == true && !attribute.UsableByDeletedUsers)
|
||||||
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
|
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
|
||||||
|
|
||||||
if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin)
|
if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin)
|
||||||
|
@ -62,7 +62,7 @@ public class LimitMiddleware : IMiddleware
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
public class LimitAttribute : Attribute
|
public class LimitAttribute : Attribute
|
||||||
{
|
{
|
||||||
public bool UsableBySuspendedUsers { get; init; }
|
public bool UsableByDeletedUsers { get; init; }
|
||||||
public bool RequireAdmin { get; init; }
|
public bool RequireAdmin { get; init; }
|
||||||
public bool RequireModerator { get; init; }
|
public bool RequireModerator { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,11 +19,12 @@ using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Foxnouns.Backend.Utils.OpenApi;
|
using Foxnouns.Backend.Utils.OpenApi;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.Redis.StackExchange;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Serialization;
|
using Newtonsoft.Json.Serialization;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
using Scalar.AspNetCore;
|
|
||||||
using Sentry.Extensibility;
|
using Sentry.Extensibility;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
|
@ -33,6 +34,9 @@ Config config = builder.AddConfiguration();
|
||||||
|
|
||||||
builder.AddSerilog();
|
builder.AddSerilog();
|
||||||
|
|
||||||
|
// Read version information from .version in the repository root
|
||||||
|
await BuildInfo.ReadBuildInfo();
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.WebHost.UseSentry(opts =>
|
.WebHost.UseSentry(opts =>
|
||||||
{
|
{
|
||||||
|
@ -46,7 +50,8 @@ builder
|
||||||
// No valid request body will ever come close to this limit,
|
// No valid request body will ever come close to this limit,
|
||||||
// but the limit is slightly higher to prevent valid requests from being rejected.
|
// but the limit is slightly higher to prevent valid requests from being rejected.
|
||||||
opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024;
|
opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024;
|
||||||
});
|
})
|
||||||
|
.UseUrls(config.Address);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.Services.AddControllers()
|
.Services.AddControllers()
|
||||||
|
@ -63,16 +68,27 @@ builder
|
||||||
{
|
{
|
||||||
NamingStrategy = new SnakeCaseNamingStrategy(),
|
NamingStrategy = new SnakeCaseNamingStrategy(),
|
||||||
};
|
};
|
||||||
|
options.SerializerSettings.DateParseHandling = DateParseHandling.None;
|
||||||
})
|
})
|
||||||
.ConfigureApiBehaviorOptions(options =>
|
.ConfigureApiBehaviorOptions(options =>
|
||||||
{
|
{
|
||||||
// the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine)
|
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult(
|
||||||
options.InvalidModelStateResponseFactory = (ActionContext actionContext) =>
|
|
||||||
new BadRequestObjectResult(
|
|
||||||
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
|
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder
|
||||||
|
.Services.AddHangfire(
|
||||||
|
(services, c) =>
|
||||||
|
{
|
||||||
|
c.UseRedisStorage(
|
||||||
|
services.GetRequiredService<KeyCacheService>().Multiplexer,
|
||||||
|
new RedisStorageOptions { Prefix = "foxnouns_net:" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.AddHangfireServer();
|
||||||
|
|
||||||
builder.Services.AddOpenApi(
|
builder.Services.AddOpenApi(
|
||||||
"v2",
|
"v2",
|
||||||
options =>
|
options =>
|
||||||
|
@ -109,16 +125,19 @@ if (config.Logging.SentryTracing)
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseCustomMiddleware();
|
app.UseCustomMiddleware();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapOpenApi("/api-docs/openapi/{documentName}.json");
|
app.UseHangfireDashboard();
|
||||||
app.MapScalarApiReference(options =>
|
|
||||||
{
|
|
||||||
options.Title = "pronouns.cc API";
|
|
||||||
options.OpenApiRoutePattern = "/api-docs/openapi/{documentName}.json";
|
|
||||||
options.EndpointPathPrefix = "/api-docs/{documentName}";
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Urls.Clear();
|
// TODO: I can't figure out why this doesn't work yet
|
||||||
app.Urls.Add(config.Address);
|
// TODO: Manually write API docs in the meantime
|
||||||
|
// app.MapOpenApi("/api-docs/openapi/{documentName}.json");
|
||||||
|
// app.MapScalarApiReference(
|
||||||
|
// "/api-docs/",
|
||||||
|
// options =>
|
||||||
|
// {
|
||||||
|
// options.Title = "pronouns.cc API";
|
||||||
|
// options.OpenApiRoutePattern = "/api-docs/openapi/{documentName}.json";
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
|
||||||
// Make sure metrics are updated whenever Prometheus scrapes them
|
// Make sure metrics are updated whenever Prometheus scrapes them
|
||||||
Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct =>
|
Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct =>
|
||||||
|
|
|
@ -20,6 +20,7 @@ using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using XidNet;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services.Auth;
|
namespace Foxnouns.Backend.Services.Auth;
|
||||||
|
|
||||||
|
@ -28,7 +29,8 @@ public class AuthService(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
UserRendererService userRenderer
|
UserRendererService userRenderer,
|
||||||
|
ValidationService validationService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<AuthService>();
|
private readonly ILogger _logger = logger.ForContext<AuthService>();
|
||||||
|
@ -48,7 +50,7 @@ public class AuthService(
|
||||||
// Validate username and whether it's not taken
|
// Validate username and whether it's not taken
|
||||||
ValidationUtils.Validate(
|
ValidationUtils.Validate(
|
||||||
[
|
[
|
||||||
("username", ValidationUtils.ValidateUsername(username)),
|
("username", validationService.ValidateUsername(username)),
|
||||||
("password", ValidationUtils.ValidatePassword(password)),
|
("password", ValidationUtils.ValidatePassword(password)),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
@ -70,6 +72,7 @@ public class AuthService(
|
||||||
},
|
},
|
||||||
LastActive = clock.GetCurrentInstant(),
|
LastActive = clock.GetCurrentInstant(),
|
||||||
Sid = null!,
|
Sid = null!,
|
||||||
|
LegacyId = Xid.NewXid().ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Add(user);
|
db.Add(user);
|
||||||
|
@ -95,7 +98,7 @@ public class AuthService(
|
||||||
AssertValidAuthType(authType, instance);
|
AssertValidAuthType(authType, instance);
|
||||||
|
|
||||||
// Validate username and whether it's not taken
|
// Validate username and whether it's not taken
|
||||||
ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(username))]);
|
ValidationUtils.Validate([("username", validationService.ValidateUsername(username))]);
|
||||||
if (await db.Users.AnyAsync(u => u.Username == username, ct))
|
if (await db.Users.AnyAsync(u => u.Username == username, ct))
|
||||||
throw new ApiError.BadRequest("Username is already taken", "username", username);
|
throw new ApiError.BadRequest("Username is already taken", "username", username);
|
||||||
|
|
||||||
|
@ -116,6 +119,7 @@ public class AuthService(
|
||||||
},
|
},
|
||||||
LastActive = clock.GetCurrentInstant(),
|
LastActive = clock.GetCurrentInstant(),
|
||||||
Sid = null!,
|
Sid = null!,
|
||||||
|
LegacyId = Xid.NewXid().ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Add(user);
|
db.Add(user);
|
||||||
|
@ -249,14 +253,14 @@ public class AuthService(
|
||||||
{
|
{
|
||||||
AssertValidAuthType(authType, app);
|
AssertValidAuthType(authType, app);
|
||||||
|
|
||||||
// This is already checked when
|
// This is already checked when generating an add account state, but we check it here too just in case.
|
||||||
int currentCount = await db
|
int currentCount = await db
|
||||||
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
|
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
|
||||||
.CountAsync(ct);
|
.CountAsync(ct);
|
||||||
if (currentCount >= AuthUtils.MaxAuthMethodsPerType)
|
if (currentCount >= AuthUtils.MaxAuthMethodsPerType)
|
||||||
{
|
{
|
||||||
throw new ApiError.BadRequest(
|
throw new ApiError.BadRequest(
|
||||||
"Too many linked accounts of this type, maximum of 3 per account."
|
$"Too many linked accounts of this type, maximum of {AuthUtils.MaxAuthMethodsPerType} per account."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,20 +25,20 @@ namespace Foxnouns.Backend.Services.Auth;
|
||||||
public partial class FediverseAuthService
|
public partial class FediverseAuthService
|
||||||
{
|
{
|
||||||
private string MastodonRedirectUri(string instance) =>
|
private string MastodonRedirectUri(string instance) =>
|
||||||
$"{_config.BaseUrl}/auth/callback/mastodon/{instance}";
|
$"{config.BaseUrl}/auth/callback/mastodon/{instance}";
|
||||||
|
|
||||||
private async Task<FediverseApplication> CreateMastodonApplicationAsync(
|
private async Task<FediverseApplication> CreateMastodonApplicationAsync(
|
||||||
string instance,
|
string instance,
|
||||||
Snowflake? existingAppId = null
|
Snowflake? existingAppId = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
HttpResponseMessage resp = await _client.PostAsJsonAsync(
|
HttpResponseMessage resp = await client.PostAsJsonAsync(
|
||||||
$"https://{instance}/api/v1/apps",
|
$"https://{instance}/api/v1/apps",
|
||||||
new CreateMastodonApplicationRequest(
|
new CreateMastodonApplicationRequest(
|
||||||
$"pronouns.cc (+{_config.BaseUrl})",
|
$"pronouns.cc (+{config.BaseUrl})",
|
||||||
MastodonRedirectUri(instance),
|
MastodonRedirectUri(instance),
|
||||||
"read read:accounts",
|
"read read:accounts",
|
||||||
_config.BaseUrl
|
config.BaseUrl
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
@ -58,19 +58,19 @@ public partial class FediverseAuthService
|
||||||
{
|
{
|
||||||
app = new FediverseApplication
|
app = new FediverseApplication
|
||||||
{
|
{
|
||||||
Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(),
|
Id = existingAppId ?? snowflakeGenerator.GenerateSnowflake(),
|
||||||
ClientId = mastodonApp.ClientId,
|
ClientId = mastodonApp.ClientId,
|
||||||
ClientSecret = mastodonApp.ClientSecret,
|
ClientSecret = mastodonApp.ClientSecret,
|
||||||
Domain = instance,
|
Domain = instance,
|
||||||
InstanceType = FediverseInstanceType.MastodonApi,
|
InstanceType = FediverseInstanceType.MastodonApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Add(app);
|
db.Add(app);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
app =
|
app =
|
||||||
await _db.FediverseApplications.FindAsync(existingAppId)
|
await db.FediverseApplications.FindAsync(existingAppId)
|
||||||
?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null");
|
?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null");
|
||||||
|
|
||||||
app.ClientId = mastodonApp.ClientId;
|
app.ClientId = mastodonApp.ClientId;
|
||||||
|
@ -78,7 +78,7 @@ public partial class FediverseAuthService
|
||||||
app.InstanceType = FediverseInstanceType.MastodonApi;
|
app.InstanceType = FediverseInstanceType.MastodonApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
@ -90,9 +90,9 @@ public partial class FediverseAuthService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (state != null)
|
if (state != null)
|
||||||
await _keyCacheService.ValidateAuthStateAsync(state);
|
await keyCacheService.ValidateAuthStateAsync(state);
|
||||||
|
|
||||||
HttpResponseMessage tokenResp = await _client.PostAsync(
|
HttpResponseMessage tokenResp = await client.PostAsync(
|
||||||
MastodonTokenUri(app.Domain),
|
MastodonTokenUri(app.Domain),
|
||||||
new FormUrlEncodedContent(
|
new FormUrlEncodedContent(
|
||||||
new Dictionary<string, string>
|
new Dictionary<string, string>
|
||||||
|
@ -123,7 +123,7 @@ public partial class FediverseAuthService
|
||||||
var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentUserUri(app.Domain));
|
var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentUserUri(app.Domain));
|
||||||
req.Headers.Add("Authorization", $"Bearer {token}");
|
req.Headers.Add("Authorization", $"Bearer {token}");
|
||||||
|
|
||||||
HttpResponseMessage currentUserResp = await _client.SendAsync(req);
|
HttpResponseMessage currentUserResp = await client.SendAsync(req);
|
||||||
currentUserResp.EnsureSuccessStatusCode();
|
currentUserResp.EnsureSuccessStatusCode();
|
||||||
FediverseUser? user = await currentUserResp.Content.ReadFromJsonAsync<FediverseUser>();
|
FediverseUser? user = await currentUserResp.Content.ReadFromJsonAsync<FediverseUser>();
|
||||||
if (user == null)
|
if (user == null)
|
||||||
|
@ -151,7 +151,7 @@ public partial class FediverseAuthService
|
||||||
app = await CreateMastodonApplicationAsync(app.Domain, app.Id);
|
app = await CreateMastodonApplicationAsync(app.Domain, app.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync());
|
state ??= HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync());
|
||||||
|
|
||||||
return $"https://{app.Domain}/oauth/authorize?response_type=code"
|
return $"https://{app.Domain}/oauth/authorize?response_type=code"
|
||||||
+ $"&client_id={app.ClientId}"
|
+ $"&client_id={app.ClientId}"
|
||||||
|
|
|
@ -34,11 +34,11 @@ public partial class FediverseAuthService
|
||||||
Snowflake? existingAppId = null
|
Snowflake? existingAppId = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
HttpResponseMessage resp = await _client.PostAsJsonAsync(
|
HttpResponseMessage resp = await client.PostAsJsonAsync(
|
||||||
MisskeyAppUri(instance),
|
MisskeyAppUri(instance),
|
||||||
new CreateMisskeyApplicationRequest(
|
new CreateMisskeyApplicationRequest(
|
||||||
$"pronouns.cc (+{_config.BaseUrl})",
|
$"pronouns.cc (+{config.BaseUrl})",
|
||||||
$"pronouns.cc on {_config.BaseUrl}",
|
$"pronouns.cc on {config.BaseUrl}",
|
||||||
["read:account"],
|
["read:account"],
|
||||||
MastodonRedirectUri(instance)
|
MastodonRedirectUri(instance)
|
||||||
)
|
)
|
||||||
|
@ -60,19 +60,19 @@ public partial class FediverseAuthService
|
||||||
{
|
{
|
||||||
app = new FediverseApplication
|
app = new FediverseApplication
|
||||||
{
|
{
|
||||||
Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(),
|
Id = existingAppId ?? snowflakeGenerator.GenerateSnowflake(),
|
||||||
ClientId = misskeyApp.Id,
|
ClientId = misskeyApp.Id,
|
||||||
ClientSecret = misskeyApp.Secret,
|
ClientSecret = misskeyApp.Secret,
|
||||||
Domain = instance,
|
Domain = instance,
|
||||||
InstanceType = FediverseInstanceType.MisskeyApi,
|
InstanceType = FediverseInstanceType.MisskeyApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Add(app);
|
db.Add(app);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
app =
|
app =
|
||||||
await _db.FediverseApplications.FindAsync(existingAppId)
|
await db.FediverseApplications.FindAsync(existingAppId)
|
||||||
?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null");
|
?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null");
|
||||||
|
|
||||||
app.ClientId = misskeyApp.Id;
|
app.ClientId = misskeyApp.Id;
|
||||||
|
@ -80,7 +80,7 @@ public partial class FediverseAuthService
|
||||||
app.InstanceType = FediverseInstanceType.MisskeyApi;
|
app.InstanceType = FediverseInstanceType.MisskeyApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,7 @@ public partial class FediverseAuthService
|
||||||
|
|
||||||
private async Task<FediverseUser> GetMisskeyUserAsync(FediverseApplication app, string code)
|
private async Task<FediverseUser> GetMisskeyUserAsync(FediverseApplication app, string code)
|
||||||
{
|
{
|
||||||
HttpResponseMessage resp = await _client.PostAsJsonAsync(
|
HttpResponseMessage resp = await client.PostAsJsonAsync(
|
||||||
MisskeyTokenUri(app.Domain),
|
MisskeyTokenUri(app.Domain),
|
||||||
new GetMisskeySessionUserKeyRequest(app.ClientSecret, code)
|
new GetMisskeySessionUserKeyRequest(app.ClientSecret, code)
|
||||||
);
|
);
|
||||||
|
@ -130,7 +130,7 @@ public partial class FediverseAuthService
|
||||||
app = await CreateMisskeyApplicationAsync(app.Domain, app.Id);
|
app = await CreateMisskeyApplicationAsync(app.Domain, app.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponseMessage resp = await _client.PostAsJsonAsync(
|
HttpResponseMessage resp = await client.PostAsJsonAsync(
|
||||||
MisskeyGenerateSessionUri(app.Domain),
|
MisskeyGenerateSessionUri(app.Domain),
|
||||||
new CreateMisskeySessionUriRequest(app.ClientSecret)
|
new CreateMisskeySessionUriRequest(app.ClientSecret)
|
||||||
);
|
);
|
||||||
|
|
|
@ -19,37 +19,17 @@ using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services.Auth;
|
namespace Foxnouns.Backend.Services.Auth;
|
||||||
|
|
||||||
public partial class FediverseAuthService
|
public partial class FediverseAuthService(
|
||||||
{
|
|
||||||
private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0";
|
|
||||||
|
|
||||||
private readonly HttpClient _client;
|
|
||||||
private readonly ILogger _logger;
|
|
||||||
private readonly Config _config;
|
|
||||||
private readonly DatabaseContext _db;
|
|
||||||
private readonly KeyCacheService _keyCacheService;
|
|
||||||
private readonly ISnowflakeGenerator _snowflakeGenerator;
|
|
||||||
|
|
||||||
public FediverseAuthService(
|
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
Config config,
|
Config config,
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
|
HttpClient client,
|
||||||
KeyCacheService keyCacheService,
|
KeyCacheService keyCacheService,
|
||||||
ISnowflakeGenerator snowflakeGenerator
|
ISnowflakeGenerator snowflakeGenerator
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_logger = logger.ForContext<FediverseAuthService>();
|
private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0";
|
||||||
_config = config;
|
private readonly ILogger _logger = logger.ForContext<FediverseAuthService>();
|
||||||
_db = db;
|
|
||||||
_keyCacheService = keyCacheService;
|
|
||||||
_snowflakeGenerator = snowflakeGenerator;
|
|
||||||
|
|
||||||
_client = new HttpClient();
|
|
||||||
_client.DefaultRequestHeaders.Remove("User-Agent");
|
|
||||||
_client.DefaultRequestHeaders.Remove("Accept");
|
|
||||||
_client.DefaultRequestHeaders.Add("User-Agent", $"pronouns.cc/{BuildInfo.Version}");
|
|
||||||
_client.DefaultRequestHeaders.Add("Accept", "application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GenerateAuthUrlAsync(
|
public async Task<string> GenerateAuthUrlAsync(
|
||||||
string instance,
|
string instance,
|
||||||
|
@ -70,7 +50,7 @@ public partial class FediverseAuthService
|
||||||
|
|
||||||
public async Task<FediverseApplication> GetApplicationAsync(string instance)
|
public async Task<FediverseApplication> GetApplicationAsync(string instance)
|
||||||
{
|
{
|
||||||
FediverseApplication? app = await _db.FediverseApplications.FirstOrDefaultAsync(a =>
|
FediverseApplication? app = await db.FediverseApplications.FirstOrDefaultAsync(a =>
|
||||||
a.Domain == instance
|
a.Domain == instance
|
||||||
);
|
);
|
||||||
if (app != null)
|
if (app != null)
|
||||||
|
@ -92,7 +72,7 @@ public partial class FediverseAuthService
|
||||||
{
|
{
|
||||||
_logger.Debug("Requesting software name for fediverse instance {Instance}", instance);
|
_logger.Debug("Requesting software name for fediverse instance {Instance}", instance);
|
||||||
|
|
||||||
HttpResponseMessage wellKnownResp = await _client.GetAsync(
|
HttpResponseMessage wellKnownResp = await client.GetAsync(
|
||||||
new Uri($"https://{instance}/.well-known/nodeinfo")
|
new Uri($"https://{instance}/.well-known/nodeinfo")
|
||||||
);
|
);
|
||||||
wellKnownResp.EnsureSuccessStatusCode();
|
wellKnownResp.EnsureSuccessStatusCode();
|
||||||
|
@ -107,7 +87,7 @@ public partial class FediverseAuthService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponseMessage nodeInfoResp = await _client.GetAsync(nodeInfoUrl);
|
HttpResponseMessage nodeInfoResp = await client.GetAsync(nodeInfoUrl);
|
||||||
nodeInfoResp.EnsureSuccessStatusCode();
|
nodeInfoResp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
PartialNodeInfo? nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync<PartialNodeInfo>();
|
PartialNodeInfo? nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync<PartialNodeInfo>();
|
||||||
|
|
|
@ -27,7 +27,7 @@ public partial class RemoteAuthService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
|
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
|
||||||
HttpResponseMessage resp = await _httpClient.PostAsync(
|
HttpResponseMessage resp = await client.PostAsync(
|
||||||
_discordTokenUri,
|
_discordTokenUri,
|
||||||
new FormUrlEncodedContent(
|
new FormUrlEncodedContent(
|
||||||
new Dictionary<string, string>
|
new Dictionary<string, string>
|
||||||
|
@ -59,7 +59,7 @@ public partial class RemoteAuthService
|
||||||
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
|
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
|
||||||
req.Headers.Add("Authorization", $"{token.TokenType} {token.AccessToken}");
|
req.Headers.Add("Authorization", $"{token.TokenType} {token.AccessToken}");
|
||||||
|
|
||||||
HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
|
HttpResponseMessage resp2 = await client.SendAsync(req, ct);
|
||||||
resp2.EnsureSuccessStatusCode();
|
resp2.EnsureSuccessStatusCode();
|
||||||
DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
|
|
|
@ -28,7 +28,7 @@ public partial class RemoteAuthService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var redirectUri = $"{config.BaseUrl}/auth/callback/google";
|
var redirectUri = $"{config.BaseUrl}/auth/callback/google";
|
||||||
HttpResponseMessage resp = await _httpClient.PostAsync(
|
HttpResponseMessage resp = await client.PostAsync(
|
||||||
_googleTokenUri,
|
_googleTokenUri,
|
||||||
new FormUrlEncodedContent(
|
new FormUrlEncodedContent(
|
||||||
new Dictionary<string, string>
|
new Dictionary<string, string>
|
||||||
|
|
|
@ -29,7 +29,7 @@ public partial class RemoteAuthService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var redirectUri = $"{config.BaseUrl}/auth/callback/tumblr";
|
var redirectUri = $"{config.BaseUrl}/auth/callback/tumblr";
|
||||||
HttpResponseMessage resp = await _httpClient.PostAsync(
|
HttpResponseMessage resp = await client.PostAsync(
|
||||||
_tumblrTokenUri,
|
_tumblrTokenUri,
|
||||||
new FormUrlEncodedContent(
|
new FormUrlEncodedContent(
|
||||||
new Dictionary<string, string>
|
new Dictionary<string, string>
|
||||||
|
@ -62,7 +62,7 @@ public partial class RemoteAuthService
|
||||||
var req = new HttpRequestMessage(HttpMethod.Get, _tumblrUserUri);
|
var req = new HttpRequestMessage(HttpMethod.Get, _tumblrUserUri);
|
||||||
req.Headers.Add("Authorization", $"Bearer {token.AccessToken}");
|
req.Headers.Add("Authorization", $"Bearer {token.AccessToken}");
|
||||||
|
|
||||||
HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
|
HttpResponseMessage resp2 = await client.SendAsync(req, ct);
|
||||||
if (!resp2.IsSuccessStatusCode)
|
if (!resp2.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
string respBody = await resp2.Content.ReadAsStringAsync(ct);
|
string respBody = await resp2.Content.ReadAsStringAsync(ct);
|
||||||
|
|
|
@ -25,6 +25,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
namespace Foxnouns.Backend.Services.Auth;
|
namespace Foxnouns.Backend.Services.Auth;
|
||||||
|
|
||||||
public partial class RemoteAuthService(
|
public partial class RemoteAuthService(
|
||||||
|
HttpClient client,
|
||||||
Config config,
|
Config config,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
|
@ -32,7 +33,6 @@ public partial class RemoteAuthService(
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
|
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
|
||||||
private readonly HttpClient _httpClient = new();
|
|
||||||
|
|
||||||
public record RemoteUser(string Id, string Username);
|
public record RemoteUser(string Id, string Username);
|
||||||
|
|
||||||
|
|
39
Foxnouns.Backend/Services/Caching/NoticeCacheService.cs
Normal file
39
Foxnouns.Backend/Services/Caching/NoticeCacheService.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
// 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 Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Services.Caching;
|
||||||
|
|
||||||
|
public class NoticeCacheService(IServiceProvider serviceProvider, IClock clock, ILogger logger)
|
||||||
|
: SingletonCacheService<Notice>(serviceProvider, clock, logger)
|
||||||
|
{
|
||||||
|
public override Duration MaxAge { get; init; } = Duration.FromMinutes(5);
|
||||||
|
|
||||||
|
public override Func<
|
||||||
|
DatabaseContext,
|
||||||
|
CancellationToken,
|
||||||
|
Task<Notice?>
|
||||||
|
> FetchFunc { get; init; } =
|
||||||
|
async (db, ct) =>
|
||||||
|
await db
|
||||||
|
.Notices.Where(n =>
|
||||||
|
n.StartTime < clock.GetCurrentInstant() && n.EndTime > clock.GetCurrentInstant()
|
||||||
|
)
|
||||||
|
.OrderByDescending(n => n.Id)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
63
Foxnouns.Backend/Services/Caching/SingletonCacheService.cs
Normal file
63
Foxnouns.Backend/Services/Caching/SingletonCacheService.cs
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// 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 NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Services.Caching;
|
||||||
|
|
||||||
|
public abstract class SingletonCacheService<T>(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IClock clock,
|
||||||
|
ILogger logger
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
private T? _item;
|
||||||
|
private Instant _lastUpdated = Instant.MinValue;
|
||||||
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||||
|
private readonly ILogger _logger = logger.ForContext<SingletonCacheService<T>>();
|
||||||
|
|
||||||
|
public virtual Duration MaxAge { get; init; } = Duration.FromMinutes(5);
|
||||||
|
|
||||||
|
public virtual Func<DatabaseContext, CancellationToken, Task<T?>> FetchFunc { get; init; } =
|
||||||
|
(_, __) => Task.FromResult<T?>(null);
|
||||||
|
|
||||||
|
public async Task<T?> GetAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _semaphore.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_lastUpdated > clock.GetCurrentInstant() - MaxAge)
|
||||||
|
{
|
||||||
|
return _item;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Debug("Cached item of type {Type} is expired, fetching it.", typeof(T));
|
||||||
|
|
||||||
|
await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope();
|
||||||
|
await using DatabaseContext db =
|
||||||
|
scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||||
|
|
||||||
|
T? item = await FetchFunc(db, ct);
|
||||||
|
_item = item;
|
||||||
|
_lastUpdated = clock.GetCurrentInstant();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_semaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -128,5 +128,5 @@ public class DataCleanupService(
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExportPath(Snowflake userId, string b64) =>
|
private static string ExportPath(Snowflake userId, string b64) =>
|
||||||
$"data-exports/{userId}/{b64}.zip";
|
$"data-exports/{userId}/{b64}/data-export.zip";
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,8 +23,11 @@ public class EmailRateLimiter
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new();
|
private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new();
|
||||||
|
|
||||||
private readonly FixedWindowRateLimiterOptions _limiterOptions =
|
private readonly FixedWindowRateLimiterOptions _limiterOptions = new()
|
||||||
new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 };
|
{
|
||||||
|
Window = TimeSpan.FromHours(2),
|
||||||
|
PermitLimit = 3,
|
||||||
|
};
|
||||||
|
|
||||||
private RateLimiter GetLimiter(string bucket) =>
|
private RateLimiter GetLimiter(string bucket) =>
|
||||||
_limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions));
|
_limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions));
|
||||||
|
|
|
@ -17,94 +17,39 @@ using Foxnouns.Backend.Database.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
public class KeyCacheService(Config config)
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<KeyCacheService>();
|
public ConnectionMultiplexer Multiplexer { get; } =
|
||||||
|
ConnectionMultiplexer.Connect(config.Database.Redis);
|
||||||
|
|
||||||
public Task SetKeyAsync(
|
public async Task SetKeyAsync(string key, string value, Duration expireAfter) =>
|
||||||
string key,
|
await Multiplexer
|
||||||
string value,
|
.GetDatabase()
|
||||||
Duration expireAfter,
|
.StringSetAsync(key, value, expiry: expireAfter.ToTimeSpan());
|
||||||
CancellationToken ct = default
|
|
||||||
) => SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct);
|
|
||||||
|
|
||||||
public async Task SetKeyAsync(
|
public async Task<string?> GetKeyAsync(string key, bool delete = false) =>
|
||||||
string key,
|
delete
|
||||||
string value,
|
? await Multiplexer.GetDatabase().StringGetDeleteAsync(key)
|
||||||
Instant expires,
|
: await Multiplexer.GetDatabase().StringGetAsync(key);
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
db.TemporaryKeys.Add(
|
|
||||||
new TemporaryKey
|
|
||||||
{
|
|
||||||
Expires = expires,
|
|
||||||
Key = key,
|
|
||||||
Value = value,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string?> GetKeyAsync(
|
public async Task DeleteKeyAsync(string key) =>
|
||||||
string key,
|
await Multiplexer.GetDatabase().KeyDeleteAsync(key);
|
||||||
bool delete = false,
|
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
TemporaryKey? value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct);
|
|
||||||
if (value == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (delete)
|
public async Task SetKeyAsync<T>(string key, T obj, Duration expiresAt)
|
||||||
await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
|
|
||||||
|
|
||||||
return value.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteKeyAsync(string key, CancellationToken ct = default) =>
|
|
||||||
await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
|
|
||||||
|
|
||||||
public async Task DeleteExpiredKeysAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
int count = await db
|
|
||||||
.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant())
|
|
||||||
.ExecuteDeleteAsync(ct);
|
|
||||||
if (count != 0)
|
|
||||||
_logger.Information("Removed {Count} expired keys from the database", count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SetKeyAsync<T>(
|
|
||||||
string key,
|
|
||||||
T obj,
|
|
||||||
Duration expiresAt,
|
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct);
|
|
||||||
|
|
||||||
public async Task SetKeyAsync<T>(
|
|
||||||
string key,
|
|
||||||
T obj,
|
|
||||||
Instant expires,
|
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
string value = JsonConvert.SerializeObject(obj);
|
string value = JsonConvert.SerializeObject(obj);
|
||||||
await SetKeyAsync(key, value, expires, ct);
|
await SetKeyAsync(key, value, expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<T?> GetKeyAsync<T>(
|
public async Task<T?> GetKeyAsync<T>(string key, bool delete = false)
|
||||||
string key,
|
|
||||||
bool delete = false,
|
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
string? value = await GetKeyAsync(key, delete, ct);
|
string? value = await GetKeyAsync(key, delete);
|
||||||
return value == null ? default : JsonConvert.DeserializeObject<T>(value);
|
return value == null ? default : JsonConvert.DeserializeObject<T>(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,7 @@ public class ModerationRendererService(
|
||||||
: null,
|
: null,
|
||||||
report.Status,
|
report.Status,
|
||||||
report.Reason,
|
report.Reason,
|
||||||
|
report.Context,
|
||||||
report.TargetType,
|
report.TargetType,
|
||||||
report.TargetSnapshot != null
|
report.TargetSnapshot != null
|
||||||
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)
|
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)
|
||||||
|
@ -45,12 +46,26 @@ public class ModerationRendererService(
|
||||||
|
|
||||||
public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
|
public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
|
||||||
{
|
{
|
||||||
|
PartialReport? report = null;
|
||||||
|
if (entry.Report != null)
|
||||||
|
{
|
||||||
|
report = new PartialReport(
|
||||||
|
entry.Report.Id,
|
||||||
|
entry.Report.ReporterId,
|
||||||
|
entry.Report.TargetUserId,
|
||||||
|
entry.Report.TargetMemberId,
|
||||||
|
entry.Report.Reason,
|
||||||
|
entry.Report.Context,
|
||||||
|
entry.Report.TargetType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new AuditLogResponse(
|
return new AuditLogResponse(
|
||||||
Id: entry.Id,
|
Id: entry.Id,
|
||||||
Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
|
Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
|
||||||
TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
|
TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
|
||||||
TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
|
TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
|
||||||
ReportId: entry.ReportId,
|
Report: report,
|
||||||
Type: entry.Type,
|
Type: entry.Type,
|
||||||
Reason: entry.Reason,
|
Reason: entry.Reason,
|
||||||
ClearedFields: entry.ClearedFields
|
ClearedFields: entry.ClearedFields
|
||||||
|
|
|
@ -18,6 +18,7 @@ using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Dto;
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Humanizer;
|
using Humanizer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services;
|
||||||
|
@ -26,7 +27,6 @@ public class ModerationService(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
IQueue queue,
|
|
||||||
IClock clock
|
IClock clock
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -63,6 +63,54 @@ public class ModerationService(
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AuditLogEntry> QuerySensitiveDataAsync(
|
||||||
|
User moderator,
|
||||||
|
User target,
|
||||||
|
string reason
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Information(
|
||||||
|
"Moderator {ModeratorId} is querying sensitive data for {TargetId}",
|
||||||
|
moderator.Id,
|
||||||
|
target.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
var entry = new AuditLogEntry
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ModeratorId = moderator.Id,
|
||||||
|
ModeratorUsername = moderator.Username,
|
||||||
|
TargetUserId = target.Id,
|
||||||
|
TargetUsername = target.Username,
|
||||||
|
Type = AuditLogEntryType.QuerySensitiveUserData,
|
||||||
|
Reason = reason,
|
||||||
|
};
|
||||||
|
db.AuditLog.Add(entry);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ShowSensitiveDataAsync(
|
||||||
|
User moderator,
|
||||||
|
User target,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Snowflake cutoff = snowflakeGenerator.GenerateSnowflake(
|
||||||
|
clock.GetCurrentInstant() - Duration.FromDays(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return await db.AuditLog.AnyAsync(
|
||||||
|
e =>
|
||||||
|
e.ModeratorId == moderator.Id
|
||||||
|
&& e.TargetUserId == target.Id
|
||||||
|
&& e.Type == AuditLogEntryType.QuerySensitiveUserData
|
||||||
|
&& e.Id > cutoff,
|
||||||
|
ct
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AuditLogEntry> ExecuteSuspensionAsync(
|
public async Task<AuditLogEntry> ExecuteSuspensionAsync(
|
||||||
User moderator,
|
User moderator,
|
||||||
User target,
|
User target,
|
||||||
|
@ -105,6 +153,12 @@ public class ModerationService(
|
||||||
target.DeletedAt = clock.GetCurrentInstant();
|
target.DeletedAt = clock.GetCurrentInstant();
|
||||||
target.DeletedBy = moderator.Id;
|
target.DeletedBy = moderator.Id;
|
||||||
|
|
||||||
|
if (report != null)
|
||||||
|
{
|
||||||
|
report.Status = ReportStatus.Closed;
|
||||||
|
db.Update(report);
|
||||||
|
}
|
||||||
|
|
||||||
if (!clearProfile)
|
if (!clearProfile)
|
||||||
{
|
{
|
||||||
db.Update(target);
|
db.Update(target);
|
||||||
|
@ -126,9 +180,7 @@ public class ModerationService(
|
||||||
target.CustomPreferences = [];
|
target.CustomPreferences = [];
|
||||||
target.ProfileFlags = [];
|
target.ProfileFlags = [];
|
||||||
|
|
||||||
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(target.Id, null));
|
||||||
new AvatarUpdatePayload(target.Id, null)
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: also clear member profiles?
|
// TODO: also clear member profiles?
|
||||||
|
|
||||||
|
@ -209,10 +261,9 @@ public class ModerationService(
|
||||||
targetMember.DisplayName = null;
|
targetMember.DisplayName = null;
|
||||||
break;
|
break;
|
||||||
case FieldsToClear.Avatar:
|
case FieldsToClear.Avatar:
|
||||||
queue.QueueInvocableWithPayload<
|
MemberAvatarUpdateJob.Enqueue(
|
||||||
MemberAvatarUpdateInvocable,
|
new AvatarUpdatePayload(targetMember.Id, null)
|
||||||
AvatarUpdatePayload
|
);
|
||||||
>(new AvatarUpdatePayload(targetMember.Id, null));
|
|
||||||
break;
|
break;
|
||||||
case FieldsToClear.Bio:
|
case FieldsToClear.Bio:
|
||||||
targetMember.Bio = null;
|
targetMember.Bio = null;
|
||||||
|
@ -251,10 +302,7 @@ public class ModerationService(
|
||||||
targetUser.DisplayName = null;
|
targetUser.DisplayName = null;
|
||||||
break;
|
break;
|
||||||
case FieldsToClear.Avatar:
|
case FieldsToClear.Avatar:
|
||||||
queue.QueueInvocableWithPayload<
|
UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(targetUser.Id, null));
|
||||||
UserAvatarUpdateInvocable,
|
|
||||||
AvatarUpdatePayload
|
|
||||||
>(new AvatarUpdatePayload(targetUser.Id, null));
|
|
||||||
break;
|
break;
|
||||||
case FieldsToClear.Bio:
|
case FieldsToClear.Bio:
|
||||||
targetUser.Bio = null;
|
targetUser.Bio = null;
|
||||||
|
@ -285,6 +333,12 @@ public class ModerationService(
|
||||||
db.Update(targetUser);
|
db.Update(targetUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (report != null)
|
||||||
|
{
|
||||||
|
report.Status = ReportStatus.Closed;
|
||||||
|
db.Update(report);
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
|
|
|
@ -33,11 +33,9 @@ public class PeriodicTasksService(ILogger logger, IServiceProvider services) : B
|
||||||
|
|
||||||
// The type is literally written on the same line, we can just use `var`
|
// The type is literally written on the same line, we can just use `var`
|
||||||
// ReSharper disable SuggestVarOrType_SimpleTypes
|
// ReSharper disable SuggestVarOrType_SimpleTypes
|
||||||
var keyCacheService = scope.ServiceProvider.GetRequiredService<KeyCacheService>();
|
|
||||||
var dataCleanupService = scope.ServiceProvider.GetRequiredService<DataCleanupService>();
|
var dataCleanupService = scope.ServiceProvider.GetRequiredService<DataCleanupService>();
|
||||||
// ReSharper restore SuggestVarOrType_SimpleTypes
|
// ReSharper restore SuggestVarOrType_SimpleTypes
|
||||||
|
|
||||||
await keyCacheService.DeleteExpiredKeysAsync(ct);
|
|
||||||
await dataCleanupService.InvokeAsync(ct);
|
await dataCleanupService.InvokeAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ public class UserRendererService(
|
||||||
bool renderMembers = true,
|
bool renderMembers = true,
|
||||||
bool renderAuthMethods = false,
|
bool renderAuthMethods = false,
|
||||||
string? overrideSid = null,
|
string? overrideSid = null,
|
||||||
|
bool renderSettings = false,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
) =>
|
) =>
|
||||||
await RenderUserInnerAsync(
|
await RenderUserInnerAsync(
|
||||||
|
@ -42,6 +43,7 @@ public class UserRendererService(
|
||||||
renderMembers,
|
renderMembers,
|
||||||
renderAuthMethods,
|
renderAuthMethods,
|
||||||
overrideSid,
|
overrideSid,
|
||||||
|
renderSettings,
|
||||||
ct
|
ct
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -52,6 +54,7 @@ public class UserRendererService(
|
||||||
bool renderMembers = true,
|
bool renderMembers = true,
|
||||||
bool renderAuthMethods = false,
|
bool renderAuthMethods = false,
|
||||||
string? overrideSid = null,
|
string? overrideSid = null,
|
||||||
|
bool renderSettings = false,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -62,6 +65,7 @@ public class UserRendererService(
|
||||||
|
|
||||||
renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers);
|
renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers);
|
||||||
renderAuthMethods = renderAuthMethods && tokenPrivileged;
|
renderAuthMethods = renderAuthMethods && tokenPrivileged;
|
||||||
|
renderSettings = renderSettings && tokenHidden;
|
||||||
|
|
||||||
IEnumerable<Member> members = renderMembers
|
IEnumerable<Member> members = renderMembers
|
||||||
? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct)
|
? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct)
|
||||||
|
@ -103,7 +107,8 @@ public class UserRendererService(
|
||||||
user.Names,
|
user.Names,
|
||||||
user.Pronouns,
|
user.Pronouns,
|
||||||
user.Fields,
|
user.Fields,
|
||||||
user.CustomPreferences,
|
user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value)))
|
||||||
|
.ToDictionary(),
|
||||||
flags.Select(f => RenderPrideFlag(f.PrideFlag)),
|
flags.Select(f => RenderPrideFlag(f.PrideFlag)),
|
||||||
utcOffset,
|
utcOffset,
|
||||||
user.Role,
|
user.Role,
|
||||||
|
@ -116,7 +121,8 @@ public class UserRendererService(
|
||||||
tokenHidden ? user.LastSidReroll : null,
|
tokenHidden ? user.LastSidReroll : null,
|
||||||
tokenHidden ? user.Timezone ?? "<none>" : null,
|
tokenHidden ? user.Timezone ?? "<none>" : null,
|
||||||
tokenHidden ? user is { Deleted: true, DeletedBy: not null } : null,
|
tokenHidden ? user is { Deleted: true, DeletedBy: not null } : null,
|
||||||
tokenHidden ? user.Deleted : null
|
tokenHidden ? user.Deleted : null,
|
||||||
|
renderSettings ? user.Settings : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,6 +136,14 @@ public class UserRendererService(
|
||||||
: a.RemoteUsername
|
: a.RemoteUsername
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static CustomPreferenceResponse RenderCustomPreference(User.CustomPreference pref) =>
|
||||||
|
new(pref.Icon, pref.Tooltip, pref.Muted, pref.Favourite, pref.Size);
|
||||||
|
|
||||||
|
public static Dictionary<Snowflake, CustomPreferenceResponse> RenderCustomPreferences(
|
||||||
|
User user
|
||||||
|
) =>
|
||||||
|
user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value))).ToDictionary();
|
||||||
|
|
||||||
public PartialUser RenderPartialUser(User user) =>
|
public PartialUser RenderPartialUser(User user) =>
|
||||||
new(
|
new(
|
||||||
user.Id,
|
user.Id,
|
||||||
|
|
125
Foxnouns.Backend/Services/V1/MembersV1Service.cs
Normal file
125
Foxnouns.Backend/Services/V1/MembersV1Service.cs
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// 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 Microsoft.EntityFrameworkCore;
|
||||||
|
using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry;
|
||||||
|
using PrideFlag = Foxnouns.Backend.Dto.V1.PrideFlag;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Services.V1;
|
||||||
|
|
||||||
|
public class MembersV1Service(DatabaseContext db, UsersV1Service usersV1Service)
|
||||||
|
{
|
||||||
|
public async Task<Member> ResolveMemberAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Member? member;
|
||||||
|
if (Snowflake.TryParse(id, out Snowflake? sf))
|
||||||
|
{
|
||||||
|
member = await db
|
||||||
|
.Members.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == sf && !m.User.Deleted, ct);
|
||||||
|
if (member != null)
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
member = await db
|
||||||
|
.Members.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(m => m.LegacyId == id && !m.User.Deleted, ct);
|
||||||
|
if (member != null)
|
||||||
|
return member;
|
||||||
|
|
||||||
|
throw new ApiError.NotFound("No member with that ID found.", ErrorCode.MemberNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Member> ResolveMemberAsync(
|
||||||
|
string userRef,
|
||||||
|
string memberRef,
|
||||||
|
Token? token,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
User user = await usersV1Service.ResolveUserAsync(userRef, token, ct);
|
||||||
|
|
||||||
|
Member? member;
|
||||||
|
if (Snowflake.TryParse(memberRef, out Snowflake? sf))
|
||||||
|
{
|
||||||
|
member = await db
|
||||||
|
.Members.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == sf && m.UserId == user.Id, ct);
|
||||||
|
if (member != null)
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
member = await db
|
||||||
|
.Members.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(m => m.LegacyId == memberRef && m.UserId == user.Id, ct);
|
||||||
|
if (member != null)
|
||||||
|
return member;
|
||||||
|
|
||||||
|
member = await db
|
||||||
|
.Members.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == user.Id, ct);
|
||||||
|
if (member != null)
|
||||||
|
return member;
|
||||||
|
|
||||||
|
throw new ApiError.NotFound(
|
||||||
|
"No member with that ID or name found.",
|
||||||
|
ErrorCode.MemberNotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MemberResponse> RenderMemberAsync(
|
||||||
|
Member m,
|
||||||
|
Token? token = default,
|
||||||
|
User? user = null,
|
||||||
|
bool renderFlags = true,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
user ??= m.User;
|
||||||
|
bool renderUnlisted = m.UserId == token?.UserId;
|
||||||
|
|
||||||
|
List<MemberFlag> flags = renderFlags
|
||||||
|
? await db.MemberFlags.Where(f => f.MemberId == m.Id).OrderBy(f => f.Id).ToListAsync(ct)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return new MemberResponse(
|
||||||
|
m.LegacyId,
|
||||||
|
m.Id,
|
||||||
|
m.Sid,
|
||||||
|
m.Name,
|
||||||
|
m.DisplayName,
|
||||||
|
m.Bio,
|
||||||
|
m.Avatar,
|
||||||
|
m.Links,
|
||||||
|
Names: FieldEntry.FromEntries(m.Names, user.CustomPreferences),
|
||||||
|
Pronouns: PronounEntry.FromPronouns(m.Pronouns, user.CustomPreferences),
|
||||||
|
Fields: ProfileField.FromFields(m.Fields, user.CustomPreferences),
|
||||||
|
Flags: flags
|
||||||
|
.Where(f => f.PrideFlag.Hash != null)
|
||||||
|
.Select(f => new PrideFlag(
|
||||||
|
f.PrideFlag.LegacyId,
|
||||||
|
f.PrideFlag.Id,
|
||||||
|
f.PrideFlag.Hash!,
|
||||||
|
f.PrideFlag.Name,
|
||||||
|
f.PrideFlag.Description
|
||||||
|
))
|
||||||
|
.ToArray(),
|
||||||
|
User: UsersV1Service.RenderPartialUser(user),
|
||||||
|
Unlisted: renderUnlisted ? m.Unlisted : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
247
Foxnouns.Backend/Services/V1/UsersV1Service.cs
Normal file
247
Foxnouns.Backend/Services/V1/UsersV1Service.cs
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
// 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 Microsoft.EntityFrameworkCore;
|
||||||
|
using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry;
|
||||||
|
using PrideFlag = Foxnouns.Backend.Dto.V1.PrideFlag;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Services.V1;
|
||||||
|
|
||||||
|
public class UsersV1Service(DatabaseContext db)
|
||||||
|
{
|
||||||
|
public async Task<User> ResolveUserAsync(
|
||||||
|
string userRef,
|
||||||
|
Token? token,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (userRef == "@me")
|
||||||
|
{
|
||||||
|
if (token == null)
|
||||||
|
{
|
||||||
|
throw new ApiError.Unauthorized(
|
||||||
|
"This endpoint requires an authenticated user.",
|
||||||
|
ErrorCode.AuthenticationRequired
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.Users.FirstAsync(u => u.Id == token.UserId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
User? user;
|
||||||
|
if (Snowflake.TryParse(userRef, out Snowflake? sf))
|
||||||
|
{
|
||||||
|
user = await db.Users.FirstOrDefaultAsync(u => u.Id == sf && !u.Deleted, ct);
|
||||||
|
if (user != null)
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await db.Users.FirstOrDefaultAsync(u => u.LegacyId == userRef && !u.Deleted, ct);
|
||||||
|
if (user != null)
|
||||||
|
return user;
|
||||||
|
|
||||||
|
user = await db.Users.FirstOrDefaultAsync(u => u.Username == userRef && !u.Deleted, ct);
|
||||||
|
if (user != null)
|
||||||
|
return user;
|
||||||
|
|
||||||
|
throw new ApiError.NotFound(
|
||||||
|
"No user with that ID or username found.",
|
||||||
|
ErrorCode.UserNotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserResponse> RenderUserAsync(
|
||||||
|
User user,
|
||||||
|
Token? token = null,
|
||||||
|
bool renderMembers = true,
|
||||||
|
bool renderFlags = true,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
bool isSelfUser = user.Id == token?.UserId;
|
||||||
|
renderMembers = renderMembers && (isSelfUser || !user.ListHidden);
|
||||||
|
|
||||||
|
// Only fetch members if we're rendering members (duh)
|
||||||
|
List<Member> members = renderMembers
|
||||||
|
? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
List<UserFlag> flags = renderFlags
|
||||||
|
? await db.UserFlags.Where(f => f.UserId == user.Id).OrderBy(f => f.Id).ToListAsync(ct)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
int? utcOffset = null;
|
||||||
|
if (
|
||||||
|
user.Timezone != null
|
||||||
|
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserResponse(
|
||||||
|
user.LegacyId,
|
||||||
|
user.Id,
|
||||||
|
user.Sid,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Bio,
|
||||||
|
user.MemberTitle,
|
||||||
|
user.Avatar,
|
||||||
|
user.Links,
|
||||||
|
Names: FieldEntry.FromEntries(user.Names, user.CustomPreferences),
|
||||||
|
Pronouns: PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences),
|
||||||
|
Fields: ProfileField.FromFields(user.Fields, user.CustomPreferences),
|
||||||
|
Flags: flags
|
||||||
|
.Where(f => f.PrideFlag.Hash != null)
|
||||||
|
.Select(f => new PrideFlag(
|
||||||
|
f.PrideFlag.LegacyId,
|
||||||
|
f.PrideFlag.Id,
|
||||||
|
f.PrideFlag.Hash!,
|
||||||
|
f.PrideFlag.Name,
|
||||||
|
f.PrideFlag.Description
|
||||||
|
))
|
||||||
|
.ToArray(),
|
||||||
|
Members: members.Select(m => RenderPartialMember(m, user.CustomPreferences)).ToArray(),
|
||||||
|
utcOffset,
|
||||||
|
CustomPreferences: RenderCustomPreferences(user.CustomPreferences)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CurrentUserResponse> RenderCurrentUserAsync(
|
||||||
|
User user,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
List<Member> members = await db
|
||||||
|
.Members.Where(m => m.UserId == user.Id)
|
||||||
|
.OrderBy(m => m.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
List<UserFlag> flags = await db
|
||||||
|
.UserFlags.Where(f => f.UserId == user.Id)
|
||||||
|
.OrderBy(f => f.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
int? utcOffset = null;
|
||||||
|
if (
|
||||||
|
user.Timezone != null
|
||||||
|
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AuthMethod> authMethods = await db
|
||||||
|
.AuthMethods.Include(a => a.FediverseApplication)
|
||||||
|
.Where(a => a.UserId == user.Id)
|
||||||
|
.OrderBy(a => a.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
AuthMethod? discord = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Discord);
|
||||||
|
AuthMethod? google = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Google);
|
||||||
|
AuthMethod? tumblr = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Tumblr);
|
||||||
|
AuthMethod? fediverse = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Fediverse);
|
||||||
|
|
||||||
|
return new CurrentUserResponse(
|
||||||
|
user.LegacyId,
|
||||||
|
user.Id,
|
||||||
|
user.Sid,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Bio,
|
||||||
|
user.MemberTitle,
|
||||||
|
user.Avatar,
|
||||||
|
user.Links,
|
||||||
|
Names: FieldEntry.FromEntries(user.Names, user.CustomPreferences),
|
||||||
|
Pronouns: PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences),
|
||||||
|
Fields: ProfileField.FromFields(user.Fields, user.CustomPreferences),
|
||||||
|
Flags: flags
|
||||||
|
.Where(f => f.PrideFlag.Hash != null)
|
||||||
|
.Select(f => new PrideFlag(
|
||||||
|
f.PrideFlag.LegacyId,
|
||||||
|
f.PrideFlag.Id,
|
||||||
|
f.PrideFlag.Hash!,
|
||||||
|
f.PrideFlag.Name,
|
||||||
|
f.PrideFlag.Description
|
||||||
|
))
|
||||||
|
.ToArray(),
|
||||||
|
Members: members.Select(m => RenderPartialMember(m, user.CustomPreferences)).ToArray(),
|
||||||
|
utcOffset,
|
||||||
|
CustomPreferences: RenderCustomPreferences(user.CustomPreferences),
|
||||||
|
user.Id.Time,
|
||||||
|
user.Timezone,
|
||||||
|
user.Role is UserRole.Admin,
|
||||||
|
user.ListHidden,
|
||||||
|
user.LastSidReroll,
|
||||||
|
discord?.RemoteId,
|
||||||
|
discord?.RemoteUsername,
|
||||||
|
google?.RemoteId,
|
||||||
|
google?.RemoteUsername,
|
||||||
|
tumblr?.RemoteId,
|
||||||
|
tumblr?.RemoteUsername,
|
||||||
|
fediverse?.RemoteId,
|
||||||
|
fediverse?.RemoteUsername,
|
||||||
|
fediverse?.FediverseApplication?.Domain
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<Guid, CustomPreference> RenderCustomPreferences(
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
|
) =>
|
||||||
|
customPreferences
|
||||||
|
.Select(x =>
|
||||||
|
(
|
||||||
|
x.Value.LegacyId,
|
||||||
|
new CustomPreference(
|
||||||
|
x.Value.Icon,
|
||||||
|
x.Value.Tooltip,
|
||||||
|
x.Value.Size,
|
||||||
|
x.Value.Muted,
|
||||||
|
x.Value.Favourite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ToDictionary();
|
||||||
|
|
||||||
|
private static PartialMember RenderPartialMember(
|
||||||
|
Member m,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
|
) =>
|
||||||
|
new(
|
||||||
|
m.LegacyId,
|
||||||
|
m.Id,
|
||||||
|
m.Sid,
|
||||||
|
m.Name,
|
||||||
|
m.DisplayName,
|
||||||
|
m.Bio,
|
||||||
|
m.Avatar,
|
||||||
|
m.Links,
|
||||||
|
Names: FieldEntry.FromEntries(m.Names, customPreferences),
|
||||||
|
Pronouns: PronounEntry.FromPronouns(m.Pronouns, customPreferences)
|
||||||
|
);
|
||||||
|
|
||||||
|
public static PartialUser RenderPartialUser(User user) =>
|
||||||
|
new(
|
||||||
|
user.LegacyId,
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Avatar,
|
||||||
|
CustomPreferences: RenderCustomPreferences(user.CustomPreferences)
|
||||||
|
);
|
||||||
|
}
|
|
@ -12,14 +12,23 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using NodaTime;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Database.Models;
|
namespace Foxnouns.Backend.Services.V1;
|
||||||
|
|
||||||
public class TemporaryKey
|
public static class V1Utils
|
||||||
{
|
{
|
||||||
public long Id { get; init; }
|
public static string TranslateStatus(
|
||||||
public required string Key { get; init; }
|
string status,
|
||||||
public required string Value { get; set; }
|
Dictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
public Instant Expires { get; init; }
|
)
|
||||||
|
{
|
||||||
|
if (!Snowflake.TryParse(status, out Snowflake? sf))
|
||||||
|
return status;
|
||||||
|
|
||||||
|
return customPreferences.TryGetValue(sf.Value, out User.CustomPreference? cf)
|
||||||
|
? cf.LegacyId.ToString()
|
||||||
|
: "unknown";
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -15,9 +15,9 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Utils;
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
public static partial class ValidationUtils
|
public partial class ValidationService
|
||||||
{
|
{
|
||||||
public static readonly string[] DefaultStatusOptions =
|
public static readonly string[] DefaultStatusOptions =
|
||||||
[
|
[
|
||||||
|
@ -28,7 +28,7 @@ public static partial class ValidationUtils
|
||||||
"avoid",
|
"avoid",
|
||||||
];
|
];
|
||||||
|
|
||||||
public static IEnumerable<(string, ValidationError?)> ValidateFields(
|
public IEnumerable<(string, ValidationError?)> ValidateFields(
|
||||||
List<Field>? fields,
|
List<Field>? fields,
|
||||||
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences
|
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences
|
||||||
)
|
)
|
||||||
|
@ -37,7 +37,7 @@ public static partial class ValidationUtils
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
if (fields.Count > 25)
|
if (fields.Count > _limits.MaxFields)
|
||||||
{
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
|
@ -45,7 +45,7 @@ public static partial class ValidationUtils
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Too many fields",
|
"Too many fields",
|
||||||
0,
|
0,
|
||||||
Limits.FieldLimit,
|
_limits.MaxFields,
|
||||||
fields.Count
|
fields.Count
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -53,39 +53,38 @@ public static partial class ValidationUtils
|
||||||
}
|
}
|
||||||
|
|
||||||
// No overwhelming this function, thank you
|
// No overwhelming this function, thank you
|
||||||
if (fields.Count > 100)
|
if (fields.Count > _limits.MaxFields + 50)
|
||||||
return errors;
|
return errors;
|
||||||
|
|
||||||
foreach ((Field? field, int index) in fields.Select((field, index) => (field, index)))
|
foreach ((Field? field, int index) in fields.Select((field, index) => (field, index)))
|
||||||
{
|
{
|
||||||
switch (field.Name.Length)
|
if (field.Name.Length > _limits.MaxFieldNameLength)
|
||||||
{
|
{
|
||||||
case > Limits.FieldNameLimit:
|
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"fields.{index}.name",
|
$"fields.{index}.name",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Field name is too long",
|
"Field name is too long",
|
||||||
1,
|
1,
|
||||||
Limits.FieldNameLimit,
|
_limits.MaxFieldNameLength,
|
||||||
field.Name.Length
|
field.Name.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
case < 1:
|
else if (field.Name.Length < 1)
|
||||||
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"fields.{index}.name",
|
$"fields.{index}.name",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Field name is too short",
|
"Field name is too short",
|
||||||
1,
|
1,
|
||||||
Limits.FieldNameLimit,
|
_limits.MaxFieldNameLength,
|
||||||
field.Name.Length
|
field.Name.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
errors = errors
|
errors = errors
|
||||||
|
@ -102,7 +101,7 @@ public static partial class ValidationUtils
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries(
|
public IEnumerable<(string, ValidationError?)> ValidateFieldEntries(
|
||||||
FieldEntry[]? entries,
|
FieldEntry[]? entries,
|
||||||
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences,
|
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences,
|
||||||
string errorPrefix = "fields"
|
string errorPrefix = "fields"
|
||||||
|
@ -112,7 +111,7 @@ public static partial class ValidationUtils
|
||||||
return [];
|
return [];
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
|
|
||||||
if (entries.Length > Limits.FieldEntriesLimit)
|
if (entries.Length > _limits.MaxFieldEntries)
|
||||||
{
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
|
@ -120,7 +119,7 @@ public static partial class ValidationUtils
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Field has too many entries",
|
"Field has too many entries",
|
||||||
0,
|
0,
|
||||||
Limits.FieldEntriesLimit,
|
_limits.MaxFieldEntries,
|
||||||
entries.Length
|
entries.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -128,7 +127,7 @@ public static partial class ValidationUtils
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
||||||
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
if (entries.Length > _limits.MaxFieldEntries + 50)
|
||||||
return errors;
|
return errors;
|
||||||
|
|
||||||
string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray();
|
string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray();
|
||||||
|
@ -139,34 +138,33 @@ public static partial class ValidationUtils
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
switch (entry.Value.Length)
|
if (entry.Value.Length > _limits.MaxFieldEntryTextLength)
|
||||||
{
|
{
|
||||||
case > Limits.FieldEntryTextLimit:
|
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"{errorPrefix}.{entryIdx}.value",
|
$"{errorPrefix}.{entryIdx}.value",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Field value is too long",
|
"Field value is too long",
|
||||||
1,
|
1,
|
||||||
Limits.FieldEntryTextLimit,
|
_limits.MaxFieldEntryTextLength,
|
||||||
entry.Value.Length
|
entry.Value.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
case < 1:
|
else if (entry.Value.Length < 1)
|
||||||
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"{errorPrefix}.{entryIdx}.value",
|
$"{errorPrefix}.{entryIdx}.value",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Field value is too short",
|
"Field value is too short",
|
||||||
1,
|
1,
|
||||||
Limits.FieldEntryTextLimit,
|
_limits.MaxFieldEntryTextLength,
|
||||||
entry.Value.Length
|
entry.Value.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -186,7 +184,7 @@ public static partial class ValidationUtils
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<(string, ValidationError?)> ValidatePronouns(
|
public IEnumerable<(string, ValidationError?)> ValidatePronouns(
|
||||||
Pronoun[]? entries,
|
Pronoun[]? entries,
|
||||||
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences,
|
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences,
|
||||||
string errorPrefix = "pronouns"
|
string errorPrefix = "pronouns"
|
||||||
|
@ -196,7 +194,7 @@ public static partial class ValidationUtils
|
||||||
return [];
|
return [];
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
|
|
||||||
if (entries.Length > Limits.FieldEntriesLimit)
|
if (entries.Length > _limits.MaxFieldEntries)
|
||||||
{
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
|
@ -204,7 +202,7 @@ public static partial class ValidationUtils
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Too many pronouns",
|
"Too many pronouns",
|
||||||
0,
|
0,
|
||||||
Limits.FieldEntriesLimit,
|
_limits.MaxFieldEntries,
|
||||||
entries.Length
|
entries.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -212,7 +210,7 @@ public static partial class ValidationUtils
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
||||||
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
if (entries.Length > _limits.MaxFieldEntries + 50)
|
||||||
return errors;
|
return errors;
|
||||||
|
|
||||||
string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray();
|
string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray();
|
||||||
|
@ -221,66 +219,64 @@ public static partial class ValidationUtils
|
||||||
(Pronoun? entry, int entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))
|
(Pronoun? entry, int entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
switch (entry.Value.Length)
|
if (entry.Value.Length > _limits.MaxFieldEntryTextLength)
|
||||||
{
|
{
|
||||||
case > Limits.FieldEntryTextLimit:
|
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"{errorPrefix}.{entryIdx}.value",
|
$"{errorPrefix}.{entryIdx}.value",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Pronoun value is too long",
|
"Pronoun value is too long",
|
||||||
1,
|
1,
|
||||||
Limits.FieldEntryTextLimit,
|
_limits.MaxFieldEntryTextLength,
|
||||||
entry.Value.Length
|
entry.Value.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
case < 1:
|
else if (entry.Value.Length < 1)
|
||||||
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"{errorPrefix}.{entryIdx}.value",
|
$"{errorPrefix}.{entryIdx}.value",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Pronoun value is too short",
|
"Pronoun value is too short",
|
||||||
1,
|
1,
|
||||||
Limits.FieldEntryTextLimit,
|
_limits.MaxFieldEntryTextLength,
|
||||||
entry.Value.Length
|
entry.Value.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.DisplayText != null)
|
if (entry.DisplayText != null)
|
||||||
{
|
{
|
||||||
switch (entry.DisplayText.Length)
|
if (entry.DisplayText.Length > _limits.MaxFieldEntryTextLength)
|
||||||
{
|
{
|
||||||
case > Limits.FieldEntryTextLimit:
|
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"{errorPrefix}.{entryIdx}.display_text",
|
$"{errorPrefix}.{entryIdx}.display_text",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Pronoun display text is too long",
|
"Pronoun display text is too long",
|
||||||
1,
|
1,
|
||||||
Limits.FieldEntryTextLimit,
|
_limits.MaxFieldEntryTextLength,
|
||||||
entry.Value.Length
|
entry.Value.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
case < 1:
|
else if (entry.DisplayText.Length < 1)
|
||||||
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
(
|
(
|
||||||
$"{errorPrefix}.{entryIdx}.display_text",
|
$"{errorPrefix}.{entryIdx}.display_text",
|
||||||
ValidationError.LengthError(
|
ValidationError.LengthError(
|
||||||
"Pronoun display text is too short",
|
"Pronoun display text is too short",
|
||||||
1,
|
1,
|
||||||
Limits.FieldEntryTextLimit,
|
_limits.MaxFieldEntryTextLength,
|
||||||
entry.Value.Length
|
entry.Value.Length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
259
Foxnouns.Backend/Services/ValidationService.Strings.cs
Normal file
259
Foxnouns.Backend/Services/ValidationService.Strings.cs
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
|
public partial class ValidationService
|
||||||
|
{
|
||||||
|
private static readonly string[] InvalidUsernames =
|
||||||
|
[
|
||||||
|
"..",
|
||||||
|
"admin",
|
||||||
|
"administrator",
|
||||||
|
"mod",
|
||||||
|
"moderator",
|
||||||
|
"api",
|
||||||
|
"page",
|
||||||
|
"pronouns",
|
||||||
|
"settings",
|
||||||
|
"pronouns.cc",
|
||||||
|
"pronounscc",
|
||||||
|
"null",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] InvalidMemberNames =
|
||||||
|
[
|
||||||
|
// these break routing outright
|
||||||
|
".",
|
||||||
|
"..",
|
||||||
|
// TODO: remove this? i'm not sure if /@[username]/edit will redirect to settings
|
||||||
|
"edit",
|
||||||
|
// this breaks the frontend, somehow
|
||||||
|
"null",
|
||||||
|
];
|
||||||
|
|
||||||
|
public ValidationError? ValidateUsername(string username)
|
||||||
|
{
|
||||||
|
if (!UsernameRegex().IsMatch(username))
|
||||||
|
{
|
||||||
|
if (username.Length < 2)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Username is too short",
|
||||||
|
2,
|
||||||
|
_limits.MaxUsernameLength,
|
||||||
|
username.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.Length > _limits.MaxUsernameLength)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Username is too long",
|
||||||
|
2,
|
||||||
|
_limits.MaxUsernameLength,
|
||||||
|
username.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationError.GenericValidationError(
|
||||||
|
"Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods",
|
||||||
|
username
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
InvalidUsernames.Any(u =>
|
||||||
|
string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return ValidationError.GenericValidationError("Username is not allowed", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationError? ValidateMemberName(string memberName)
|
||||||
|
{
|
||||||
|
if (!MemberRegex().IsMatch(memberName))
|
||||||
|
{
|
||||||
|
if (memberName.Length < 1)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Name is too short",
|
||||||
|
1,
|
||||||
|
_limits.MaxMemberNameLength,
|
||||||
|
memberName.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberName.Length > _limits.MaxMemberNameLength)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Name is too long",
|
||||||
|
1,
|
||||||
|
_limits.MaxMemberNameLength,
|
||||||
|
memberName.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationError.GenericValidationError(
|
||||||
|
"Member name cannot contain any of the following: "
|
||||||
|
+ " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , "
|
||||||
|
+ "and cannot be one or two periods",
|
||||||
|
memberName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
InvalidMemberNames.Any(u =>
|
||||||
|
string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return ValidationError.GenericValidationError("Name is not allowed", memberName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationError? ValidateDisplayName(string? displayName)
|
||||||
|
{
|
||||||
|
if (displayName?.Length == 0)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Display name is too short",
|
||||||
|
1,
|
||||||
|
_limits.MaxDisplayNameLength,
|
||||||
|
displayName.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayName?.Length > _limits.MaxDisplayNameLength)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Display name is too long",
|
||||||
|
1,
|
||||||
|
_limits.MaxDisplayNameLength,
|
||||||
|
displayName.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links)
|
||||||
|
{
|
||||||
|
if (links == null)
|
||||||
|
return [];
|
||||||
|
if (links.Length > _limits.MaxLinks)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"links",
|
||||||
|
ValidationError.LengthError("Too many links", 0, _limits.MaxLinks, links.Length)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors = new List<(string, ValidationError?)>();
|
||||||
|
foreach ((string link, int idx) in links.Select((l, i) => (l, i)))
|
||||||
|
{
|
||||||
|
if (link.Length == 0)
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
(
|
||||||
|
$"links.{idx}",
|
||||||
|
ValidationError.LengthError(
|
||||||
|
"Link cannot be empty",
|
||||||
|
1,
|
||||||
|
_limits.MaxLinkLength,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (link.Length > _limits.MaxLinkLength)
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
(
|
||||||
|
$"links.{idx}",
|
||||||
|
ValidationError.LengthError(
|
||||||
|
"Link is too long",
|
||||||
|
1,
|
||||||
|
_limits.MaxLinkLength,
|
||||||
|
link.Length
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationError? ValidateBio(string? bio)
|
||||||
|
{
|
||||||
|
if (bio?.Length == 0)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Bio is too short",
|
||||||
|
1,
|
||||||
|
_limits.MaxBioLength,
|
||||||
|
bio.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bio?.Length > _limits.MaxBioLength)
|
||||||
|
{
|
||||||
|
return ValidationError.LengthError(
|
||||||
|
"Bio is too long",
|
||||||
|
1,
|
||||||
|
_limits.MaxBioLength,
|
||||||
|
bio.Length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationError? ValidateAvatar(string? avatar)
|
||||||
|
{
|
||||||
|
if (avatar?.Length == 0)
|
||||||
|
{
|
||||||
|
return ValidationError.GenericValidationError("Avatar cannot be empty", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avatar?.Length > _limits.MaxAvatarLength)
|
||||||
|
{
|
||||||
|
return ValidationError.GenericValidationError("Avatar is too large", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-US")]
|
||||||
|
private static partial Regex UsernameRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(
|
||||||
|
"""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""",
|
||||||
|
RegexOptions.IgnoreCase,
|
||||||
|
"en-US"
|
||||||
|
)]
|
||||||
|
private static partial Regex MemberRegex();
|
||||||
|
}
|
6
Foxnouns.Backend/Services/ValidationService.cs
Normal file
6
Foxnouns.Backend/Services/ValidationService.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
|
public partial class ValidationService(Config config)
|
||||||
|
{
|
||||||
|
private readonly Config.LimitsConfig _limits = config.Limits;
|
||||||
|
}
|
|
@ -135,7 +135,7 @@ public static class AuthUtils
|
||||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
|
||||||
|
|
||||||
public static string RandomToken(int bytes = 48) =>
|
public static string RandomToken(int bytes = 48) =>
|
||||||
RandomUrlUnsafeToken()
|
RandomUrlUnsafeToken(bytes)
|
||||||
// Make the token URL-safe
|
// Make the token URL-safe
|
||||||
.Replace('+', '-')
|
.Replace('+', '-')
|
||||||
.Replace('/', '_');
|
.Replace('/', '_');
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published
|
|
||||||
// by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
namespace Foxnouns.Backend.Utils;
|
|
||||||
|
|
||||||
public static class Limits
|
|
||||||
{
|
|
||||||
public const int FieldLimit = 25;
|
|
||||||
public const int FieldNameLimit = 100;
|
|
||||||
public const int FieldEntryTextLimit = 100;
|
|
||||||
public const int FieldEntriesLimit = 100;
|
|
||||||
}
|
|
|
@ -22,8 +22,10 @@ namespace Foxnouns.Backend.Utils.OpenApi;
|
||||||
|
|
||||||
public class PropertyKeySchemaTransformer : IOpenApiSchemaTransformer
|
public class PropertyKeySchemaTransformer : IOpenApiSchemaTransformer
|
||||||
{
|
{
|
||||||
private static readonly DefaultContractResolver SnakeCaseConverter =
|
private static readonly DefaultContractResolver SnakeCaseConverter = new()
|
||||||
new() { NamingStrategy = new SnakeCaseNamingStrategy() };
|
{
|
||||||
|
NamingStrategy = new SnakeCaseNamingStrategy(),
|
||||||
|
};
|
||||||
|
|
||||||
public Task TransformAsync(
|
public Task TransformAsync(
|
||||||
OpenApiSchema schema,
|
OpenApiSchema schema,
|
||||||
|
|
|
@ -12,189 +12,16 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Utils;
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
public static partial class ValidationUtils
|
public static partial class ValidationUtils
|
||||||
{
|
{
|
||||||
private static readonly string[] InvalidUsernames =
|
public const int MaximumReportContextLength = 512;
|
||||||
[
|
|
||||||
"..",
|
|
||||||
"admin",
|
|
||||||
"administrator",
|
|
||||||
"mod",
|
|
||||||
"moderator",
|
|
||||||
"api",
|
|
||||||
"page",
|
|
||||||
"pronouns",
|
|
||||||
"settings",
|
|
||||||
"pronouns.cc",
|
|
||||||
"pronounscc",
|
|
||||||
];
|
|
||||||
|
|
||||||
private static readonly string[] InvalidMemberNames =
|
public static ValidationError? ValidateReportContext(string? context) =>
|
||||||
[
|
context?.Length > MaximumReportContextLength
|
||||||
// these break routing outright
|
? ValidationError.GenericValidationError("Report context is too long", null)
|
||||||
".",
|
: null;
|
||||||
"..",
|
|
||||||
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
|
|
||||||
"edit",
|
|
||||||
];
|
|
||||||
|
|
||||||
public static ValidationError? ValidateUsername(string username)
|
|
||||||
{
|
|
||||||
if (!UsernameRegex().IsMatch(username))
|
|
||||||
{
|
|
||||||
return username.Length switch
|
|
||||||
{
|
|
||||||
< 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length),
|
|
||||||
> 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length),
|
|
||||||
_ => ValidationError.GenericValidationError(
|
|
||||||
"Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods",
|
|
||||||
username
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
InvalidUsernames.Any(u =>
|
|
||||||
string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return ValidationError.GenericValidationError("Username is not allowed", username);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ValidationError? ValidateMemberName(string memberName)
|
|
||||||
{
|
|
||||||
if (!MemberRegex().IsMatch(memberName))
|
|
||||||
{
|
|
||||||
return memberName.Length switch
|
|
||||||
{
|
|
||||||
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
|
||||||
> 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
|
|
||||||
_ => ValidationError.GenericValidationError(
|
|
||||||
"Member name cannot contain any of the following: "
|
|
||||||
+ " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , "
|
|
||||||
+ "and cannot be one or two periods",
|
|
||||||
memberName
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
InvalidMemberNames.Any(u =>
|
|
||||||
string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return ValidationError.GenericValidationError("Name is not allowed", memberName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ValidationError? ValidateDisplayName(string? displayName)
|
|
||||||
{
|
|
||||||
return displayName?.Length switch
|
|
||||||
{
|
|
||||||
0 => ValidationError.LengthError(
|
|
||||||
"Display name is too short",
|
|
||||||
1,
|
|
||||||
100,
|
|
||||||
displayName.Length
|
|
||||||
),
|
|
||||||
> 100 => ValidationError.LengthError(
|
|
||||||
"Display name is too long",
|
|
||||||
1,
|
|
||||||
100,
|
|
||||||
displayName.Length
|
|
||||||
),
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private const int MaxLinks = 25;
|
|
||||||
private const int MaxLinkLength = 256;
|
|
||||||
|
|
||||||
public static IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links)
|
|
||||||
{
|
|
||||||
if (links == null)
|
|
||||||
return [];
|
|
||||||
if (links.Length > MaxLinks)
|
|
||||||
{
|
|
||||||
return
|
|
||||||
[
|
|
||||||
("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
var errors = new List<(string, ValidationError?)>();
|
|
||||||
foreach ((string link, int idx) in links.Select((l, i) => (l, i)))
|
|
||||||
{
|
|
||||||
switch (link.Length)
|
|
||||||
{
|
|
||||||
case 0:
|
|
||||||
errors.Add(
|
|
||||||
(
|
|
||||||
$"links.{idx}",
|
|
||||||
ValidationError.LengthError("Link cannot be empty", 1, 256, 0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case > MaxLinkLength:
|
|
||||||
errors.Add(
|
|
||||||
(
|
|
||||||
$"links.{idx}",
|
|
||||||
ValidationError.LengthError(
|
|
||||||
"Link is too long",
|
|
||||||
1,
|
|
||||||
MaxLinkLength,
|
|
||||||
link.Length
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
public const int MaxBioLength = 1024;
|
|
||||||
public const int MaxAvatarLength = 1_500_000;
|
|
||||||
|
|
||||||
public static ValidationError? ValidateBio(string? bio)
|
|
||||||
{
|
|
||||||
return bio?.Length switch
|
|
||||||
{
|
|
||||||
0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length),
|
|
||||||
> MaxBioLength => ValidationError.LengthError(
|
|
||||||
"Bio is too long",
|
|
||||||
1,
|
|
||||||
MaxBioLength,
|
|
||||||
bio.Length
|
|
||||||
),
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ValidationError? ValidateAvatar(string? avatar)
|
|
||||||
{
|
|
||||||
return avatar?.Length switch
|
|
||||||
{
|
|
||||||
0 => ValidationError.GenericValidationError("Avatar cannot be empty", null),
|
|
||||||
> MaxAvatarLength => ValidationError.GenericValidationError(
|
|
||||||
"Avatar is too large",
|
|
||||||
null
|
|
||||||
),
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public const int MinimumPasswordLength = 12;
|
public const int MinimumPasswordLength = 12;
|
||||||
public const int MaximumPasswordLength = 1024;
|
public const int MaximumPasswordLength = 1024;
|
||||||
|
@ -216,14 +43,4 @@ public static partial class ValidationUtils
|
||||||
),
|
),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
[GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
|
|
||||||
private static partial Regex UsernameRegex();
|
|
||||||
|
|
||||||
[GeneratedRegex(
|
|
||||||
"""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""",
|
|
||||||
RegexOptions.IgnoreCase,
|
|
||||||
"en-NL"
|
|
||||||
)]
|
|
||||||
private static partial Regex MemberRegex();
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
; The host the server will listen on
|
; The host the server will listen on
|
||||||
Host = localhost
|
Host = localhost
|
||||||
; The port the server will listen on
|
; The port the server will listen on
|
||||||
Port = 5000
|
Port = 6000
|
||||||
; The base *external* URL
|
; The base *external* URL
|
||||||
BaseUrl = https://pronouns.localhost
|
BaseUrl = https://pronouns.localhost
|
||||||
; The base URL for media, without a trailing slash. This must be publicly accessible.
|
; The base URL for media, without a trailing slash. This must be publicly accessible.
|
||||||
|
@ -43,6 +43,9 @@ AccessKey = <s3AccessKey>
|
||||||
SecretKey = <s3SecretKey>
|
SecretKey = <s3SecretKey>
|
||||||
Bucket = pronounscc
|
Bucket = pronounscc
|
||||||
|
|
||||||
|
[Limits]
|
||||||
|
MaxMemberCount = 5000
|
||||||
|
|
||||||
[EmailAuth]
|
[EmailAuth]
|
||||||
; The address that emails will be sent from. If not set, email auth is disabled.
|
; The address that emails will be sent from. If not set, email auth is disabled.
|
||||||
From = noreply@accounts.pronouns.cc
|
From = noreply@accounts.pronouns.cc
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
"net9.0": {
|
"net9.0": {
|
||||||
"Coravel": {
|
"Coravel": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.2, )",
|
||||||
"resolved": "6.0.0",
|
"resolved": "6.0.2",
|
||||||
"contentHash": "U16V/IxGL2TcpU9sT1gUA3pqoVIlz+WthC4idn8OTPiEtLElTcmNF6sHt+gOx8DRU8TBgN5vjfL4AHetjacOWQ==",
|
"contentHash": "/XZiRId4Ilar/OqjGKdxkZWfW97ekeT0wgiWNjGdqf8pPxiK508//Zkc0xrKMDOqchFT7B/oqAoQ+Vrx1txpPQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Caching.Memory": "3.1.0",
|
"Microsoft.Extensions.Caching.Memory": "3.1.0",
|
||||||
"Microsoft.Extensions.Configuration.Binder": "6.0.0",
|
"Microsoft.Extensions.Configuration.Binder": "6.0.0",
|
||||||
|
@ -17,12 +17,12 @@
|
||||||
},
|
},
|
||||||
"Coravel.Mailer": {
|
"Coravel.Mailer": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[7.0.0, )",
|
"requested": "[7.1.0, )",
|
||||||
"resolved": "7.0.0",
|
"resolved": "7.1.0",
|
||||||
"contentHash": "mxSlOOBxPjCAZruOpgXtubnZA9lD0DRgutApQmAsts7DoRfe0wTzqWrYjeZTiIzgVJZKZxJglN8duTvbPrw3jQ==",
|
"contentHash": "yMbUrwKl5/HbJeX8JkHa8Q3CPTJ3OmPyDSG7sULbXGEhzc2GiYIh7pmVhI1FFeL3VUtFavMDkS8PTwEeCpiwlg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"MailKit": "4.3.0",
|
"MailKit": "4.8.0",
|
||||||
"Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation": "6.0.27"
|
"Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation": "6.0.36"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EFCore.NamingConventions": {
|
"EFCore.NamingConventions": {
|
||||||
|
@ -46,6 +46,37 @@
|
||||||
"Npgsql": "8.0.3"
|
"Npgsql": "8.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Hangfire": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[1.8.18, )",
|
||||||
|
"resolved": "1.8.18",
|
||||||
|
"contentHash": "EY+UqMHTOQAtdjeJf3jlnj8MpENyDPTpA6OHMncucVlkaongZjrx+gCN4bgma7vD3BNHqfQ7irYrfE5p1DOBEQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Hangfire.AspNetCore": "[1.8.18]",
|
||||||
|
"Hangfire.Core": "[1.8.18]",
|
||||||
|
"Hangfire.SqlServer": "[1.8.18]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Hangfire.Core": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[1.8.18, )",
|
||||||
|
"resolved": "1.8.18",
|
||||||
|
"contentHash": "oNAkV8QQoYg5+vM2M024NBk49EhTO2BmKDLuQaKNew23RpH9OUGtKDl1KldBdDJrD8TMFzjhWCArol3igd2i2w==",
|
||||||
|
"dependencies": {
|
||||||
|
"Newtonsoft.Json": "11.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Hangfire.Redis.StackExchange": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[1.9.4, )",
|
||||||
|
"resolved": "1.9.4",
|
||||||
|
"contentHash": "rB4eGf4+hFhdnrN3//2O39JGuy1ThIKL3oTdVI2F3HqmSaSD9Cixl2xmMAqGJMld39Ke7eoP9sxbxnpVnYW66g==",
|
||||||
|
"dependencies": {
|
||||||
|
"Hangfire.Core": "1.8.7",
|
||||||
|
"Newtonsoft.Json": "13.0.3",
|
||||||
|
"StackExchange.Redis": "2.7.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Humanizer.Core": {
|
"Humanizer.Core": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2.14.1, )",
|
"requested": "[2.14.1, )",
|
||||||
|
@ -60,41 +91,41 @@
|
||||||
},
|
},
|
||||||
"Microsoft.AspNetCore.Mvc.NewtonsoftJson": {
|
"Microsoft.AspNetCore.Mvc.NewtonsoftJson": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.0, )",
|
"requested": "[9.0.2, )",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "pTFDEmZi3GheCSPrBxzyE63+d5unln2vYldo/nOm1xet/4rpEk2oJYcwpclPQ13E+LZBF9XixkgwYTUwqznlWg==",
|
"contentHash": "cCnaxji6nqIHHLAEhZ6QirXCvwJNi0Q/qCPLkRW5SqMYNuOwoQdGk1KAhW65phBq1VHGt7wLbadpuGPGqfiZuA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.AspNetCore.JsonPatch": "9.0.0",
|
"Microsoft.AspNetCore.JsonPatch": "9.0.2",
|
||||||
"Newtonsoft.Json": "13.0.3",
|
"Newtonsoft.Json": "13.0.3",
|
||||||
"Newtonsoft.Json.Bson": "1.0.2"
|
"Newtonsoft.Json.Bson": "1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.AspNetCore.OpenApi": {
|
"Microsoft.AspNetCore.OpenApi": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.0, )",
|
"requested": "[9.0.2, )",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "FqUK5j1EOPNuFT7IafltZQ3cakqhSwVzH5ZW1MhZDe4pPXs9sJ2M5jom1Omsu+mwF2tNKKlRAzLRHQTZzbd+6Q==",
|
"contentHash": "JUndpjRNdG8GvzBLH/J4hen4ehWaPcshtiQ6+sUs1Bcj3a7dOsmWpDloDlpPeMOVSlhHwUJ3Xld0ClZjsFLgFQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.OpenApi": "1.6.17"
|
"Microsoft.OpenApi": "1.6.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.EntityFrameworkCore": {
|
"Microsoft.EntityFrameworkCore": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.0, )",
|
"requested": "[9.0.2, )",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "wpG+nfnfDAw87R3ovAsUmjr3MZ4tYXf6bFqEPVAIKE6IfPml3DS//iX0DBnf8kWn5ZHSO5oi1m4d/Jf+1LifJQ==",
|
"contentHash": "P90ZuybgcpW32y985eOYxSoZ9IiL0UTYQlY0y1Pt1iHAnpZj/dQHREpSpry1RNvk8YjAeoAkWFdem5conqB9zQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.EntityFrameworkCore.Abstractions": "9.0.0",
|
"Microsoft.EntityFrameworkCore.Abstractions": "9.0.2",
|
||||||
"Microsoft.EntityFrameworkCore.Analyzers": "9.0.0",
|
"Microsoft.EntityFrameworkCore.Analyzers": "9.0.2",
|
||||||
"Microsoft.Extensions.Caching.Memory": "9.0.0",
|
"Microsoft.Extensions.Caching.Memory": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging": "9.0.0"
|
"Microsoft.Extensions.Logging": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.EntityFrameworkCore.Design": {
|
"Microsoft.EntityFrameworkCore.Design": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.0, )",
|
"requested": "[9.0.2, )",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "Pqo8I+yHJ3VQrAoY0hiSncf+5P7gN/RkNilK5e+/K/yKh+yAWxdUAI6t0TG26a9VPlCa9FhyklzyFvRyj3YG9A==",
|
"contentHash": "WWRmTxb/yd05cTW+k32lLvIhffxilgYvwKHDxiqe7GRLKeceyMspuf5BRpW65sFF7S2G+Be9JgjUe1ypGqt9tg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Humanizer.Core": "2.14.1",
|
"Humanizer.Core": "2.14.1",
|
||||||
"Microsoft.Build.Framework": "17.8.3",
|
"Microsoft.Build.Framework": "17.8.3",
|
||||||
|
@ -102,33 +133,45 @@
|
||||||
"Microsoft.CodeAnalysis.CSharp": "4.8.0",
|
"Microsoft.CodeAnalysis.CSharp": "4.8.0",
|
||||||
"Microsoft.CodeAnalysis.CSharp.Workspaces": "4.8.0",
|
"Microsoft.CodeAnalysis.CSharp.Workspaces": "4.8.0",
|
||||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "4.8.0",
|
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "4.8.0",
|
||||||
"Microsoft.EntityFrameworkCore.Relational": "9.0.0",
|
"Microsoft.EntityFrameworkCore.Relational": "9.0.2",
|
||||||
"Microsoft.Extensions.Caching.Memory": "9.0.0",
|
"Microsoft.Extensions.Caching.Memory": "9.0.2",
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.DependencyModel": "9.0.0",
|
"Microsoft.Extensions.DependencyModel": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging": "9.0.0",
|
"Microsoft.Extensions.Logging": "9.0.2",
|
||||||
"Mono.TextTemplating": "3.0.0",
|
"Mono.TextTemplating": "3.0.0",
|
||||||
"System.Text.Json": "9.0.0"
|
"System.Text.Json": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Caching.Memory": {
|
"Microsoft.Extensions.Caching.Memory": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.0, )",
|
"requested": "[9.0.2, )",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "zbnPX/JQ0pETRSUG9fNPBvpIq42Aufvs15gGYyNIMhCun9yhmWihz0WgsI7bSDPjxWTKBf8oX/zv6v2uZ3W9OQ==",
|
"contentHash": "AlEfp0DMz8E1h1Exi8LBrUCNmCYcGDfSM4F/uK1D1cYx/R3w0LVvlmjICqxqXTsy7BEZaCf5leRZY2FuPEiFaw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Caching.Abstractions": "9.0.0",
|
"Microsoft.Extensions.Caching.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Options": "9.0.0",
|
"Microsoft.Extensions.Options": "9.0.2",
|
||||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Http.Resilience": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[9.2.0, )",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "Km+YyCuk1IaeOsAzPDygtgsUOh3Fi89hpA18si0tFJmpSBf9aKzP9ffV5j7YOoVDvRWirpumXAPQzk1inBsvKw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration.Binder": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Http.Diagnostics": "9.2.0",
|
||||||
|
"Microsoft.Extensions.ObjectPool": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Resilience": "9.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"MimeKit": {
|
"MimeKit": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[4.9.0, )",
|
"requested": "[4.10.0, )",
|
||||||
"resolved": "4.9.0",
|
"resolved": "4.10.0",
|
||||||
"contentHash": "DZXXMZzmAABDxFhOSMb6SE8KKxcRd/sk1E6aJTUE5ys2FWOQhznYV2Gl3klaaSfqKn27hQ32haqquH1J8Z6kJw==",
|
"contentHash": "GQofI17cH55XSh109hJmHaYMtSFqTX/eUek3UcV7hTnYayAIXZ6eHlv345tfdc+bQ/BrEnYOSZVzx9I3wpvvpg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"BouncyCastle.Cryptography": "2.5.0",
|
"BouncyCastle.Cryptography": "2.5.0",
|
||||||
"System.Formats.Asn1": "8.0.1",
|
"System.Formats.Asn1": "8.0.1",
|
||||||
|
@ -137,11 +180,11 @@
|
||||||
},
|
},
|
||||||
"Minio": {
|
"Minio": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.3, )",
|
"requested": "[6.0.4, )",
|
||||||
"resolved": "6.0.3",
|
"resolved": "6.0.4",
|
||||||
"contentHash": "WHlkouclHtiK/pIXPHcjVmbeELHPtElj2qRSopFVpSmsFhZXeM10sPvczrkSPePsmwuvZdFryJ/hJzKu3XeLVg==",
|
"contentHash": "JckRL95hQ/eDHTQZ/BB7jeR0JyF+bOctMW6uriXHY5YPjCX61hiJGsswGjuDSEViKJEPxtPi3e4IwD/1TJ7PIw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"CommunityToolkit.HighPerformance": "8.2.2",
|
"CommunityToolkit.HighPerformance": "8.3.0",
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.1",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.1",
|
||||||
"Microsoft.Extensions.Logging": "8.0.0",
|
"Microsoft.Extensions.Logging": "8.0.0",
|
||||||
"System.IO.Hashing": "8.0.0",
|
"System.IO.Hashing": "8.0.0",
|
||||||
|
@ -156,39 +199,39 @@
|
||||||
},
|
},
|
||||||
"NodaTime": {
|
"NodaTime": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[3.2.0, )",
|
"requested": "[3.2.1, )",
|
||||||
"resolved": "3.2.0",
|
"resolved": "3.2.1",
|
||||||
"contentHash": "yoRA3jEJn8NM0/rQm78zuDNPA3DonNSZdsorMUj+dltc1D+/Lc5h9YXGqbEEZozMGr37lAoYkcSM/KjTVqD0ow=="
|
"contentHash": "D1aHhUfPQUxU2nfDCVuSLahpp0xCYZTmj/KNH3mSK/tStJYcx9HO9aJ0qbOP3hzjGPV/DXOqY2AHe27Nt4xs4g=="
|
||||||
},
|
},
|
||||||
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.2, )",
|
"requested": "[9.0.4, )",
|
||||||
"resolved": "9.0.2",
|
"resolved": "9.0.4",
|
||||||
"contentHash": "cYdOGplIvr9KgsG8nJ8xnzBTImeircbgetlzS1OmepS5dAQW6PuGpVrLOKBNEwEvGYZPsV8037X5vZ/Dmpwz7Q==",
|
"contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.EntityFrameworkCore": "[9.0.0, 10.0.0)",
|
"Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)",
|
||||||
"Microsoft.EntityFrameworkCore.Relational": "[9.0.0, 10.0.0)",
|
"Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)",
|
||||||
"Npgsql": "9.0.2"
|
"Npgsql": "9.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime": {
|
"Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.2, )",
|
"requested": "[9.0.4, )",
|
||||||
"resolved": "9.0.2",
|
"resolved": "9.0.4",
|
||||||
"contentHash": "+mfwiRCK+CAKTkeBZCuQuMaOwM/yMX8B65515PS1le9TUjlG8DobuAmb48MSR/Pr/YMvU1tV8FFEFlyQviQzrg==",
|
"contentHash": "QZ80CL3c9xzC83eVMWYWa1RcFZA6HJtpMAKFURlmz+1p0OyysSe8R6f/4sI9vk/nwqF6Fkw3lDgku/xH6HcJYg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Npgsql.EntityFrameworkCore.PostgreSQL": "9.0.2",
|
"Npgsql.EntityFrameworkCore.PostgreSQL": "9.0.4",
|
||||||
"Npgsql.NodaTime": "9.0.2"
|
"Npgsql.NodaTime": "9.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Npgsql.Json.NET": {
|
"Npgsql.Json.NET": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.2, )",
|
"requested": "[9.0.3, )",
|
||||||
"resolved": "9.0.2",
|
"resolved": "9.0.3",
|
||||||
"contentHash": "E81dvvpNtS4WigxZu16OAFxVvPvbEkXI7vJXZzEp7GQ03MArF5V4HBb7KXDzTaE5ZQ0bhCUFoMTODC6Z8mu27g==",
|
"contentHash": "lN8p9UKkoXaGUhX3DHg/1W6YeEfbjQiQ7XrJSGREUoDHXOLxDQHJnZ49P/9P2s/pH6HTVgTgT5dijpKoRLN0vQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Newtonsoft.Json": "13.0.3",
|
"Newtonsoft.Json": "13.0.3",
|
||||||
"Npgsql": "9.0.2"
|
"Npgsql": "9.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prometheus-net": {
|
"prometheus-net": {
|
||||||
|
@ -212,24 +255,24 @@
|
||||||
},
|
},
|
||||||
"Roslynator.Analyzers": {
|
"Roslynator.Analyzers": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[4.12.9, )",
|
"requested": "[4.13.1, )",
|
||||||
"resolved": "4.12.9",
|
"resolved": "4.13.1",
|
||||||
"contentHash": "X6lDpN/D5wuinq37KIx+l3GSUe9No+8bCjGBTI5sEEtxapLztkHg6gzNVhMXpXw8P+/5gFYxTXJ5Pf8O4iNz/w=="
|
"contentHash": "KZpLy6ZlCebMk+d/3I5KU2R7AOb4LNJ6tPJqPtvFXmO8bEBHQvCIAvJOnY2tu4C9/aVOROTDYUFADxFqw1gh/g=="
|
||||||
},
|
},
|
||||||
"Scalar.AspNetCore": {
|
"Scalar.AspNetCore": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[1.2.55, )",
|
"requested": "[2.0.26, )",
|
||||||
"resolved": "1.2.55",
|
"resolved": "2.0.26",
|
||||||
"contentHash": "zArlr6nfPQMRwyia0WFirsyczQby51GhNgWITiEIRkot+CVGZSGQ4oWGqExO11/6x26G+mcQo9Oft1mGpN0/ZQ=="
|
"contentHash": "0tKBFM7quBq0ifgRWo7eTTVpiTbnwpf/6ygtb/aYVuo0D2gMsYknAJRqEhH8HFBqzntNiYpzHbQSf2b+VAA8sA=="
|
||||||
},
|
},
|
||||||
"Sentry.AspNetCore": {
|
"Sentry.AspNetCore": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[4.13.0, )",
|
"requested": "[5.3.0, )",
|
||||||
"resolved": "4.13.0",
|
"resolved": "5.3.0",
|
||||||
"contentHash": "1cH9hSvjRbTkcpjUejFTrTC3jMIiOrcZ0DIvt16+AYqXhuxPEnI56npR1nhv+7WUGyhyp5cHFIZqrKnyrrGP0w==",
|
"contentHash": "zC2yhwQB0laYWGXLYDCsiKSIqleaEK3fUH9Z5t8Bgvfs2nGX0mHmh9oPqNAAbkVGvni56mhgHHCBxN/kpfkawA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
|
"Microsoft.Extensions.Configuration.Binder": "9.0.0",
|
||||||
"Sentry.Extensions.Logging": "4.13.0"
|
"Sentry.Extensions.Logging": "5.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
|
@ -264,25 +307,35 @@
|
||||||
},
|
},
|
||||||
"Serilog.Sinks.Seq": {
|
"Serilog.Sinks.Seq": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[8.0.0, )",
|
"requested": "[9.0.0, )",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "z5ig56/qzjkX6Fj4U/9m1g8HQaQiYPMZS4Uevtjg1I+WWzoGSf5t/E+6JbMP/jbZYhU63bA5NJN5y0x+qqx2Bw==",
|
"contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Serilog": "4.0.0",
|
"Serilog": "4.2.0",
|
||||||
"Serilog.Sinks.File": "5.0.0"
|
"Serilog.Sinks.File": "6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SixLabors.ImageSharp": {
|
"SixLabors.ImageSharp": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[3.1.6, )",
|
"requested": "[3.1.7, )",
|
||||||
"resolved": "3.1.6",
|
"resolved": "3.1.7",
|
||||||
"contentHash": "dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA=="
|
"contentHash": "9fIOOAsyLFid6qKypM2Iy0Z3Q9yoanV8VoYAHtI2sYGMNKzhvRTjgFDHonIiVe+ANtxIxM6SuqUzj0r91nItpA=="
|
||||||
|
},
|
||||||
|
"StackExchange.Redis": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[2.8.31, )",
|
||||||
|
"resolved": "2.8.31",
|
||||||
|
"contentHash": "RCHVQa9Zke8k0oBgJn1Yl6BuYy8i6kv+sdMObiH60nOwD6QvWAjxdDwOm+LO78E8WsGiPqgOuItkz98fPS6haQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "6.0.0",
|
||||||
|
"Pipelines.Sockets.Unofficial": "2.2.8"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"System.Text.Json": {
|
"System.Text.Json": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[9.0.0, )",
|
"requested": "[9.0.2, )",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A=="
|
"contentHash": "4TY2Yokh5Xp8XHFhsY9y84yokS7B0rhkaZCXuRiKppIiKwPVH4lVSFD9EEFzRpXdBM5ZeZXD43tc2vB6njEwwQ=="
|
||||||
},
|
},
|
||||||
"System.Text.RegularExpressions": {
|
"System.Text.RegularExpressions": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
@ -293,6 +346,12 @@
|
||||||
"System.Runtime": "4.3.1"
|
"System.Runtime": "4.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Yort.Xid.Net": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[2.0.1, )",
|
||||||
|
"resolved": "2.0.1",
|
||||||
|
"contentHash": "+3sNX7/RKSKheVuMz9jtWLazD+R4PXpx8va2d9SdDgvKOhETbEb0VYis8K/fD1qm/qOQT57LadToSpzReGMZlw=="
|
||||||
|
},
|
||||||
"BouncyCastle.Cryptography": {
|
"BouncyCastle.Cryptography": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.5.0",
|
"resolved": "2.5.0",
|
||||||
|
@ -300,8 +359,8 @@
|
||||||
},
|
},
|
||||||
"CommunityToolkit.HighPerformance": {
|
"CommunityToolkit.HighPerformance": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.2.2",
|
"resolved": "8.3.0",
|
||||||
"contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw=="
|
"contentHash": "2zc0Wfr9OtEbLqm6J1Jycim/nKmYv+v12CytJ3tZGNzw7n3yjh1vNCMX0kIBaFBk3sw8g0pMR86QJGXGlArC+A=="
|
||||||
},
|
},
|
||||||
"EntityFrameworkCore.Exceptions.Common": {
|
"EntityFrameworkCore.Exceptions.Common": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
@ -311,18 +370,46 @@
|
||||||
"Microsoft.EntityFrameworkCore.Relational": "8.0.0"
|
"Microsoft.EntityFrameworkCore.Relational": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Hangfire.AspNetCore": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "1.8.18",
|
||||||
|
"contentHash": "5D6Do0qgoAnakvh4KnKwhIoUzFU84Z0sCYMB+Sit+ygkpL1P6JGYDcd/9vDBcfr5K3JqBxD4Zh2IK2LOXuuiaw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Hangfire.NetCore": "[1.8.18]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Hangfire.NetCore": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "1.8.18",
|
||||||
|
"contentHash": "3KAV9AZ1nqQHC54qR4buNEEKRmQJfq+lODtZxUk5cdi68lV8+9K2f4H1/mIfDlPpgjPFjEfCobNoi2+TIpKySw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Hangfire.Core": "[1.8.18]",
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0",
|
||||||
|
"Microsoft.Extensions.Hosting.Abstractions": "3.0.0",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Hangfire.SqlServer": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "1.8.18",
|
||||||
|
"contentHash": "yBfI2ygYfN/31rOrahfOFHee1mwTrG0ppsmK9awCS0mAr2GEaB9eyYqg/lURgZy8AA8UVJVs5nLHa2hc1pDAVQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Hangfire.Core": "[1.8.18]"
|
||||||
|
}
|
||||||
|
},
|
||||||
"MailKit": {
|
"MailKit": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "4.3.0",
|
"resolved": "4.8.0",
|
||||||
"contentHash": "jVmB3Nr0JpqhyMiXOGWMin+QvRKpucGpSFBCav9dG6jEJPdBV+yp1RHVpKzxZPfT+0adaBuZlMFdbIciZo1EWA==",
|
"contentHash": "zZ1UoM4FUnSFUJ9fTl5CEEaejR0DNP6+FDt1OfXnjg4igZntcir1tg/8Ufd6WY5vrpmvToAjluYqjVM24A+5lA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"MimeKit": "4.3.0"
|
"MimeKit": "4.8.0",
|
||||||
|
"System.Formats.Asn1": "8.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.AspNetCore.JsonPatch": {
|
"Microsoft.AspNetCore.JsonPatch": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "/4UONYoAIeexPoAmbzBPkVGA6KAY7t0BM+1sr0fKss2V1ERCdcM+Llub4X5Ma+LJ60oPp6KzM0e3j+Pp/JHCNw==",
|
"contentHash": "bZMRhazEBgw9aZ5EBGYt0017CSd+aecsUCnppVjSa1SzWH6C1ieTSQZRAe+H0DzAVzWAoK7HLwKnQUPioopPrA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.CSharp": "4.7.0",
|
"Microsoft.CSharp": "4.7.0",
|
||||||
"Newtonsoft.Json": "13.0.3"
|
"Newtonsoft.Json": "13.0.3"
|
||||||
|
@ -330,27 +417,27 @@
|
||||||
},
|
},
|
||||||
"Microsoft.AspNetCore.Mvc.Razor.Extensions": {
|
"Microsoft.AspNetCore.Mvc.Razor.Extensions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "6.0.27",
|
"resolved": "6.0.36",
|
||||||
"contentHash": "trwJhFrTQuJTImmixMsDnDgRE8zuTzAUAot7WqiUlmjNzlJWLOaXXBpeA/xfNJvZuOsyGjC7RIzEyNyDGhDTLg==",
|
"contentHash": "KFHRhrGAnd80310lpuWzI7Cf+GidS/h3JaPDFFnSmSGjCxB5vkBv5E+TXclJCJhqPtgNxg+keTC5SF1T9ieG5w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.AspNetCore.Razor.Language": "6.0.27",
|
"Microsoft.AspNetCore.Razor.Language": "6.0.36",
|
||||||
"Microsoft.CodeAnalysis.Razor": "6.0.27"
|
"Microsoft.CodeAnalysis.Razor": "6.0.36"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation": {
|
"Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "6.0.27",
|
"resolved": "6.0.36",
|
||||||
"contentHash": "C6Gh/sAuUACxNtllcH4ZniWtPcGbixJuB1L5RXwoUe1a1wM6rpQ2TVMWpX2+cgeBj8U/izJyWY+nJ4Lz8mmMKA==",
|
"contentHash": "0OG/wNedsQ9kTMrFuvrUDoJvp6Fxj6BzWgi7AUCluOENxu/0PzbjY9AC5w6mZJ22/AFxn2gFc2m0yOBTfQbiPg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.AspNetCore.Mvc.Razor.Extensions": "6.0.27",
|
"Microsoft.AspNetCore.Mvc.Razor.Extensions": "6.0.36",
|
||||||
"Microsoft.CodeAnalysis.Razor": "6.0.27",
|
"Microsoft.CodeAnalysis.Razor": "6.0.36",
|
||||||
"Microsoft.Extensions.DependencyModel": "6.0.0"
|
"Microsoft.Extensions.DependencyModel": "6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.AspNetCore.Razor.Language": {
|
"Microsoft.AspNetCore.Razor.Language": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "6.0.27",
|
"resolved": "6.0.36",
|
||||||
"contentHash": "bI1kIZBgx7oJIB7utPrw4xIgcj7Pdx1jnHMTdsG54U602OcGpBzbfAuKaWs+LVdj+zZVuZsCSoRIZNJKTDP7Hw=="
|
"contentHash": "n5Mg5D0aRrhHJJ6bJcwKqQydIFcgUq0jTlvuynoJjwA2IvAzh8Aqf9cpYagofQbIlIXILkCP6q6FgbngyVtpYA=="
|
||||||
},
|
},
|
||||||
"Microsoft.Bcl.AsyncInterfaces": {
|
"Microsoft.Bcl.AsyncInterfaces": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
@ -404,10 +491,10 @@
|
||||||
},
|
},
|
||||||
"Microsoft.CodeAnalysis.Razor": {
|
"Microsoft.CodeAnalysis.Razor": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "6.0.27",
|
"resolved": "6.0.36",
|
||||||
"contentHash": "NAUvSjH8QY8gPp/fXjHhi3MnQEGtSJA0iRT/dT3RKO3AdGACPJyGmKEKxLag9+Kf2On51yGHT9DEPPnK3hyezg==",
|
"contentHash": "RTLNJglWezr/1IkiWdtDpPYW7X7lwa4ow8E35cHt+sWdWxOnl+ayQqMy1RfbaLp7CLmRmgXSzMMZZU3D4vZi9Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.AspNetCore.Razor.Language": "6.0.27",
|
"Microsoft.AspNetCore.Razor.Language": "6.0.36",
|
||||||
"Microsoft.CodeAnalysis.CSharp": "4.0.0",
|
"Microsoft.CodeAnalysis.CSharp": "4.0.0",
|
||||||
"Microsoft.CodeAnalysis.Common": "4.0.0"
|
"Microsoft.CodeAnalysis.Common": "4.0.0"
|
||||||
}
|
}
|
||||||
|
@ -443,191 +530,274 @@
|
||||||
},
|
},
|
||||||
"Microsoft.EntityFrameworkCore.Abstractions": {
|
"Microsoft.EntityFrameworkCore.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "fnmifFL8KaA4ZNLCVgfjCWhZUFxkrDInx5hR4qG7Q8IEaSiy/6VOSRFyx55oH7MV4y7wM3J3EE90nSpcVBI44Q=="
|
"contentHash": "oVSjNSIYHsk0N66eqAWgDcyo9etEFbUswbz7SmlYR6nGp05byHrJAYM5N8U2aGWJWJI6WvIC2e4TXJgH6GZ6HQ=="
|
||||||
},
|
},
|
||||||
"Microsoft.EntityFrameworkCore.Analyzers": {
|
"Microsoft.EntityFrameworkCore.Analyzers": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "Qje+DzXJOKiXF72SL0XxNlDtTkvWWvmwknuZtFahY5hIQpRKO59qnGuERIQ3qlzuq5x4bAJ8WMbgU5DLhBgeOQ=="
|
"contentHash": "w4jzX7XI+L3erVGzbHXpx64A3QaLXxqG3f1vPpGYYZGpxOIHkh7e4iLLD7cq4Ng1vjkwzWl5ZJp0Kj/nHsgFYg=="
|
||||||
},
|
},
|
||||||
"Microsoft.EntityFrameworkCore.Relational": {
|
"Microsoft.EntityFrameworkCore.Relational": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "j+msw6fWgAE9M3Q/5B9Uhv7pdAdAQUvFPJAiBJmoy+OXvehVbfbCE8ftMAa51Uo2ZeiqVnHShhnv4Y4UJJmUzA==",
|
"contentHash": "r7O4N5uaM95InVSGUj7SMOQWN0f1PBF2Y30ow7Jg+pGX5GJCRVd/1fq83lQ50YMyq+EzyHac5o4CDQA2RsjKJQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.EntityFrameworkCore": "9.0.0",
|
"Microsoft.EntityFrameworkCore": "9.0.2",
|
||||||
"Microsoft.Extensions.Caching.Memory": "9.0.0",
|
"Microsoft.Extensions.Caching.Memory": "9.0.2",
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging": "9.0.0"
|
"Microsoft.Extensions.Logging": "9.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.AmbientMetadata.Application": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "GMCX3zybUB22aAADjYPXrWhhd1HNMkcY5EcFAJnXy/4k5pPpJ6TS4VRl37xfrtosNyzbpO2SI7pd2Q5PvggSdg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Hosting.Abstractions": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Caching.Abstractions": {
|
"Microsoft.Extensions.Caching.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "FPWZAa9c0H4dvOj351iR1jkUIs4u9ykL4Bm592yhjDyO5lCoWd+TMAHx2EMbarzUvCvgjWjJIoC6//Q9kH6YhA==",
|
"contentHash": "a7QhA25n+BzSM5r5d7JznfyluMBGI7z3qyLlFviZ1Eiqv6DdiK27sLZdP/rpYirBM6UYAKxu5TbmfhIy13GN9A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Compliance.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "Te+N4xphDlGIS90lKJMZyezFiMWKLAtYV2/M8gGJG4thH6xyC7LWhMzgz2+tWMehxwZlBUq2D9DvVpjKBZFTPQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
|
"Microsoft.Extensions.ObjectPool": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Configuration": {
|
"Microsoft.Extensions.Configuration": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==",
|
"contentHash": "EBZW+u96tApIvNtjymXEIS44tH0I/jNwABHo4c33AchWOiDWCq2rL3klpnIo+xGrxoVGJzPDISV6hZ+a9C9SzQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==",
|
"contentHash": "I0O/270E/lUNqbBxlRVjxKOMZyYjP88dpEgQTveml+h2lTzAP4vbawLVwjS9SC7lKaU893bwyyNz0IVJYsm9EA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Configuration.Binder": {
|
"Microsoft.Extensions.Configuration.Binder": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
|
"contentHash": "krJ04xR0aPXrOf5dkNASg6aJjsdzexvsMRL6UNOUjiTzqBvRr95sJ1owoKEm89bSONQCfZNhHrAFV9ahDqIPIw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection": {
|
"Microsoft.Extensions.DependencyInjection": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==",
|
"contentHash": "ZffbJrskOZ40JTzcTyKwFHS5eACSWp2bUQBBApIgGV+es8RaTD4OxUG7XxFr3RIPLXtYQ1jQzF2DjKB5fZn7Qg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
|
"contentHash": "MNe7GSTBf3jQx5vYrXF0NZvn6l7hUKF6J54ENfAgCO8y6xjN1XUmKKWG464LP2ye6QqDiA1dkaWEZBYnhoZzjg=="
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "WcwfTpl3IcPcaahTVEaJwMUg1eWog1SkIA6jQZZFqMXiMX9/tVkhNB6yzUQmBdGWdlWDDRKpOmK7T7x1Uu05pQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Hosting.Abstractions": "9.0.2"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyModel": {
|
"Microsoft.Extensions.DependencyModel": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA=="
|
"contentHash": "3ImbcbS68jy9sKr9Z9ToRbEEX0bvIRdb8zyf5ebtL9Av2CUCGHvaO5wsSXfRfAjr60Vrq0tlmNji9IzAxW6EOw=="
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Diagnostics": {
|
"Microsoft.Extensions.Diagnostics": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==",
|
"contentHash": "kwFWk6DPaj1Roc0CExRv+TTwjsiERZA730jQIPlwCcS5tMaCAQtaGfwAK0z8CMFpVTiT+MgKXpd/P50qVCuIgg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration": "8.0.0",
|
"Microsoft.Extensions.Configuration": "9.0.2",
|
||||||
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
|
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==",
|
"contentHash": "kFwIZEC/37cwKuEm/nXvjF7A/Myz9O7c7P9Csgz6AOiiDE62zdOG5Bu7VkROu1oMYaX0wgijPJ5LqVt6+JKjVg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Options": "9.0.0"
|
"Microsoft.Extensions.Options": "9.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "et5JevHsLv1w1O1Zhb6LiUfai/nmDRzIHnbrZJdzLsIbbMCKTZpeHuANYIppAD//n12KvgOne05j4cu0GhG9gw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.FileProviders.Abstractions": {
|
"Microsoft.Extensions.FileProviders.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "uK439QzYR0q2emLVtYzwyK3x+T5bTY4yWsd/k/ZUS9LR6Sflp8MIdhGXW8kQCd86dQD4tLqvcbLkku8qHY263Q==",
|
"contentHash": "IcOBmTlr2jySswU+3x8c3ql87FRwTVPQgVKaV5AXzPT5u0VItfNU8SMbESpdSp5STwxT/1R99WYszgHWsVkzhg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Hosting.Abstractions": {
|
"Microsoft.Extensions.Hosting.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "yUKJgu81ExjvqbNWqZKshBbLntZMbMVz/P7Way2SBx7bMqA08Mfdc9O7hWDKAiSp+zPUGT6LKcSCQIPeDK+CCw==",
|
"contentHash": "PvjZW6CMdZbPbOwKsQXYN5VPtIWZQqdTRuBPZiW3skhU3hymB17XSlLVC4uaBbDZU+/3eHG3p80y+MzZxZqR7Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0",
|
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0",
|
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.0"
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Http": {
|
"Microsoft.Extensions.Http": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==",
|
"contentHash": "34+kcwxPZr3Owk9eZx268+gqGNB8G/8Y96gZHomxam0IOH08FhPBjPrLWDtKdVn4+sVUUJnJMpECSTJi4XXCcg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Diagnostics": "8.0.0",
|
"Microsoft.Extensions.Diagnostics": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging": "8.0.0",
|
"Microsoft.Extensions.Logging": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Options": "8.0.0"
|
"Microsoft.Extensions.Options": "9.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Http.Diagnostics": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "Eeup1LuD5hVk5SsKAuX1D7I9sF380MjrNG10IaaauRLOmrRg8rq2TA8PYTXVBXf3MLkZ6m2xpBqRbZdxf8ygkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.DependencyInjection.AutoActivation": "9.2.0",
|
||||||
|
"Microsoft.Extensions.Http": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Telemetry": "9.2.0",
|
||||||
|
"System.IO.Pipelines": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Logging": {
|
"Microsoft.Extensions.Logging": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
|
"contentHash": "loV/0UNpt2bD+6kCDzFALVE63CDtqzPeC0LAetkdhiEr/tTNbvOlQ7CBResH7BQBd3cikrwiBfaHdyHMFUlc2g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection": "9.0.0",
|
"Microsoft.Extensions.DependencyInjection": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Options": "9.0.0"
|
"Microsoft.Extensions.Options": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Logging.Abstractions": {
|
"Microsoft.Extensions.Logging.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==",
|
"contentHash": "dV9s2Lamc8jSaqhl2BQSPn/AryDIH2sSbQUyLitLXV0ROmsb+SROnn2cH939JFbsNrnf3mIM3GNRKT7P0ldwLg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Logging.Configuration": {
|
"Microsoft.Extensions.Logging.Configuration": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "ixXXV0G/12g6MXK65TLngYN9V5hQQRuV+fZi882WIoVJT7h5JvoYoxTEwCgdqwLjSneqh1O+66gM8sMr9z/rsQ==",
|
"contentHash": "pnwYZE7U6d3Y6iMVqADOAUUMMBGYAQPsT3fMwVr/V1Wdpe5DuVGFcViZavUthSJ5724NmelIl1cYy+kRfKfRPQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration": "8.0.0",
|
"Microsoft.Extensions.Configuration": "9.0.2",
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
|
"Microsoft.Extensions.Configuration.Binder": "9.0.2",
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging": "8.0.0",
|
"Microsoft.Extensions.Logging": "9.0.2",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Options": "8.0.0",
|
"Microsoft.Extensions.Options": "9.0.2",
|
||||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
|
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.ObjectPool": {
|
"Microsoft.Extensions.ObjectPool": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "7.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "udvKco0sAVgYGTBnHUb0tY9JQzJ/nPDiv/8PIyz69wl1AibeCDZOLVVI+6156dPfHmJH7ws5oUJRiW4ZmAvuuA=="
|
"contentHash": "nWx7uY6lfkmtpyC2dGc0IxtrZZs/LnLCQHw3YYQucbqWj8a27U/dZ+eh72O3ZiolqLzzLkVzoC+w/M8dZwxRTw=="
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Options": {
|
"Microsoft.Extensions.Options": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==",
|
"contentHash": "zr98z+AN8+isdmDmQRuEJ/DAKZGUTHmdv3t0ZzjHvNqvA44nAgkXE9kYtfoN6581iALChhVaSw2Owt+Z2lVbkQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Primitives": "9.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==",
|
"contentHash": "OPm1NXdMg4Kb4Kz+YHdbBQfekh7MqQZ7liZ5dYUd+IbJakinv9Fl7Ck6Strbgs0a6E76UGbP/jHR532K/7/feQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
|
"Microsoft.Extensions.Configuration.Binder": "9.0.2",
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2",
|
||||||
"Microsoft.Extensions.Options": "8.0.0",
|
"Microsoft.Extensions.Options": "9.0.2",
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Primitives": {
|
"Microsoft.Extensions.Primitives": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg=="
|
"contentHash": "puBMtKe/wLuYa7H6docBkLlfec+h8L35DXqsDKKJgW0WY5oCwJ3cBJKcDaZchv6knAyqOMfsl6VUbaR++E5LXA=="
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Resilience": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "dyaM+Jeznh/i21bOrrRs3xceFfn0571EOjOq95dRXmL1rHDLC4ExhACJ2xipRBP6g1AgRNqmryi+hMrVWWgmlg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Diagnostics": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "9.2.0",
|
||||||
|
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Telemetry.Abstractions": "9.2.0",
|
||||||
|
"Polly.Extensions": "8.4.2",
|
||||||
|
"Polly.RateLimiting": "8.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Telemetry": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "4+bw7W4RrAMrND9TxonnSmzJOdXiPxljoda8OPJiReIN607mKCc0t0Mf28sHNsTujO1XQw28wsI0poxeeQxohw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.AmbientMetadata.Application": "9.2.0",
|
||||||
|
"Microsoft.Extensions.DependencyInjection.AutoActivation": "9.2.0",
|
||||||
|
"Microsoft.Extensions.Logging.Configuration": "9.0.2",
|
||||||
|
"Microsoft.Extensions.ObjectPool": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Telemetry.Abstractions": "9.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Telemetry.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.2.0",
|
||||||
|
"contentHash": "kEl+5G3RqS20XaEhHh/nOugcjKEK+rgVtMJra1iuwNzdzQXElelf3vu8TugcT7rIZ/T4T76EKW1OX/fmlxz4hw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Compliance.Abstractions": "9.2.0",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.2",
|
||||||
|
"Microsoft.Extensions.ObjectPool": "9.0.2",
|
||||||
|
"Microsoft.Extensions.Options": "9.0.2"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.NETCore.Platforms": {
|
"Microsoft.NETCore.Platforms": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
@ -662,35 +832,67 @@
|
||||||
},
|
},
|
||||||
"Npgsql": {
|
"Npgsql": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.2",
|
"resolved": "9.0.3",
|
||||||
"contentHash": "hCbO8box7i/XXiTFqCJ3GoowyLqx3JXxyrbOJ6om7dr+eAknvBNhhUHeJVGAQo44sySZTfdVffp4BrtPeLZOAA==",
|
"contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.2"
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Npgsql.NodaTime": {
|
"Npgsql.NodaTime": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "9.0.2",
|
"resolved": "9.0.3",
|
||||||
"contentHash": "jURb6VGmmR3pPae2N3HrUixSZ/U5ovqZgg/qo3m5Rq/q0m2fpxbZcsHZo21s5MLa/AfJAx4hcFMY98D4RtLdcg==",
|
"contentHash": "PMWXCft/iw+5A7eCeMcy6YZXBst6oeisbCkv2JMQVG4SAFa5vQaf6K2voXzUJCqzwOFcCWs+oT42w2uMDFpchw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"NodaTime": "3.2.0",
|
"NodaTime": "3.2.0",
|
||||||
"Npgsql": "9.0.2"
|
"Npgsql": "9.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Pipelines.Sockets.Unofficial": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.2.8",
|
||||||
|
"contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"System.IO.Pipelines": "5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Polly.Core": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.4.2",
|
||||||
|
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
|
||||||
|
},
|
||||||
|
"Polly.Extensions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.4.2",
|
||||||
|
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
|
||||||
|
"Microsoft.Extensions.Options": "8.0.0",
|
||||||
|
"Polly.Core": "8.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Polly.RateLimiting": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.4.2",
|
||||||
|
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Polly.Core": "8.4.2",
|
||||||
|
"System.Threading.RateLimiting": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Sentry": {
|
"Sentry": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "4.13.0",
|
"resolved": "5.3.0",
|
||||||
"contentHash": "Wfw3M1WpFcrYaGzPm7QyUTfIOYkVXQ1ry6p4WYjhbLz9fPwV23SGQZTFDpdox67NHM0V0g1aoQ4YKLm4ANtEEg=="
|
"contentHash": "zlBIP7YmYxySwcgapLMj1gdxPEz9rwdrOa4Yjub/TzcAaMQXusRH9hY4CE6pu0EIibZ7C7Hhjhr6xOTlyK8gFQ=="
|
||||||
},
|
},
|
||||||
"Sentry.Extensions.Logging": {
|
"Sentry.Extensions.Logging": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "4.13.0",
|
"resolved": "5.3.0",
|
||||||
"contentHash": "yZ5+TtJKWcss6cG17YjnovImx4X56T8O6Qy6bsMC8tMDttYy8J7HJ2F+WdaZNyjOCo0Rfi6N2gc+Clv/5pf+TQ==",
|
"contentHash": "DPN6NXvO4LTH21UM2gUFJwSwVa/fuT3B/UZmQyfSfecqViXrZO7WFuKz/h592YUoGNCumyt8x045bxbz6j9btg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
|
"Microsoft.Extensions.Configuration.Binder": "9.0.0",
|
||||||
"Microsoft.Extensions.Http": "8.0.0",
|
"Microsoft.Extensions.Http": "9.0.0",
|
||||||
"Microsoft.Extensions.Logging.Configuration": "8.0.0",
|
"Microsoft.Extensions.Logging.Configuration": "9.0.0",
|
||||||
"Sentry": "4.13.0"
|
"Sentry": "5.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Serilog.Extensions.Hosting": {
|
"Serilog.Extensions.Hosting": {
|
||||||
|
@ -818,8 +1020,8 @@
|
||||||
},
|
},
|
||||||
"System.IO.Pipelines": {
|
"System.IO.Pipelines": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "7.0.0",
|
"resolved": "9.0.2",
|
||||||
"contentHash": "jRn6JYnNPW6xgQazROBLSfpdoczRw694vO5kKvMcNnpXuolEixUyw6IBuBs2Y2mlSX/LdLvyyWmfXhaI3ND1Yg=="
|
"contentHash": "UIBaK7c/A3FyQxmX/747xw4rCUkm1BhNiVU617U5jweNJssNjLJkPUGhBsrlDG0BpKWCYKsncD+Kqpy4KmvZZQ=="
|
||||||
},
|
},
|
||||||
"System.Reactive": {
|
"System.Reactive": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
@ -857,6 +1059,11 @@
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "7.0.0",
|
"resolved": "7.0.0",
|
||||||
"contentHash": "qmeeYNROMsONF6ndEZcIQ+VxR4Q/TX/7uIVLJqtwIWL7dDWeh0l1UIqgo4wYyjG//5lUNhwkLDSFl+pAWO6oiA=="
|
"contentHash": "qmeeYNROMsONF6ndEZcIQ+VxR4Q/TX/7uIVLJqtwIWL7dDWeh0l1UIqgo4wYyjG//5lUNhwkLDSFl+pAWO6oiA=="
|
||||||
|
},
|
||||||
|
"System.Threading.RateLimiting": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.0.0",
|
||||||
|
"contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2
Foxnouns.Backend/static-pages/.gitignore
vendored
Normal file
2
Foxnouns.Backend/static-pages/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
|
@ -12,9 +12,9 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapper" Version="2.1.35"/>
|
<PackageReference Include="Dapper" Version="2.1.66"/>
|
||||||
<PackageReference Include="Npgsql" Version="9.0.2"/>
|
<PackageReference Include="Npgsql" Version="9.0.3"/>
|
||||||
<PackageReference Include="Npgsql.NodaTime" Version="9.0.2"/>
|
<PackageReference Include="Npgsql.NodaTime" Version="9.0.3"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.DataMigrator.Models;
|
using Foxnouns.DataMigrator.Models;
|
||||||
using NodaTime.Extensions;
|
using NodaTime.Extensions;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
@ -39,6 +39,7 @@ public class UserMigrator(
|
||||||
_user = new User
|
_user = new User
|
||||||
{
|
{
|
||||||
Id = goUser.SnowflakeId,
|
Id = goUser.SnowflakeId,
|
||||||
|
LegacyId = goUser.Id,
|
||||||
Username = goUser.Username,
|
Username = goUser.Username,
|
||||||
DisplayName = goUser.DisplayName,
|
DisplayName = goUser.DisplayName,
|
||||||
Bio = goUser.Bio,
|
Bio = goUser.Bio,
|
||||||
|
@ -139,6 +140,7 @@ public class UserMigrator(
|
||||||
new PrideFlag
|
new PrideFlag
|
||||||
{
|
{
|
||||||
Id = flag.SnowflakeId,
|
Id = flag.SnowflakeId,
|
||||||
|
LegacyId = flag.Id,
|
||||||
UserId = _user!.Id,
|
UserId = _user!.Id,
|
||||||
Hash = flag.Hash,
|
Hash = flag.Hash,
|
||||||
Name = flag.Name,
|
Name = flag.Name,
|
||||||
|
@ -190,6 +192,7 @@ public class UserMigrator(
|
||||||
UserId = _user!.Id,
|
UserId = _user!.Id,
|
||||||
Name = goMember.Name,
|
Name = goMember.Name,
|
||||||
Sid = goMember.Sid,
|
Sid = goMember.Sid,
|
||||||
|
LegacyId = goMember.Id,
|
||||||
DisplayName = goMember.DisplayName,
|
DisplayName = goMember.DisplayName,
|
||||||
Bio = goMember.Bio,
|
Bio = goMember.Bio,
|
||||||
Avatar = goMember.Avatar,
|
Avatar = goMember.Avatar,
|
||||||
|
@ -235,6 +238,7 @@ public class UserMigrator(
|
||||||
"small" => PreferenceSize.Small,
|
"small" => PreferenceSize.Small,
|
||||||
_ => PreferenceSize.Normal,
|
_ => PreferenceSize.Normal,
|
||||||
},
|
},
|
||||||
|
LegacyId = new Guid(id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -256,6 +260,6 @@ public class UserMigrator(
|
||||||
{
|
{
|
||||||
if (_preferenceIds.TryGetValue(id, out Snowflake preferenceId))
|
if (_preferenceIds.TryGetValue(id, out Snowflake preferenceId))
|
||||||
return preferenceId.ToString();
|
return preferenceId.ToString();
|
||||||
return ValidationUtils.DefaultStatusOptions.Contains(id) ? id : "okay";
|
return ValidationService.DefaultStatusOptions.Contains(id) ? id : "okay";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,18 @@
|
||||||
# Example .env file--DO NOT EDIT
|
# Example .env file--DO NOT EDIT, copy to .env or .env.local then edit
|
||||||
|
|
||||||
|
# The language the frontend will use. Valid languages are listed in src/lib/i18n/index.ts.
|
||||||
PUBLIC_LANGUAGE=en
|
PUBLIC_LANGUAGE=en
|
||||||
|
# The public base URL, i.e. the one users will see. Used for building links.
|
||||||
PUBLIC_BASE_URL=https://pronouns.cc
|
PUBLIC_BASE_URL=https://pronouns.cc
|
||||||
|
# The base URL for the URL shortener service. Used for building short links.
|
||||||
PUBLIC_SHORT_URL=https://prns.cc
|
PUBLIC_SHORT_URL=https://prns.cc
|
||||||
|
# The base public URL for the API. This is (almost) always the public base URL + /api.
|
||||||
PUBLIC_API_BASE=https://pronouns.cc/api
|
PUBLIC_API_BASE=https://pronouns.cc/api
|
||||||
|
# The base *private* URL for the API's rate limiter proxy. The frontend will rewrite API URLs to use this.
|
||||||
|
# In development, you can set this to the same value as $PRIVATE_INTERNAL_API_HOST, but be aware that this will disable rate limiting.
|
||||||
PRIVATE_API_HOST=http://localhost:5003/api
|
PRIVATE_API_HOST=http://localhost:5003/api
|
||||||
PRIVATE_INTERNAL_API_HOST=http://localhost:5000/api
|
# The base private URL for the API, which bypasses the rate limiter. Used for /api/internal paths and unauthenticated GET requests.
|
||||||
|
PRIVATE_INTERNAL_API_HOST=http://localhost:6000/api
|
||||||
|
|
||||||
|
# The Sentry URL to use. Optional.
|
||||||
|
PRIVATE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM docker.io/node:22-slim
|
FROM docker.io/node:23-slim
|
||||||
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
|
@ -13,14 +13,15 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.10",
|
"@sveltejs/adapter-node": "^5.2.10",
|
||||||
"@sveltejs/kit": "^2.11.1",
|
"@sveltejs/kit": "^2.12.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/sanitize-html": "^2.13.0",
|
"@types/sanitize-html": "^2.13.0",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
|
@ -28,17 +29,19 @@
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.2",
|
"prettier-plugin-svelte": "^3.3.2",
|
||||||
"sass": "^1.83.0",
|
"sass": "^1.83.0",
|
||||||
"svelte": "^5.13.0",
|
"svelte": "^5.14.3",
|
||||||
"svelte-bootstrap-icons": "^3.1.1",
|
"svelte-bootstrap-icons": "^3.1.1",
|
||||||
"svelte-check": "^4.1.1",
|
"svelte-check": "^4.1.1",
|
||||||
|
"svelte-easy-crop": "^4.0.0",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.18.0",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^5.4.11"
|
"vite": "^6.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
|
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/firago": "^5.1.0",
|
"@fontsource/firago": "^5.1.0",
|
||||||
|
"@sentry/sveltekit": "^8.52.0",
|
||||||
"base64-arraybuffer": "^1.0.2",
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
|
|
2068
Foxnouns.Frontend/pnpm-lock.yaml
generated
2068
Foxnouns.Frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
10
Foxnouns.Frontend/src/app.d.ts
vendored
10
Foxnouns.Frontend/src/app.d.ts
vendored
|
@ -1,7 +1,17 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
|
||||||
|
import type { ErrorCode } from "$api/error";
|
||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
code: ErrorCode;
|
||||||
|
errors?: Array<{ key: string; errors: ValidationError[] }>;
|
||||||
|
error_id?: string;
|
||||||
|
}
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
|
|
|
@ -64,3 +64,11 @@
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.big-footer {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background-color: bootstrap.shade-color(bootstrap.$dark, 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
background-color: bootstrap.shade-color(bootstrap.$light, 5%);
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
|
import ApiError, { ErrorCode } from "$api/error";
|
||||||
import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private";
|
import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private";
|
||||||
|
import { env } from "$env/dynamic/private";
|
||||||
import { PUBLIC_API_BASE } from "$env/static/public";
|
import { PUBLIC_API_BASE } from "$env/static/public";
|
||||||
import type { HandleFetch } from "@sveltejs/kit";
|
import log from "$lib/log";
|
||||||
|
import type { HandleFetch, HandleServerError } from "@sveltejs/kit";
|
||||||
|
import * as Sentry from "@sentry/sveltekit";
|
||||||
|
|
||||||
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
||||||
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
|
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
|
||||||
|
@ -11,3 +15,33 @@ export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
||||||
|
|
||||||
return await fetch(request);
|
return await fetch(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: env.PRIVATE_SENTRY_DSN,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const handleError: HandleServerError = async ({ error, status, message }) => {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return {
|
||||||
|
status: error.raw?.status || status,
|
||||||
|
message: error.raw?.message || "Unknown error",
|
||||||
|
code: error.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 400 && status <= 499) {
|
||||||
|
return { status, message, code: ErrorCode.GenericApiError };
|
||||||
|
}
|
||||||
|
|
||||||
|
// client errors and backend API errors just clog up sentry, so we don't send those.
|
||||||
|
const id = Sentry.captureException(error, {
|
||||||
|
mechanism: {
|
||||||
|
type: "sveltekit",
|
||||||
|
handled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.error("[%s] error in handler:", id, error);
|
||||||
|
|
||||||
|
return { error_id: id, status, message, code: ErrorCode.InternalServerError };
|
||||||
|
};
|
||||||
|
|
|
@ -4,10 +4,12 @@ import type { AddAccountResponse, CallbackResponse } from "$api/models";
|
||||||
import { setToken } from "$lib";
|
import { setToken } from "$lib";
|
||||||
import log from "$lib/log";
|
import log from "$lib/log";
|
||||||
import { isRedirect, redirect, type ServerLoadEvent } from "@sveltejs/kit";
|
import { isRedirect, redirect, type ServerLoadEvent } from "@sveltejs/kit";
|
||||||
|
import type { TicketData } from "../../routes/auth/callback/register/[ticket]/+page.server";
|
||||||
|
|
||||||
export default function createCallbackLoader(
|
export default function createCallbackLoader(
|
||||||
callbackType: string,
|
callbackType: string,
|
||||||
bodyFn?: (event: ServerLoadEvent) => Promise<unknown>,
|
bodyFn?: (event: ServerLoadEvent) => Promise<unknown>,
|
||||||
|
returnData?: boolean,
|
||||||
) {
|
) {
|
||||||
return async (event: ServerLoadEvent) => {
|
return async (event: ServerLoadEvent) => {
|
||||||
const { parent, fetch, cookies } = event;
|
const { parent, fetch, cookies } = event;
|
||||||
|
@ -53,12 +55,23 @@ export default function createCallbackLoader(
|
||||||
redirect(303, `/@${resp.user!.username}`);
|
redirect(303, `/@${resp.user!.username}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (returnData)
|
||||||
return {
|
return {
|
||||||
hasAccount: false,
|
|
||||||
isLinkRequest: false,
|
|
||||||
ticket: resp.ticket!,
|
ticket: resp.ticket!,
|
||||||
remoteUser: resp.remote_username!,
|
remoteUser: resp.remote_username!,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ticket = btoa(
|
||||||
|
JSON.stringify({
|
||||||
|
type: callbackType,
|
||||||
|
ticket: resp.ticket!,
|
||||||
|
remoteUsername: resp.remote_username!,
|
||||||
|
} satisfies TicketData),
|
||||||
|
)
|
||||||
|
.replaceAll("+", "-")
|
||||||
|
.replaceAll("/", "_");
|
||||||
|
|
||||||
|
redirect(303, "/auth/callback/register/" + ticket);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isRedirect(e)) throw e;
|
if (isRedirect(e)) throw e;
|
||||||
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
||||||
|
|
90
Foxnouns.Frontend/src/lib/actions/modaction.ts
Normal file
90
Foxnouns.Frontend/src/lib/actions/modaction.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||||
|
import type { AuditLogEntry, ClearableField } from "$api/models/moderation";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import { type RequestEvent } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
type ModactionResponse = { ok: boolean; resp: AuditLogEntry | null; error: RawApiError | null };
|
||||||
|
type ModactionFunction = (evt: RequestEvent) => Promise<ModactionResponse>;
|
||||||
|
|
||||||
|
export default function createModactionAction(
|
||||||
|
type: "ignore" | "warn" | "suspend",
|
||||||
|
requireReason: boolean,
|
||||||
|
): ModactionFunction {
|
||||||
|
return async function ({ request, fetch, cookies }) {
|
||||||
|
const body = await request.formData();
|
||||||
|
const userId = body.get("user") as string;
|
||||||
|
const memberId = body.get("member") as string | null;
|
||||||
|
const reportId = body.get("report") as string | null;
|
||||||
|
const reason = body.get("reason") as string | null;
|
||||||
|
|
||||||
|
if (!reportId && type === "ignore") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
resp: null,
|
||||||
|
error: {
|
||||||
|
status: 400,
|
||||||
|
message: "Bad request",
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
errors: [
|
||||||
|
{ key: "report", errors: [{ message: "Ignoring a report requires a report ID" }] },
|
||||||
|
],
|
||||||
|
} satisfies RawApiError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reason && requireReason) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
resp: null,
|
||||||
|
error: {
|
||||||
|
status: 400,
|
||||||
|
message: "Bad request",
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
errors: [{ key: "reason", errors: [{ message: "You must give a reason" }] }],
|
||||||
|
} satisfies RawApiError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let clearFields: ClearableField[] | undefined = undefined;
|
||||||
|
if (type === "warn") {
|
||||||
|
clearFields = body.getAll("clear-fields") as ClearableField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let path: string;
|
||||||
|
if (type === "warn") path = `/moderation/warnings/${userId}`;
|
||||||
|
else if (type === "suspend") path = `/moderation/suspensions/${userId}`;
|
||||||
|
else path = `/moderation/reports/${reportId}/ignore`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<AuditLogEntry>("POST", path, {
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
body: {
|
||||||
|
reason: reason,
|
||||||
|
// These are ignored by POST /reports/{id}/ignore
|
||||||
|
member_id: memberId,
|
||||||
|
report_id: reportId,
|
||||||
|
// This is ignored by everything but POST /warnings/{id}
|
||||||
|
clear_fields: clearFields,
|
||||||
|
// This is ignored by everything but POST /suspensions/{id}
|
||||||
|
clear_profile: !!body.get("clear-profile"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, resp, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { ok: false, error: e.obj, resp: null };
|
||||||
|
log.error("could not take action on %s:", path, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModactions() {
|
||||||
|
return {
|
||||||
|
ignore: createModactionAction("ignore", false),
|
||||||
|
warn: createModactionAction("warn", true),
|
||||||
|
suspend: createModactionAction("suspend", true),
|
||||||
|
};
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ export default class ApiError {
|
||||||
|
|
||||||
toObject(): RawApiError {
|
toObject(): RawApiError {
|
||||||
return {
|
return {
|
||||||
|
error_id: this.raw?.error_id,
|
||||||
status: this.raw?.status || 500,
|
status: this.raw?.status || 500,
|
||||||
code: this.code,
|
code: this.code,
|
||||||
message: this.raw?.message || "Internal server error",
|
message: this.raw?.message || "Internal server error",
|
||||||
|
@ -23,6 +24,7 @@ export default class ApiError {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RawApiError = {
|
export type RawApiError = {
|
||||||
|
error_id?: string;
|
||||||
status: number;
|
status: number;
|
||||||
message: string;
|
message: string;
|
||||||
code: ErrorCode;
|
code: ErrorCode;
|
||||||
|
@ -41,6 +43,7 @@ export enum ErrorCode {
|
||||||
MemberNotFound = "MEMBER_NOT_FOUND",
|
MemberNotFound = "MEMBER_NOT_FOUND",
|
||||||
AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED",
|
AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED",
|
||||||
LastAuthMethod = "LAST_AUTH_METHOD",
|
LastAuthMethod = "LAST_AUTH_METHOD",
|
||||||
|
PageNotFound = "PAGE_NOT_FOUND",
|
||||||
// This code isn't actually returned by the API
|
// This code isn't actually returned by the API
|
||||||
Non204Response = "(non 204 response)",
|
Non204Response = "(non 204 response)",
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
/**
|
/**
|
||||||
* Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`.
|
* Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`.
|
||||||
*/
|
*/
|
||||||
export type RequestArgs = {
|
export type RequestArgs<T> = {
|
||||||
/**
|
/**
|
||||||
* The token for this request. Where possible, `cookies` should be passed instead.
|
* The token for this request. Where possible, `cookies` should be passed instead.
|
||||||
* Will override `cookies` if both are passed.
|
* Will override `cookies` if both are passed.
|
||||||
|
@ -23,7 +23,7 @@ export type RequestArgs = {
|
||||||
/**
|
/**
|
||||||
* The body for this request, which will be serialized to JSON. Should be a plain JS object.
|
* The body for this request, which will be serialized to JSON. Should be a plain JS object.
|
||||||
*/
|
*/
|
||||||
body?: unknown;
|
body?: T;
|
||||||
/**
|
/**
|
||||||
* The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests.
|
* The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests.
|
||||||
*/
|
*/
|
||||||
|
@ -41,10 +41,10 @@ export type RequestArgs = {
|
||||||
* @param args Optional arguments to the request function.
|
* @param args Optional arguments to the request function.
|
||||||
* @returns A Response object.
|
* @returns A Response object.
|
||||||
*/
|
*/
|
||||||
export async function baseRequest(
|
export async function baseRequest<T = unknown>(
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
args: RequestArgs = {},
|
args: RequestArgs<T> = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
|
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
|
||||||
|
|
||||||
|
@ -72,19 +72,20 @@ export async function baseRequest(
|
||||||
* @param args Optional arguments to the request function.
|
* @param args Optional arguments to the request function.
|
||||||
* @returns The response deserialized as `T`.
|
* @returns The response deserialized as `T`.
|
||||||
*/
|
*/
|
||||||
export async function apiRequest<T>(
|
export async function apiRequest<TResponse, TRequest = unknown>(
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
args: RequestArgs = {},
|
args: RequestArgs<TRequest> = {},
|
||||||
): Promise<T> {
|
): Promise<TResponse> {
|
||||||
const resp = await baseRequest(method, path, args);
|
const resp = await baseRequest(method, path, args);
|
||||||
|
|
||||||
if (resp.status < 200 || resp.status > 299) {
|
if (resp.status < 200 || resp.status > 299) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
|
log.error("Received error for request to %s %s:", method, path, err);
|
||||||
if ("code" in err) throw new ApiError(err);
|
if ("code" in err) throw new ApiError(err);
|
||||||
else throw new ApiError();
|
else throw new ApiError();
|
||||||
}
|
}
|
||||||
return (await resp.json()) as T;
|
return (await resp.json()) as TResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -94,10 +95,10 @@ export async function apiRequest<T>(
|
||||||
* @param args Optional arguments to the request function.
|
* @param args Optional arguments to the request function.
|
||||||
* @param enforce204 Whether to throw an error on a non-204 status code.
|
* @param enforce204 Whether to throw an error on a non-204 status code.
|
||||||
*/
|
*/
|
||||||
export async function fastRequest(
|
export async function fastRequest<T = unknown>(
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
args: RequestArgs = {},
|
args: RequestArgs<T> = {},
|
||||||
enforce204: boolean = false,
|
enforce204: boolean = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = await baseRequest(method, path, args);
|
const resp = await baseRequest(method, path, args);
|
||||||
|
|
|
@ -10,6 +10,7 @@ export type Meta = {
|
||||||
};
|
};
|
||||||
members: number;
|
members: number;
|
||||||
limits: Limits;
|
limits: Limits;
|
||||||
|
notice: { id: string; message: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Limits = {
|
export type Limits = {
|
||||||
|
|
123
Foxnouns.Frontend/src/lib/api/models/moderation.ts
Normal file
123
Foxnouns.Frontend/src/lib/api/models/moderation.ts
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import type { Member } from "./member";
|
||||||
|
import type { AuthMethod, PartialMember, PartialUser, User } from "./user";
|
||||||
|
|
||||||
|
export type CreateReportRequest = {
|
||||||
|
reason: ReportReason;
|
||||||
|
context: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum ReportReason {
|
||||||
|
Totalitarianism = "TOTALITARIANISM",
|
||||||
|
HateSpeech = "HATE_SPEECH",
|
||||||
|
Racism = "RACISM",
|
||||||
|
Homophobia = "HOMOPHOBIA",
|
||||||
|
Transphobia = "TRANSPHOBIA",
|
||||||
|
Queerphobia = "QUEERPHOBIA",
|
||||||
|
Exclusionism = "EXCLUSIONISM",
|
||||||
|
Sexism = "SEXISM",
|
||||||
|
Ableism = "ABLEISM",
|
||||||
|
ChildPornography = "CHILD_PORNOGRAPHY",
|
||||||
|
PedophiliaAdvocacy = "PEDOPHILIA_ADVOCACY",
|
||||||
|
Harassment = "HARASSMENT",
|
||||||
|
Impersonation = "IMPERSONATION",
|
||||||
|
Doxxing = "DOXXING",
|
||||||
|
EncouragingSelfHarm = "ENCOURAGING_SELF_HARM",
|
||||||
|
Spam = "SPAM",
|
||||||
|
Trolling = "TROLLING",
|
||||||
|
Advertisement = "ADVERTISEMENT",
|
||||||
|
CopyrightViolation = "COPYRIGHT_VIOLATION",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Report = {
|
||||||
|
id: string;
|
||||||
|
reporter: PartialUser;
|
||||||
|
target_user: PartialUser;
|
||||||
|
target_member?: PartialMember;
|
||||||
|
status: "OPEN" | "CLOSED";
|
||||||
|
reason: ReportReason;
|
||||||
|
context: string | null;
|
||||||
|
target_type: "USER" | "MEMBER";
|
||||||
|
snapshot: User | Member | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogEntry = {
|
||||||
|
id: string;
|
||||||
|
moderator: AuditLogEntity;
|
||||||
|
target_user?: AuditLogEntity;
|
||||||
|
target_member?: AuditLogEntity;
|
||||||
|
report?: PartialReport;
|
||||||
|
type: AuditLogEntryType;
|
||||||
|
reason: string | null;
|
||||||
|
cleared_fields?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogEntity = { id: string; username: string };
|
||||||
|
|
||||||
|
export enum AuditLogEntryType {
|
||||||
|
IgnoreReport = "IGNORE_REPORT",
|
||||||
|
WarnUser = "WARN_USER",
|
||||||
|
WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
|
||||||
|
SuspendUser = "SUSPEND_USER",
|
||||||
|
QuerySensitiveUserData = "QUERY_SENSITIVE_USER_DATA",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PartialReport = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
target_user_id: string;
|
||||||
|
target_member_id?: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
context: string | null;
|
||||||
|
target_type: "USER" | "MEMBER";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportDetails = {
|
||||||
|
report: Report;
|
||||||
|
user: User;
|
||||||
|
member?: Member;
|
||||||
|
audit_log_entry?: AuditLogEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueriedUser = {
|
||||||
|
user: User;
|
||||||
|
member_list_hidden: boolean;
|
||||||
|
last_active: string;
|
||||||
|
last_sid_reroll: string;
|
||||||
|
suspended: boolean;
|
||||||
|
deleted: boolean;
|
||||||
|
auth_methods?: AuthMethod[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WarnUserRequest = {
|
||||||
|
reason: string;
|
||||||
|
clear_fields?: ClearableField[];
|
||||||
|
member_id?: string;
|
||||||
|
report_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuspendUserRequest = {
|
||||||
|
reason: string;
|
||||||
|
clear_profile: boolean;
|
||||||
|
report_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum ClearableField {
|
||||||
|
DisplayName = "DISPLAY_NAME",
|
||||||
|
Avatar = "AVATAR",
|
||||||
|
Bio = "BIO",
|
||||||
|
Links = "LINKS",
|
||||||
|
Names = "NAMES",
|
||||||
|
Pronouns = "PRONOUNS",
|
||||||
|
Fields = "FIELDS",
|
||||||
|
Flags = "FLAGS",
|
||||||
|
CustomPreferences = "CUSTOM_PREFERENCES",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Notification = {
|
||||||
|
id: string;
|
||||||
|
type: "NOTICE" | "WARNING" | "SUSPENSION";
|
||||||
|
message?: string;
|
||||||
|
localization_key?: string;
|
||||||
|
localization_params: Record<string, string>;
|
||||||
|
acknowledged: boolean;
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue