Compare commits
49 commits
v2024.12.2
...
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 |
173 changed files with 6325 additions and 1187 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
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -14,3 +14,6 @@ docker/proxy-config.json
|
|||
docker/frontend.env
|
||||
|
||||
Foxnouns.DataMigrator/apps.json
|
||||
|
||||
out/
|
||||
build/
|
||||
|
|
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);
|
||||
|
|
|
@ -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;
|
||||
|
@ -28,13 +27,8 @@ namespace Foxnouns.Backend.Controllers;
|
|||
[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>();
|
||||
|
@ -80,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;
|
||||
|
@ -30,8 +29,7 @@ namespace Foxnouns.Backend.Controllers;
|
|||
public class FlagsController(
|
||||
DatabaseContext db,
|
||||
UserRendererService userRenderer,
|
||||
ISnowflakeGenerator snowflakeGenerator,
|
||||
IQueue queue
|
||||
ISnowflakeGenerator snowflakeGenerator
|
||||
) : ApiControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
@ -74,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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
@ -37,8 +36,9 @@ 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>();
|
||||
|
@ -65,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")]
|
||||
|
@ -77,26 +75,26 @@ 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
|
||||
|
@ -123,6 +121,9 @@ public class MembersController(
|
|||
CurrentUser!.Id
|
||||
);
|
||||
|
||||
CurrentUser.LastActive = clock.GetCurrentInstant();
|
||||
db.Update(CurrentUser);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
@ -139,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));
|
||||
|
@ -163,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 ?? [];
|
||||
}
|
||||
|
||||
|
@ -191,7 +190,7 @@ public class MembersController(
|
|||
if (req.Names != null)
|
||||
{
|
||||
errors.AddRange(
|
||||
ValidationUtils.ValidateFieldEntries(
|
||||
validationService.ValidateFieldEntries(
|
||||
req.Names,
|
||||
CurrentUser!.CustomPreferences,
|
||||
"names"
|
||||
|
@ -203,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();
|
||||
}
|
||||
|
@ -211,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();
|
||||
}
|
||||
|
@ -228,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
|
||||
|
@ -236,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)
|
||||
);
|
||||
}
|
|
@ -220,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,
|
||||
|
|
|
@ -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,8 +33,8 @@ 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>();
|
||||
|
@ -47,7 +46,15 @@ public class UsersController(
|
|||
{
|
||||
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);
|
||||
|
@ -251,20 +263,12 @@ public class UsersController(
|
|||
}
|
||||
|
||||
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)]
|
||||
|
@ -277,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);
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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");
|
||||
|
@ -343,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")
|
||||
|
@ -479,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")
|
||||
|
@ -783,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")
|
||||
|
|
|
@ -41,4 +41,5 @@ public enum AuditLogEntryType
|
|||
WarnUser,
|
||||
WarnUserAndClearProfile,
|
||||
SuspendUser,
|
||||
QuerySensitiveUserData,
|
||||
}
|
||||
|
|
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!;
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
using NodaTime;
|
||||
|
||||
namespace Foxnouns.Backend.Database.Models;
|
||||
|
||||
public class TemporaryKey
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public required string Value { get; set; }
|
||||
public Instant Expires { get; init; }
|
||||
}
|
|
@ -95,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,
|
||||
|
@ -61,15 +83,17 @@ 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);
|
||||
|
|
|
@ -49,7 +49,8 @@ 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(
|
||||
|
@ -79,6 +80,7 @@ public record PartialUser(
|
|||
public class UpdateUserSettingsRequest : PatchRequest
|
||||
{
|
||||
public bool? DarkMode { get; init; }
|
||||
public Snowflake? LastReadNotice { get; init; }
|
||||
}
|
||||
|
||||
public class CustomPreferenceUpdateRequest
|
||||
|
|
|
@ -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,14 +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;
|
||||
|
@ -51,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)
|
||||
|
@ -97,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)
|
||||
|
@ -112,23 +153,25 @@ 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>();
|
||||
|
@ -156,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,41 +8,46 @@
|
|||
</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>
|
||||
|
|
|
@ -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);
|
|
@ -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)
|
|
@ -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 =>
|
||||
|
|
|
@ -29,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>();
|
||||
|
@ -49,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)),
|
||||
]
|
||||
);
|
||||
|
@ -97,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);
|
||||
|
||||
|
@ -252,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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
@ -117,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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,195 +12,15 @@
|
|||
//
|
||||
// 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",
|
||||
];
|
||||
|
||||
private static readonly string[] InvalidMemberNames =
|
||||
[
|
||||
// these break routing outright
|
||||
".",
|
||||
"..",
|
||||
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
|
||||
"edit",
|
||||
];
|
||||
|
||||
public static ValidationError? ValidateUsername(string username)
|
||||
{
|
||||
if (!UsernameRegex().IsMatch(username))
|
||||
{
|
||||
return username.Length switch
|
||||
{
|
||||
< 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length),
|
||||
> 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length),
|
||||
_ => ValidationError.GenericValidationError(
|
||||
"Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods",
|
||||
username
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
InvalidUsernames.Any(u =>
|
||||
string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase)
|
||||
)
|
||||
)
|
||||
{
|
||||
return ValidationError.GenericValidationError("Username is not allowed", username);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static ValidationError? ValidateMemberName(string memberName)
|
||||
{
|
||||
if (!MemberRegex().IsMatch(memberName))
|
||||
{
|
||||
return memberName.Length switch
|
||||
{
|
||||
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
||||
> 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
|
||||
_ => ValidationError.GenericValidationError(
|
||||
"Member name cannot contain any of the following: "
|
||||
+ " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , "
|
||||
+ "and cannot be one or two periods",
|
||||
memberName
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
InvalidMemberNames.Any(u =>
|
||||
string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase)
|
||||
)
|
||||
)
|
||||
{
|
||||
return ValidationError.GenericValidationError("Name is not allowed", memberName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static ValidationError? ValidateDisplayName(string? displayName)
|
||||
{
|
||||
return displayName?.Length switch
|
||||
{
|
||||
0 => ValidationError.LengthError(
|
||||
"Display name is too short",
|
||||
1,
|
||||
100,
|
||||
displayName.Length
|
||||
),
|
||||
> 100 => ValidationError.LengthError(
|
||||
"Display name is too long",
|
||||
1,
|
||||
100,
|
||||
displayName.Length
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private const int MaxLinks = 25;
|
||||
private const int MaxLinkLength = 256;
|
||||
|
||||
public static IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links)
|
||||
{
|
||||
if (links == null)
|
||||
return [];
|
||||
if (links.Length > MaxLinks)
|
||||
{
|
||||
return
|
||||
[
|
||||
("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length)),
|
||||
];
|
||||
}
|
||||
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
foreach ((string link, int idx) in links.Select((l, i) => (l, i)))
|
||||
{
|
||||
switch (link.Length)
|
||||
{
|
||||
case 0:
|
||||
errors.Add(
|
||||
(
|
||||
$"links.{idx}",
|
||||
ValidationError.LengthError("Link cannot be empty", 1, 256, 0)
|
||||
)
|
||||
);
|
||||
break;
|
||||
case > MaxLinkLength:
|
||||
errors.Add(
|
||||
(
|
||||
$"links.{idx}",
|
||||
ValidationError.LengthError(
|
||||
"Link is too long",
|
||||
1,
|
||||
MaxLinkLength,
|
||||
link.Length
|
||||
)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
public const int MaxBioLength = 1024;
|
||||
public const int MaxAvatarLength = 1_500_000;
|
||||
|
||||
public static ValidationError? ValidateBio(string? bio)
|
||||
{
|
||||
return bio?.Length switch
|
||||
{
|
||||
0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length),
|
||||
> MaxBioLength => ValidationError.LengthError(
|
||||
"Bio is too long",
|
||||
1,
|
||||
MaxBioLength,
|
||||
bio.Length
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static ValidationError? ValidateAvatar(string? avatar)
|
||||
{
|
||||
return avatar?.Length switch
|
||||
{
|
||||
0 => ValidationError.GenericValidationError("Avatar cannot be empty", null),
|
||||
> MaxAvatarLength => ValidationError.GenericValidationError(
|
||||
"Avatar is too large",
|
||||
null
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public const int MaximumReportContextLength = 512;
|
||||
|
||||
public static ValidationError? ValidateReportContext(string? context) =>
|
||||
context?.Length > MaximumReportContextLength
|
||||
? ValidationError.GenericValidationError("Avatar is too large", null)
|
||||
? ValidationError.GenericValidationError("Report context is too long", null)
|
||||
: null;
|
||||
|
||||
public const int MinimumPasswordLength = 12;
|
||||
|
@ -223,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();
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
@ -306,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",
|
||||
|
@ -317,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"
|
||||
|
@ -336,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",
|
||||
|
@ -410,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"
|
||||
}
|
||||
|
@ -449,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",
|
||||
|
@ -668,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": {
|
||||
|
@ -824,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",
|
||||
|
@ -863,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;
|
||||
|
@ -260,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, 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
|
||||
# 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"
|
||||
|
|
|
@ -15,12 +15,13 @@
|
|||
"@sveltejs/adapter-node": "^5.2.10",
|
||||
"@sveltejs/kit": "^2.12.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
||||
"@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",
|
||||
|
@ -31,6 +32,7 @@
|
|||
"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.1",
|
||||
|
@ -39,6 +41,7 @@
|
|||
"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",
|
||||
|
|
1553
Foxnouns.Frontend/pnpm-lock.yaml
generated
1553
Foxnouns.Frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
3
Foxnouns.Frontend/src/app.d.ts
vendored
3
Foxnouns.Frontend/src/app.d.ts
vendored
|
@ -9,7 +9,8 @@ declare global {
|
|||
message: string;
|
||||
status: number;
|
||||
code: ErrorCode;
|
||||
id: string;
|
||||
errors?: Array<{ key: string; errors: ValidationError[] }>;
|
||||
error_id?: string;
|
||||
}
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
|
|
|
@ -1,8 +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 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`)) {
|
||||
|
@ -14,12 +16,13 @@ export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
|||
return await fetch(request);
|
||||
};
|
||||
|
||||
export const handleError: HandleServerError = async ({ error, status, message }) => {
|
||||
const id = crypto.randomUUID();
|
||||
Sentry.init({
|
||||
dsn: env.PRIVATE_SENTRY_DSN,
|
||||
});
|
||||
|
||||
export const handleError: HandleServerError = async ({ error, status, message }) => {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
id,
|
||||
status: error.raw?.status || status,
|
||||
message: error.raw?.message || "Unknown error",
|
||||
code: error.code,
|
||||
|
@ -27,10 +30,18 @@ export const handleError: HandleServerError = async ({ error, status, message })
|
|||
}
|
||||
|
||||
if (status >= 400 && status <= 499) {
|
||||
return { id, status, message, code: ErrorCode.GenericApiError };
|
||||
return { status, message, code: ErrorCode.GenericApiError };
|
||||
}
|
||||
|
||||
// client errors and backend API errors just clog up sentry, so we don't send those.
|
||||
const id = Sentry.captureException(error, {
|
||||
mechanism: {
|
||||
type: "sveltekit",
|
||||
handled: false,
|
||||
},
|
||||
});
|
||||
|
||||
log.error("[%s] error in handler:", id, error);
|
||||
|
||||
return { id, status, message, code: ErrorCode.InternalServerError };
|
||||
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)",
|
||||
}
|
||||
|
|
|
@ -81,6 +81,7 @@ export async function apiRequest<TResponse, TRequest = unknown>(
|
|||
|
||||
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();
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ export type Meta = {
|
|||
};
|
||||
members: number;
|
||||
limits: Limits;
|
||||
notice: { id: string; message: string } | null;
|
||||
};
|
||||
|
||||
export type Limits = {
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import type { Member } from "./member";
|
||||
import type { AuthMethod, PartialMember, PartialUser, User } from "./user";
|
||||
|
||||
export type CreateReportRequest = {
|
||||
reason: ReportReason;
|
||||
context: string | null;
|
||||
|
@ -24,3 +27,97 @@ export enum ReportReason {
|
|||
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;
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@ export type MeUser = UserWithMembers & {
|
|||
timezone: string;
|
||||
suspended: boolean;
|
||||
deleted: boolean;
|
||||
settings: UserSettings;
|
||||
};
|
||||
|
||||
export type UserWithMembers = User & { members: PartialMember[] | null };
|
||||
|
@ -40,6 +41,7 @@ export type UserWithHiddenFields = User & {
|
|||
|
||||
export type UserSettings = {
|
||||
dark_mode: boolean | null;
|
||||
last_read_notice: string | null;
|
||||
};
|
||||
|
||||
export type PartialMember = {
|
||||
|
|
|
@ -12,12 +12,22 @@
|
|||
<svelte:element this={headerElem ?? "h4"}>
|
||||
{#if error.code === ErrorCode.BadRequest}
|
||||
{$t("error.bad-request-header")}
|
||||
{:else if error.status === 404}
|
||||
{$t("error.not-found-header")}
|
||||
{:else}
|
||||
{$t("error.generic-header")}
|
||||
{/if}
|
||||
</svelte:element>
|
||||
{/if}
|
||||
<p>{errorDescription($t, error.code)}</p>
|
||||
{#if error.error_id}
|
||||
<p>
|
||||
{$t("error.error-id")}
|
||||
<code>
|
||||
{error.error_id}
|
||||
</code>
|
||||
</p>
|
||||
{/if}
|
||||
{#if error.errors}
|
||||
<details>
|
||||
<summary>{$t("error.extra-info-header")}</summary>
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import Envelope from "svelte-bootstrap-icons/lib/Envelope.svelte";
|
||||
import CashCoin from "svelte-bootstrap-icons/lib/CashCoin.svelte";
|
||||
import Logo from "./Logo.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = { meta: Meta };
|
||||
let { meta }: Props = $props();
|
||||
|
@ -18,13 +19,13 @@
|
|||
<div class="align-start flex-grow-1">
|
||||
<Logo />
|
||||
<ul class="mt-2 list-unstyled">
|
||||
<li><strong>Version</strong> {meta.version}</li>
|
||||
<li><strong>{$t("footer.version")}</strong> {meta.version}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="align-end">
|
||||
<ul class="list-unstyled">
|
||||
<li>{meta.users.total} <strong>users</strong></li>
|
||||
<li>{meta.members} <strong>members</strong></li>
|
||||
<li>{meta.users.total.toLocaleString()} <strong>{$t("footer.users")}</strong></li>
|
||||
<li>{meta.members.toLocaleString()} <strong>{$t("footer.members")}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -36,7 +37,7 @@
|
|||
>
|
||||
<li class="list-inline-item">
|
||||
<Git />
|
||||
Source code
|
||||
{$t("footer.source")}
|
||||
</li>
|
||||
</a>
|
||||
<a
|
||||
|
@ -46,37 +47,37 @@
|
|||
>
|
||||
<li class="list-inline-item">
|
||||
<Reception4 />
|
||||
Status
|
||||
{$t("footer.status")}
|
||||
</li>
|
||||
</a>
|
||||
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/about">
|
||||
<li class="list-inline-item">
|
||||
<Envelope />
|
||||
About and contact
|
||||
{$t("footer.about-contact")}
|
||||
</li>
|
||||
</a>
|
||||
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/tos">
|
||||
<li class="list-inline-item">
|
||||
<CardText />
|
||||
Terms of service
|
||||
{$t("footer.terms")}
|
||||
</li>
|
||||
</a>
|
||||
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/privacy">
|
||||
<li class="list-inline-item">
|
||||
<Shield />
|
||||
Privacy policy
|
||||
{$t("footer.privacy")}
|
||||
</li>
|
||||
</a>
|
||||
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/changelog">
|
||||
<li class="list-inline-item">
|
||||
<Newspaper />
|
||||
Changelog
|
||||
{$t("footer.changelog")}
|
||||
</li>
|
||||
</a>
|
||||
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/donate">
|
||||
<li class="list-inline-item">
|
||||
<CashCoin />
|
||||
Donate
|
||||
{$t("footer.donate")}
|
||||
</li>
|
||||
</a>
|
||||
</ul>
|
||||
|
|
49
Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte
Normal file
49
Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { fastRequest } from "$api";
|
||||
import type { UserSettings } from "$api/models";
|
||||
import { idTimestamp } from "$lib";
|
||||
import { t } from "$lib/i18n";
|
||||
import log from "$lib/log";
|
||||
import { renderUnsafeMarkdown } from "$lib/markdown";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
type Props = { id: string; message: string; settings?: UserSettings; token: string | null };
|
||||
let { id, message, settings, token }: Props = $props();
|
||||
|
||||
let lastReadNotice = $state(settings?.last_read_notice || null);
|
||||
|
||||
// Render the notice if:
|
||||
// - user is not logged in (no settings object)
|
||||
// - last read notice is null (never marked any notice as read)
|
||||
// - last read notice ID is smaller than the current one (has not marked the current notice as read)
|
||||
let renderNotice = $derived(!lastReadNotice || lastReadNotice < id);
|
||||
let canDismiss = $derived(!!token);
|
||||
let renderedMessage = $derived(renderUnsafeMarkdown(message));
|
||||
|
||||
let dismiss = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
await fastRequest("PATCH", "/users/@me/settings", { token, body: { last_read_notice: id } });
|
||||
lastReadNotice = id;
|
||||
} catch (e) {
|
||||
log.error("error updating last read notice ID:", e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if renderNotice}
|
||||
<div class="alert alert-light" role="alert">
|
||||
<div>
|
||||
{@html renderedMessage}
|
||||
</div>
|
||||
{#if canDismiss}
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_invalid_attribute -->
|
||||
<a href="#" tabindex="0" role="button" onclick={() => dismiss()} onkeyup={() => dismiss()}>
|
||||
{$t("notification.mark-as-read")}
|
||||
</a>
|
||||
• {idTimestamp(id).toLocaleString(DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
|
@ -13,13 +13,21 @@
|
|||
import Logo from "$components/Logo.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = { user: MeUser | null; meta: Meta };
|
||||
let { user, meta }: Props = $props();
|
||||
type Props = { user: MeUser | null; meta: Meta; unreadNotifications?: boolean };
|
||||
let { user, meta, unreadNotifications }: Props = $props();
|
||||
|
||||
let isOpen = $state(true);
|
||||
const toggleMenu = () => (isOpen = !isOpen);
|
||||
</script>
|
||||
|
||||
{#if user && unreadNotifications}
|
||||
<div class="notification-alert text-center py-3 mb-2 px-2">
|
||||
<strong>{$t("nav.unread-notification-text")}</strong>
|
||||
<br />
|
||||
<a href="/settings/notifications">{$t("nav.unread-notification-link")}</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if user && user.deleted}
|
||||
<div class="deleted-alert text-center py-3 mb-2 px-2">
|
||||
{#if user.suspended}
|
||||
|
@ -58,6 +66,13 @@
|
|||
@{user.username}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{#if user.role === "ADMIN" || user.role === "MODERATOR"}
|
||||
<NavItem>
|
||||
<NavLink href="/admin" active={page.url.pathname.startsWith(`/admin`)}>
|
||||
Administration
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{/if}
|
||||
<NavItem>
|
||||
<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
|
||||
{$t("nav.settings")}
|
||||
|
@ -80,6 +95,11 @@
|
|||
background-color: var(--bs-danger-bg-subtle);
|
||||
}
|
||||
|
||||
.notification-alert {
|
||||
color: var(--bs-warning-text-emphasis);
|
||||
background-color: var(--bs-warning-bg-subtle);
|
||||
}
|
||||
|
||||
/* These exact values make it look almost identical to the SVG version, which is what we want */
|
||||
#beta-text {
|
||||
font-size: 0.7em;
|
||||
|
|
14
Foxnouns.Frontend/src/lib/components/URLAlert.svelte
Normal file
14
Foxnouns.Frontend/src/lib/components/URLAlert.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = { data?: { alertKey?: string }; key?: string };
|
||||
|
||||
let props: Props = $props();
|
||||
let key = $derived(props.key ?? props.data?.alertKey);
|
||||
</script>
|
||||
|
||||
{#if key}
|
||||
<div class="alert alert-light">
|
||||
{$t(key)}
|
||||
</div>
|
||||
{/if}
|
73
Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte
Normal file
73
Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte
Normal file
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
import { ClearableField } from "$api/models/moderation";
|
||||
import FormStatusMarker, { type FormError } from "$components/editor/FormStatusMarker.svelte";
|
||||
import { TabContent, TabPane } from "@sveltestrap/sveltestrap";
|
||||
|
||||
let {
|
||||
userId,
|
||||
reportId,
|
||||
memberId,
|
||||
form,
|
||||
}: { userId: string; reportId?: string; memberId?: string; form: FormError } = $props();
|
||||
|
||||
let fields = $derived.by(() => {
|
||||
const fields = [];
|
||||
for (const value of Object.values(ClearableField)) {
|
||||
fields.push({ value });
|
||||
}
|
||||
return fields;
|
||||
});
|
||||
</script>
|
||||
|
||||
<form method="POST">
|
||||
<input type="hidden" name="user" value={userId} />
|
||||
{#if memberId}
|
||||
<input type="hidden" name="member" value={memberId} />
|
||||
{/if}
|
||||
{#if reportId}
|
||||
<input type="hidden" name="report" value={reportId} />
|
||||
{/if}
|
||||
<FormStatusMarker {form} />
|
||||
<textarea name="reason" class="form-control" style="height: 200px;"></textarea>
|
||||
<TabContent>
|
||||
{#if reportId}
|
||||
<TabPane tabId="ignore" tab="Ignore">
|
||||
<button type="submit" formaction="?/ignore" class="btn btn-secondary">Ignore report</button>
|
||||
</TabPane>
|
||||
{/if}
|
||||
<TabPane tabId="warn" tab="Warn" active>
|
||||
<div class="row row-cols-1 row-cols-lg-2">
|
||||
{#each fields as field}
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="clear-fields"
|
||||
value={field.value}
|
||||
id="reason-{field.value}"
|
||||
/>
|
||||
<label class="form-check-label" for="reason-{field.value}">
|
||||
<code>{field.value}</code>
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" formaction="?/warn" class="btn btn-danger">Warn user</button>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabId="suspend" tab="Suspend">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="yes"
|
||||
name="clear-profile"
|
||||
id="clear-profile"
|
||||
/>
|
||||
<label class="form-check-label" for="clear-profile">Clear the user's profile?</label>
|
||||
</div>
|
||||
<button type="submit" formaction="?/suspend" class="btn btn-danger">Suspend user</button>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</form>
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { AuditLogEntity } from "$api/models/moderation";
|
||||
|
||||
type Props = { entity: AuditLogEntity };
|
||||
let { entity }: Props = $props();
|
||||
</script>
|
||||
|
||||
<strong>{entity.username}</strong> <span class="text-secondary">({entity.id})</span>
|
|
@ -0,0 +1,67 @@
|
|||
<script lang="ts">
|
||||
import type { AuditLogEntry } from "$api/models/moderation";
|
||||
import { idTimestamp } from "$lib";
|
||||
import { renderMarkdown } from "$lib/markdown";
|
||||
import { DateTime } from "luxon";
|
||||
import AuditLogEntity from "./AuditLogEntity.svelte";
|
||||
|
||||
type Props = { entry: AuditLogEntry };
|
||||
let { entry }: Props = $props();
|
||||
|
||||
let reason = $derived(renderMarkdown(entry.reason));
|
||||
let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
|
||||
</script>
|
||||
|
||||
<div class="card my-1 p-2">
|
||||
<h6 class="d-flex">
|
||||
<span class="flex-grow-1">
|
||||
<AuditLogEntity entity={entry.moderator} />
|
||||
{#if entry.type === "IGNORE_REPORT"}
|
||||
ignored a report
|
||||
{:else if entry.type === "WARN_USER" || entry.type === "WARN_USER_AND_CLEAR_PROFILE"}
|
||||
warned
|
||||
{:else if entry.type === "SUSPEND_USER"}
|
||||
suspended
|
||||
{:else if entry.type === "QUERY_SENSITIVE_USER_DATA"}
|
||||
looked up sensitive data of
|
||||
{:else}
|
||||
(unknown action <code>{entry.type}</code>)
|
||||
{/if}
|
||||
{#if entry.target_user}
|
||||
<AuditLogEntity entity={entry.target_user} />
|
||||
{/if}
|
||||
{#if entry.target_member}
|
||||
for member <AuditLogEntity entity={entry.target_member} />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<small class="text-secondary">{date}</small>
|
||||
</h6>
|
||||
|
||||
{#if entry.type === "IGNORE_REPORT"}
|
||||
{#if entry.report}
|
||||
<details>
|
||||
<summary>Report</summary>
|
||||
<ul>
|
||||
<li><strong>From:</strong> {entry.report.reporter_id}</li>
|
||||
<li><strong>Target:</strong> {entry.report.target_user_id}</li>
|
||||
<li><strong>Reason:</strong> {entry.report.reason}</li>
|
||||
{#if entry.report.context}
|
||||
<li><strong>Context:</strong> {entry.report.context}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<p><em>(the ignored report has been deleted)</em></p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if reason}
|
||||
<details>
|
||||
<summary>Reason</summary>
|
||||
{@html reason}
|
||||
</details>
|
||||
{:else}
|
||||
<p><em>(no reason given)</em></p>
|
||||
{/if}
|
||||
</div>
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { AuditLogEntryType, type AuditLogEntry } from "$api/models/moderation";
|
||||
import { idTimestamp } from "$lib";
|
||||
import { renderMarkdown } from "$lib/markdown";
|
||||
import { DateTime } from "luxon";
|
||||
import AuditLogEntity from "./AuditLogEntity.svelte";
|
||||
|
||||
type Props = { entry: AuditLogEntry };
|
||||
let { entry }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<h3 id="report-status">
|
||||
Closed by <AuditLogEntity entity={entry.moderator} /> at
|
||||
{idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED)}
|
||||
</h3>
|
||||
<p>
|
||||
{#if entry.type === AuditLogEntryType.IgnoreReport}
|
||||
Report was ignored
|
||||
{:else if entry.type === AuditLogEntryType.WarnUser || entry.type === AuditLogEntryType.WarnUserAndClearProfile}
|
||||
User was warned
|
||||
{#if entry.cleared_fields && entry.cleared_fields.length > 0}
|
||||
<br />Cleared fields: {entry.cleared_fields.join(", ")}
|
||||
{/if}
|
||||
{:else if entry.type === AuditLogEntryType.SuspendUser}
|
||||
User was suspended
|
||||
{/if}
|
||||
</p>
|
||||
<h4>Reason</h4>
|
||||
<p>
|
||||
{#if entry.reason}
|
||||
{@html renderMarkdown(entry.reason)}
|
||||
{:else}
|
||||
<em class="text-secondary">(no reason given)</em>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = { title: string; onlyNumber?: boolean; children: Snippet };
|
||||
let { title, onlyNumber = true, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="col-md">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{title}</h5>
|
||||
<p class="card-text text-center" class:fs-1={onlyNumber}>
|
||||
{@render children()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import type { PartialUser } from "$api/models";
|
||||
import Avatar from "$components/Avatar.svelte";
|
||||
import { idTimestamp } from "$lib";
|
||||
import { t } from "$lib/i18n";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
type Props = { user: PartialUser };
|
||||
let { user }: Props = $props();
|
||||
|
||||
let createdAt = $derived(idTimestamp(user.id).toLocaleString(DateTime.DATETIME_SHORT));
|
||||
</script>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/@{user.username}">
|
||||
<Avatar
|
||||
name={user.username}
|
||||
url={user.avatar_url}
|
||||
lazyLoad
|
||||
alt={$t("avatar-tooltip", { name: "@" + user.username })}
|
||||
/>
|
||||
</a>
|
||||
<p class="m-2">
|
||||
<a class="text-reset fs-5 text-break" href="/@{user.username}">
|
||||
@{user.username}
|
||||
</a>
|
||||
</p>
|
||||
<p>Created {createdAt}</p>
|
||||
</div>
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import Avatar from "$components/Avatar.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||
import { Icon, InputGroup, Modal } from "@sveltestrap/sveltestrap";
|
||||
import Cropper, { type CropArea, type OnCropCompleteEvent } from "svelte-easy-crop";
|
||||
import { encode } from "base64-arraybuffer";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
||||
|
@ -21,6 +22,7 @@
|
|||
let avatar: string = $state("");
|
||||
let avatarExists = $derived(avatar !== "");
|
||||
let avatarTooLarge = $derived(avatar !== "" && avatar.length > MAX_AVATAR_BYTES);
|
||||
let cropperOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
getAvatar(avatarFiles);
|
||||
|
@ -28,7 +30,7 @@
|
|||
|
||||
const getAvatar = async (list: FileList | null) => {
|
||||
if (!list || list.length === 0) {
|
||||
avatar = "";
|
||||
uncroppedAvatar = "";
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -36,7 +38,47 @@
|
|||
const base64 = encode(buffer);
|
||||
|
||||
const uri = `data:${list[0].type};base64,${base64}`;
|
||||
avatar = uri;
|
||||
uncroppedAvatar = uri;
|
||||
cropperOpen = true;
|
||||
};
|
||||
|
||||
let uncroppedAvatar: string = $state("");
|
||||
let crop = $state({ x: 0, y: 0 });
|
||||
let zoom = $state(1);
|
||||
let croppedArea = $state({ x: 0, y: 0, height: 0, width: 0 } satisfies CropArea);
|
||||
|
||||
const onCropComplete = (e: OnCropCompleteEvent) => {
|
||||
croppedArea = e.pixels;
|
||||
};
|
||||
|
||||
const doCrop = () => {
|
||||
cropperOpen = false;
|
||||
if (!uncroppedAvatar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = croppedArea.width;
|
||||
canvas.height = croppedArea.height;
|
||||
|
||||
ctx?.drawImage(
|
||||
img,
|
||||
croppedArea.x,
|
||||
croppedArea.y,
|
||||
croppedArea.width,
|
||||
croppedArea.height,
|
||||
0,
|
||||
0,
|
||||
croppedArea.width,
|
||||
croppedArea.height,
|
||||
);
|
||||
|
||||
avatar = canvas.toDataURL("image/webp", 1);
|
||||
};
|
||||
img.src = uncroppedAvatar;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -44,6 +86,41 @@
|
|||
<Avatar {name} url={avatarExists ? avatar : current} {alt} />
|
||||
</p>
|
||||
|
||||
<Modal
|
||||
isOpen={cropperOpen}
|
||||
autoFocus
|
||||
backdrop
|
||||
fade
|
||||
keyboard
|
||||
returnFocusAfterClose
|
||||
toggle={() => (cropperOpen = !cropperOpen)}
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">{$t("editor.crop-avatar-header")}</h1>
|
||||
</div>
|
||||
<div class="modal-body cropper-wrapper">
|
||||
{#if uncroppedAvatar}
|
||||
<Cropper
|
||||
image={uncroppedAvatar}
|
||||
{crop}
|
||||
{zoom}
|
||||
minZoom={1}
|
||||
maxZoom={4}
|
||||
aspect={1 / 1}
|
||||
oncropcomplete={onCropComplete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick={() => doCrop()}>
|
||||
{$t("editor.crop-avatar-button")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick={() => (cropperOpen = false)}>
|
||||
{$t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<InputGroup class="mb-2">
|
||||
<input
|
||||
class="form-control"
|
||||
|
@ -53,7 +130,7 @@
|
|||
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
class="btn btn-primary"
|
||||
disabled={!avatarExists || avatarTooLarge}
|
||||
onclick={() => onclick(avatar)}
|
||||
>
|
||||
|
@ -79,3 +156,9 @@
|
|||
})}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.cropper-wrapper {
|
||||
min-height: 30em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { t } from "$lib/i18n";
|
||||
</script>
|
||||
|
||||
<div class="alert alert-secondary">
|
||||
{$t("editor.custom-preference-notice")}
|
||||
<a href="/settings/prefs" class="alert-link">{$t("editor.custom-preference-notice-link")}</a>
|
||||
</div>
|
|
@ -6,6 +6,7 @@
|
|||
import FieldEditor from "./FieldEditor.svelte";
|
||||
import FormStatusMarker from "./FormStatusMarker.svelte";
|
||||
import NoscriptWarning from "./NoscriptWarning.svelte";
|
||||
import CustomPreferencesNotice from "$components/editor/CustomPreferencesNotice.svelte";
|
||||
|
||||
type Props = {
|
||||
fields: Field[];
|
||||
|
@ -45,6 +46,7 @@
|
|||
|
||||
<NoscriptWarning />
|
||||
<FormStatusMarker form={ok} />
|
||||
<CustomPreferencesNotice />
|
||||
|
||||
<h4>{$t("edit-profile.editing-fields-header")}</h4>
|
||||
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
<script module lang="ts">
|
||||
export type FormError = { error: RawApiError | null; ok: boolean } | null;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Icon } from "@sveltestrap/sveltestrap";
|
||||
import { t } from "$lib/i18n";
|
||||
import type { RawApiError } from "$api/error";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
|
||||
type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string };
|
||||
type Props = { form: FormError | null; successMessage?: string };
|
||||
let { form, successMessage }: Props = $props();
|
||||
</script>
|
||||
|
||||
|
|
|
@ -2,14 +2,16 @@
|
|||
import type { RawApiError } from "$api/error";
|
||||
import IconButton from "$components/IconButton.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import ephemeralState from "$lib/state.svelte";
|
||||
import FormStatusMarker from "./FormStatusMarker.svelte";
|
||||
|
||||
type Props = {
|
||||
stateKey: string;
|
||||
currentLinks: string[];
|
||||
save(links: string[]): Promise<void>;
|
||||
form: { ok: boolean; error: RawApiError | null } | null;
|
||||
};
|
||||
let { currentLinks, save, form }: Props = $props();
|
||||
let { stateKey, currentLinks, save, form }: Props = $props();
|
||||
|
||||
let links = $state(currentLinks);
|
||||
let newEntry = $state("");
|
||||
|
@ -37,6 +39,12 @@
|
|||
links = [...links, newEntry];
|
||||
newEntry = "";
|
||||
};
|
||||
|
||||
ephemeralState(
|
||||
stateKey,
|
||||
() => links,
|
||||
(data) => (links = data),
|
||||
);
|
||||
</script>
|
||||
|
||||
<h4>
|
||||
|
|
|
@ -4,16 +4,18 @@
|
|||
import FlagSearch from "$components/editor/FlagSearch.svelte";
|
||||
import IconButton from "$components/IconButton.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import ephemeralState from "$lib/state.svelte";
|
||||
import FlagButton from "./FlagButton.svelte";
|
||||
import FormStatusMarker from "./FormStatusMarker.svelte";
|
||||
|
||||
type Props = {
|
||||
stateKey: string;
|
||||
profileFlags: PrideFlag[];
|
||||
allFlags: PrideFlag[];
|
||||
save(flags: string[]): Promise<void>;
|
||||
form: { ok: boolean; error: RawApiError | null } | null;
|
||||
};
|
||||
let { profileFlags, allFlags, save, form }: Props = $props();
|
||||
let { stateKey, profileFlags, allFlags, save, form }: Props = $props();
|
||||
|
||||
let flags = $state(profileFlags);
|
||||
|
||||
|
@ -40,6 +42,12 @@
|
|||
};
|
||||
|
||||
const saveChanges = () => save(flags.map((f) => f.id));
|
||||
|
||||
ephemeralState(
|
||||
stateKey,
|
||||
() => flags,
|
||||
(data) => (flags = data),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue