Compare commits

...

58 commits

Author SHA1 Message Date
sam
b0431ff962
feat(frontend): global notices 2025-04-06 16:24:05 +02:00
sam
b07f4b75c0
feat(backend): global notices 2025-04-06 15:32:44 +02:00
sam
22be49976a
feat(backend): return settings in GET /users/@me 2025-04-06 15:32:26 +02:00
sam
3527acb8ba
feat: add pre-built docker images 2025-03-18 15:38:06 +01:00
sam
978b8e100e
remove unused MetricsAddress from config 2025-03-18 15:03:03 +01:00
sam
f00f5b400e
feat(frontend): allow configuring assets url 2025-03-17 22:46:44 +01:00
sam
f5f0416346
refactor(backend): misc cleanup 2025-03-13 15:18:35 +01:00
sam
5d452824cd
refactor(backend): use single shared HTTP client with backoff 2025-03-11 16:15:11 +01:00
sam
bba322bd22
chore(backend): update dependencies 2025-03-08 23:46:46 +01:00
sam
200e648772
fix(backend): update User.LastActive in more places 2025-03-05 15:40:13 +01:00
sam
790b39f730
fix(frontend): consistency in the editor 2025-03-05 15:13:44 +01:00
sam
7d0df67c06
fix(frontend): fix moving pronouns 2025-03-05 15:13:26 +01:00
sam
dd9d35249c
feat(frontend): notifications 2025-03-05 01:18:21 +01:00
sam
f99d10ecf0
fix(backend): don't hardcode redis URL, add redis to docker compose 2025-03-04 17:25:07 +01:00
sam
7759225428
refactor(backend): replace coravel with hangfire for background jobs
for *some reason*, coravel locks a persistent job queue behind a
paywall. this means that if the server ever crashes, all pending jobs
are lost. this is... not good, so we're switching to hangfire for that
instead.

coravel is still used for emails, though.

