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