BREAKING CHANGE: Foxnouns.NET now requires Redis to work. the EFCore
storage for hangfire doesn't work well enough, unfortunately.
2025-03-04 17:03:39 +01:00
sam
cd24196cd1
chore(backend): format 2025-02-28 16:53:53 +01:00
sam
7d6d4631b8
fix(frontend): don't reference email auth in text if it's disabled 2025-02-28 16:50:57 +01:00
sam
a248536789
fix typo in DOCKER.md 2025-02-28 16:47:21 +01:00
sam
218c756a70
feat(backend): make field limits configurable 2025-02-28 16:37:15 +01:00
sam
7ea6c62d67
chore(backend): update dependencies 2025-02-28 16:36:45 +01:00
sam
64ea25e89e
feat(frontend): avatar cropping 2025-02-24 21:32:20 +01:00
sam
f1f777ff82
fix(frontend): localize footer 2025-02-24 20:37:51 +01:00
sam
a72c0f41c3
add build script 2025-02-24 18:25:49 +01:00
sam
6fe816404f
rename rate/ to Foxnouns.RateLimiter/ for consistency 2025-02-24 17:47:37 +01:00
sam
d1faf1ddee
feat(frontend): store pending profile changes in sessionStorage 2025-02-24 17:37:49 +01:00
sam
92bf933c10
feat(frontend): link custom preferences in profile editor 2025-02-24 17:13:46 +01:00
sam
c8e4078b35
fix: show 404 page if /api/v2/meta/page/{page} can't find a file 2025-02-23 21:42:01 +01:00
sam
0c6e3bf38f
feat(frontend): show closed reports button, add some alerts for auth 2025-02-23 20:02:40 +01:00
sam
30146556f5
chore: update frontend dockerfile to node 23 2025-02-11 14:57:07 +01:00
sam
c47fc41437
feat(frontend): remove auth method 2025-02-11 14:21:40 +01:00
sam
373d97e70a
feat: make some limits configurable 2025-02-07 21:48:50 +01:00
sam
74800b46ef
feat(frontend): don't break signup pages on reload 2025-02-07 20:57:27 +01:00
sam
32e0c09d06
fix(backend): add thousands separators to footer 2025-02-07 20:33:59 +01:00
sam
6bb01f0bf1
feat(frontend): show audit log entry for closed reports 2025-02-03 17:35:34 +01:00
sam
cacd3a30b7
feat: report page, take action on reports 2025-02-03 17:03:32 +01:00
sam
a0ba712632
feat(frontend): show error ID for internal server errors 2025-01-30 02:00:49 +01:00
sam
83b62b4845
chore: update husky/csharpier 2025-01-27 16:26:00 +01:00
sam
045964ffb7
feat(backend): report detail endpoint 2025-01-27 16:25:49 +01:00
sam
8edbc8bf1d
feat(backend): only one sensitive data request per 24 hours 2024-12-29 16:34:11 -05:00
sam
db22e35f0d
feat(frontend): partial user lookup 2024-12-28 11:39:22 -05:00
sam
9d3d46bf33
feat(frontend): show "query sensitive data" in audit log 2024-12-27 17:49:29 -05:00
sam
12eddb9949
feat(backend): user lookup 2024-12-27 17:48:37 -05:00
sam
8713279d3d
raise member limit to 1000 2024-12-27 13:34:54 -05:00
sam
dc9c11ec52
feat: return reports in audit log entries 2024-12-27 13:21:02 -05:00
sam
53006ea313
feat(frontend): audit log 2024-12-26 16:33:32 -05:00
sam
49e9eabea0
refactor(frontend): deduplicate isActive function 2024-12-26 14:10:03 -05:00
sam
5077bd6a0b
fix(backend): return report context in mod api 2024-12-26 14:01:51 -05:00
sam
3f0edc4374
static pages volume in docker-compose.yml 2024-12-26 10:25:00 -05:00
sam
7468aa20ab
feat: static documentation pages 2024-12-25 17:53:31 -05:00
sam
fe1cf7ce8a
feat: GET /api/v1/users/@me 2024-12-25 16:04:32 -05:00
sam
478ba2a406
feat: GET /api/v1/users/{userRef}/members/{memberRef} 2024-12-25 14:53:36 -05:00
sam
78afb8b9c4
feat: GET /api/v1/users/{userRef}/members 2024-12-25 14:33:42 -05:00
sam
e908e67ca6
chore: license headers 2024-12-25 14:24:18 -05:00
sam
d182b07482
feat: GET /api/v1/members/{id}, api v1 flags 2024-12-25 14:23:16 -05:00
sam
2281b3e478
fix: replace port 5000 in example docs with port 6000
macOS runs a service on port 5000 by default. this doesn't actually
prevent the backend server from *starting*, or the rate limiter proxy
from working, but it *does* mean that when the backend restarts, if the
proxy sends a request, it will stop working until it's restarted.

the easiest way to work around this is by just changing the port the
backend listens on. this does not change the ports used in the docker
configuration.
2024-12-25 14:03:15 -05:00
sam
140419a1ca
feat: rate limiter lets api v1 requests through 2024-12-25 12:08:53 -05:00
sam
7791c91960
feat(backend): initial /api/v1/users endpoint 2024-12-25 11:19:50 -05:00
sam
5e7df2e074
feat(frontend): add footer 2024-12-25 11:04:20 -05:00
185 changed files with 7325 additions and 1169 deletions

View file

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

View file

@ -21,3 +21,4 @@
**/values.dev.yaml **/values.dev.yaml
LICENSE LICENSE
README.md README.md
static-pages/*

View file

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

3
.gitignore vendored
View file

@ -14,3 +14,6 @@ docker/proxy-config.json
docker/frontend.env docker/frontend.env
Foxnouns.DataMigrator/apps.json Foxnouns.DataMigrator/apps.json
out/
build/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.Middleware; using Foxnouns.Backend.Middleware;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View file

@ -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;
@ -28,13 +27,8 @@ namespace Foxnouns.Backend.Controllers;
[Authorize("identify")] [Authorize("identify")]
[Limit(UsableByDeletedUsers = true)] [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>();
@ -80,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();
} }
} }

View file

@ -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,8 +29,7 @@ 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]
@ -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));
} }

View file

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

View file

@ -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,8 +36,9 @@ 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>();
@ -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();

View file

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

View file

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

View 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));
}
}

View 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)
);
}

View file

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

View file

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

View file

@ -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,8 +33,8 @@ 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>();
@ -47,7 +46,15 @@ public class UsersController(
{ {
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);

View 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
)
);
}
}

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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");
@ -254,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[]")
@ -292,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");
@ -332,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")
@ -386,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")
@ -398,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");
@ -457,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")
@ -582,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[]")
@ -637,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");
@ -750,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")

View file

@ -41,4 +41,5 @@ public enum AuditLogEntryType
WarnUser, WarnUser,
WarnUserAndClearProfile, WarnUserAndClearProfile,
SuspendUser, SuspendUser,
QuerySensitiveUserData,
} }

View file

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

View 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!;
}

View file

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

View file

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

View file

@ -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
) )
{ {
writer.WriteValue(value.Value.ToString()); if (value != null)
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

View file

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

View file

@ -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,
@ -61,15 +83,17 @@ 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);

View file

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

View 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
);

View 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);

View file

@ -164,6 +164,7 @@ public enum ErrorCode
GenericApiError, GenericApiError,
UserNotFound, UserNotFound,
MemberNotFound, MemberNotFound,
PageNotFound,
AccountAlreadyLinked, AccountAlreadyLinked,
LastAuthMethod, LastAuthMethod,
InvalidReportTarget, InvalidReportTarget,

View file

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

View file

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

View file

@ -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>>());

View file

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

View file

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

View file

@ -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();

View file

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

View file

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

View file

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

View file

@ -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 ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
new BadRequestObjectResult( );
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 =>

View file

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

View file

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

View file

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

View file

@ -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(
ILogger logger,
Config config,
DatabaseContext db,
HttpClient client,
KeyCacheService keyCacheService,
ISnowflakeGenerator snowflakeGenerator
)
{ {
private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0"; private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0";
private readonly ILogger _logger = logger.ForContext<FediverseAuthService>();
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,
Config config,
DatabaseContext db,
KeyCacheService keyCacheService,
ISnowflakeGenerator snowflakeGenerator
)
{
_logger = logger.ForContext<FediverseAuthService>();
_config = config;
_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>();

View file

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

View file

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

View file

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

View file

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

View 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);
}

View 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();
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
);
}
}

View 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)
);
}

View file

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

View file

@ -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.MaxFieldNameLength,
Limits.FieldNameLimit, field.Name.Length
field.Name.Length
)
) )
); )
break; );
case < 1: }
errors.Add( else if (field.Name.Length < 1)
( {
$"fields.{index}.name", errors.Add(
ValidationError.LengthError( (
"Field name is too short", $"fields.{index}.name",
1, ValidationError.LengthError(
Limits.FieldNameLimit, "Field name is too short",
field.Name.Length 1,
) _limits.MaxFieldNameLength,
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.MaxFieldEntryTextLength,
Limits.FieldEntryTextLimit, entry.Value.Length
entry.Value.Length
)
) )
); )
break; );
case < 1: }
errors.Add( else if (entry.Value.Length < 1)
( {
$"{errorPrefix}.{entryIdx}.value", errors.Add(
ValidationError.LengthError( (
"Field value is too short", $"{errorPrefix}.{entryIdx}.value",
1, ValidationError.LengthError(
Limits.FieldEntryTextLimit, "Field value is too short",
entry.Value.Length 1,
) _limits.MaxFieldEntryTextLength,
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.MaxFieldEntryTextLength,
Limits.FieldEntryTextLimit, entry.Value.Length
entry.Value.Length
)
) )
); )
break; );
case < 1: }
errors.Add( else if (entry.Value.Length < 1)
( {
$"{errorPrefix}.{entryIdx}.value", errors.Add(
ValidationError.LengthError( (
"Pronoun value is too short", $"{errorPrefix}.{entryIdx}.value",
1, ValidationError.LengthError(
Limits.FieldEntryTextLimit, "Pronoun value is too short",
entry.Value.Length 1,
) _limits.MaxFieldEntryTextLength,
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.MaxFieldEntryTextLength,
Limits.FieldEntryTextLimit, entry.Value.Length
entry.Value.Length
)
) )
); )
break; );
case < 1: }
errors.Add( else if (entry.DisplayText.Length < 1)
( {
$"{errorPrefix}.{entryIdx}.display_text", errors.Add(
ValidationError.LengthError( (
"Pronoun display text is too short", $"{errorPrefix}.{entryIdx}.display_text",
1, ValidationError.LengthError(
Limits.FieldEntryTextLimit, "Pronoun display text is too short",
entry.Value.Length 1,
) _limits.MaxFieldEntryTextLength,
entry.Value.Length
) )
); )
break; );
} }
} }

View 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();
}

View file

@ -0,0 +1,6 @@
namespace Foxnouns.Backend.Services;
public partial class ValidationService(Config config)
{
private readonly Config.LimitsConfig _limits = config.Limits;
}

View file

@ -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('/', '_');

View file

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

View file

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

View file

@ -12,195 +12,15 @@
// //
// 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 =
[
"..",
"admin",
"administrator",
"mod",
"moderator",
"api",
"page",
"pronouns",
"settings",
"pronouns.cc",
"pronounscc",
];
private static readonly string[] InvalidMemberNames =
[
// these break routing outright
".",
"..",
// 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 MaximumReportContextLength = 512; public const int MaximumReportContextLength = 512;
public static ValidationError? ValidateReportContext(string? context) => public static ValidationError? ValidateReportContext(string? context) =>
context?.Length > MaximumReportContextLength context?.Length > MaximumReportContextLength
? ValidationError.GenericValidationError("Avatar is too large", null) ? ValidationError.GenericValidationError("Report context is too long", null)
: null; : null;
public const int MinimumPasswordLength = 12; public const int MinimumPasswordLength = 12;
@ -223,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();
} }

View file

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

View file

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

View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

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

View file

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

View file

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

View file

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

View file

@ -15,12 +15,13 @@
"@sveltejs/adapter-node": "^5.2.10", "@sveltejs/adapter-node": "^5.2.10",
"@sveltejs/kit": "^2.12.1", "@sveltejs/kit": "^2.12.1",
"@sveltejs/vite-plugin-svelte": "^5.0.2", "@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",
@ -31,6 +32,7 @@
"svelte": "^5.14.3", "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.1", "typescript-eslint": "^8.18.1",
@ -39,6 +41,7 @@
"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",

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,8 @@ declare global {
message: string; message: string;
status: number; status: number;
code: ErrorCode; code: ErrorCode;
id: string; errors?: Array<{ key: string; errors: ValidationError[] }>;
error_id?: string;
} }
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}

View file

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

View file

@ -1,8 +1,10 @@
import ApiError, { ErrorCode } from "$api/error"; 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 log from "$lib/log"; import log from "$lib/log";
import type { HandleFetch, HandleServerError } from "@sveltejs/kit"; 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`)) {
@ -14,12 +16,13 @@ export const handleFetch: HandleFetch = async ({ request, fetch }) => {
return await fetch(request); return await fetch(request);
}; };
export const handleError: HandleServerError = async ({ error, status, message }) => { Sentry.init({
const id = crypto.randomUUID(); dsn: env.PRIVATE_SENTRY_DSN,
});
export const handleError: HandleServerError = async ({ error, status, message }) => {
if (error instanceof ApiError) { if (error instanceof ApiError) {
return { return {
id,
status: error.raw?.status || status, status: error.raw?.status || status,
message: error.raw?.message || "Unknown error", message: error.raw?.message || "Unknown error",
code: error.code, code: error.code,
@ -27,10 +30,18 @@ export const handleError: HandleServerError = async ({ error, status, message })
} }
if (status >= 400 && status <= 499) { if (status >= 400 && status <= 499) {
return { id, status, message, code: ErrorCode.GenericApiError }; 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); log.error("[%s] error in handler:", id, error);
return { id, status, message, code: ErrorCode.InternalServerError }; return { error_id: id, status, message, code: ErrorCode.InternalServerError };
}; };

View file

@ -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}`);
} }
return { if (returnData)
hasAccount: false, return {
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 };

View 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),
};
}

View file

@ -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)",
} }

View file

@ -81,6 +81,7 @@ export async function apiRequest<TResponse, TRequest = unknown>(
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();
} }

View file

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

View file

@ -1,3 +1,6 @@
import type { Member } from "./member";
import type { AuthMethod, PartialMember, PartialUser, User } from "./user";
export type CreateReportRequest = { export type CreateReportRequest = {
reason: ReportReason; reason: ReportReason;
context: string | null; context: string | null;
@ -24,3 +27,97 @@ export enum ReportReason {
Advertisement = "ADVERTISEMENT", Advertisement = "ADVERTISEMENT",
CopyrightViolation = "COPYRIGHT_VIOLATION", 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;
};

View file

@ -28,6 +28,7 @@ export type MeUser = UserWithMembers & {
timezone: string; timezone: string;
suspended: boolean; suspended: boolean;
deleted: boolean; deleted: boolean;
settings: UserSettings;
}; };
export type UserWithMembers = User & { members: PartialMember[] | null }; export type UserWithMembers = User & { members: PartialMember[] | null };
@ -40,6 +41,7 @@ export type UserWithHiddenFields = User & {
export type UserSettings = { export type UserSettings = {
dark_mode: boolean | null; dark_mode: boolean | null;
last_read_notice: string | null;
}; };
export type PartialMember = { export type PartialMember = {

View file

@ -12,12 +12,22 @@
<svelte:element this={headerElem ?? "h4"}> <svelte:element this={headerElem ?? "h4"}>
{#if error.code === ErrorCode.BadRequest} {#if error.code === ErrorCode.BadRequest}
{$t("error.bad-request-header")} {$t("error.bad-request-header")}
{:else if error.status === 404}
{$t("error.not-found-header")}
{:else} {:else}
{$t("error.generic-header")} {$t("error.generic-header")}
{/if} {/if}
</svelte:element> </svelte:element>
{/if} {/if}
<p>{errorDescription($t, error.code)}</p> <p>{errorDescription($t, error.code)}</p>
{#if error.error_id}
<p>
{$t("error.error-id")}
<code>
{error.error_id}
</code>
</p>
{/if}
{#if error.errors} {#if error.errors}
<details> <details>
<summary>{$t("error.extra-info-header")}</summary> <summary>{$t("error.extra-info-header")}</summary>

View file

@ -0,0 +1,84 @@
<script lang="ts">
import type { Meta } from "$api/models";
import Git from "svelte-bootstrap-icons/lib/Git.svelte";
import Reception4 from "svelte-bootstrap-icons/lib/Reception4.svelte";
import Newspaper from "svelte-bootstrap-icons/lib/Newspaper.svelte";
import CardText from "svelte-bootstrap-icons/lib/CardText.svelte";
import Shield from "svelte-bootstrap-icons/lib/Shield.svelte";
import Envelope from "svelte-bootstrap-icons/lib/Envelope.svelte";
import CashCoin from "svelte-bootstrap-icons/lib/CashCoin.svelte";
import Logo from "./Logo.svelte";
import { t } from "$lib/i18n";
type Props = { meta: Meta };
let { meta }: Props = $props();
</script>
<footer class="big-footer mt-3 pt-3 pb-1 px-5">
<div class="d-flex flex-column flex-md-row mb-2">
<div class="align-start flex-grow-1">
<Logo />
<ul class="mt-2 list-unstyled">
<li><strong>{$t("footer.version")}</strong> {meta.version}</li>
</ul>
</div>
<div class="align-end">
<ul class="list-unstyled">
<li>{meta.users.total.toLocaleString()} <strong>{$t("footer.users")}</strong></li>
<li>{meta.members.toLocaleString()} <strong>{$t("footer.members")}</strong></li>
</ul>
</div>
</div>
<ul class="list-inline">
<a
class="list-inline-item link-underline link-underline-opacity-0"
target="_blank"
href={meta.repository}
>
<li class="list-inline-item">
<Git />
{$t("footer.source")}
</li>
</a>
<a
class="list-inline-item link-underline link-underline-opacity-0"
target="_blank"
href="https://status.pronouns.cc"
>
<li class="list-inline-item">
<Reception4 />
{$t("footer.status")}
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/about">
<li class="list-inline-item">
<Envelope />
{$t("footer.about-contact")}
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/tos">
<li class="list-inline-item">
<CardText />
{$t("footer.terms")}
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/privacy">
<li class="list-inline-item">
<Shield />
{$t("footer.privacy")}
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/changelog">
<li class="list-inline-item">
<Newspaper />
{$t("footer.changelog")}
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/donate">
<li class="list-inline-item">
<CashCoin />
{$t("footer.donate")}
</li>
</a>
</ul>
</footer>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { fastRequest } from "$api";
import type { UserSettings } from "$api/models";
import { idTimestamp } from "$lib";
import { t } from "$lib/i18n";
import log from "$lib/log";
import { renderUnsafeMarkdown } from "$lib/markdown";
import { DateTime } from "luxon";
type Props = { id: string; message: string; settings?: UserSettings; token: string | null };
let { id, message, settings, token }: Props = $props();
let lastReadNotice = $state(settings?.last_read_notice || null);
// Render the notice if:
// - user is not logged in (no settings object)
// - last read notice is null (never marked any notice as read)
// - last read notice ID is smaller than the current one (has not marked the current notice as read)
let renderNotice = $derived(!lastReadNotice || lastReadNotice < id);
let canDismiss = $derived(!!token);
let renderedMessage = $derived(renderUnsafeMarkdown(message));
let dismiss = async () => {
if (!token) return;
try {
await fastRequest("PATCH", "/users/@me/settings", { token, body: { last_read_notice: id } });
lastReadNotice = id;
} catch (e) {
log.error("error updating last read notice ID:", e);
}
};
</script>
{#if renderNotice}
<div class="alert alert-light" role="alert">
<div>
{@html renderedMessage}
</div>
{#if canDismiss}
<div>
<!-- svelte-ignore a11y_invalid_attribute -->
<a href="#" tabindex="0" role="button" onclick={() => dismiss()} onkeyup={() => dismiss()}>
{$t("notification.mark-as-read")}
</a>
{idTimestamp(id).toLocaleString(DateTime.DATETIME_MED)}
</div>
{/if}
</div>
{/if}

View file

@ -13,13 +13,21 @@
import Logo from "$components/Logo.svelte"; import Logo from "$components/Logo.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
type Props = { user: MeUser | null; meta: Meta }; type Props = { user: MeUser | null; meta: Meta; unreadNotifications?: boolean };
let { user, meta }: Props = $props(); let { user, meta, unreadNotifications }: Props = $props();
let isOpen = $state(true); let isOpen = $state(true);
const toggleMenu = () => (isOpen = !isOpen); const toggleMenu = () => (isOpen = !isOpen);
</script> </script>
{#if user && unreadNotifications}
<div class="notification-alert text-center py-3 mb-2 px-2">
<strong>{$t("nav.unread-notification-text")}</strong>
<br />
<a href="/settings/notifications">{$t("nav.unread-notification-link")}</a>
</div>
{/if}
{#if user && user.deleted} {#if user && user.deleted}
<div class="deleted-alert text-center py-3 mb-2 px-2"> <div class="deleted-alert text-center py-3 mb-2 px-2">
{#if user.suspended} {#if user.suspended}
@ -58,6 +66,13 @@
@{user.username} @{user.username}
</NavLink> </NavLink>
</NavItem> </NavItem>
{#if user.role === "ADMIN" || user.role === "MODERATOR"}
<NavItem>
<NavLink href="/admin" active={page.url.pathname.startsWith(`/admin`)}>
Administration
</NavLink>
</NavItem>
{/if}
<NavItem> <NavItem>
<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}> <NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
{$t("nav.settings")} {$t("nav.settings")}
@ -80,6 +95,11 @@
background-color: var(--bs-danger-bg-subtle); background-color: var(--bs-danger-bg-subtle);
} }
.notification-alert {
color: var(--bs-warning-text-emphasis);
background-color: var(--bs-warning-bg-subtle);
}
/* These exact values make it look almost identical to the SVG version, which is what we want */ /* These exact values make it look almost identical to the SVG version, which is what we want */
#beta-text { #beta-text {
font-size: 0.7em; font-size: 0.7em;

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { t } from "$lib/i18n";
type Props = { data?: { alertKey?: string }; key?: string };
let props: Props = $props();
let key = $derived(props.key ?? props.data?.alertKey);
</script>
{#if key}
<div class="alert alert-light">
{$t(key)}
</div>
{/if}

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