Compare commits

...

29 commits

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

coravel is still used for emails, though.

BREAKING CHANGE: Foxnouns.NET now requires Redis to work. the EFCore
storage for hangfire doesn't work well enough, unfortunately.
2025-03-04 17:03:39 +01:00
sam
cd24196cd1
chore(backend): format 2025-02-28 16:53:53 +01:00
sam
7d6d4631b8
fix(frontend): don't reference email auth in text if it's disabled 2025-02-28 16:50:57 +01:00
sam
a248536789
fix typo in DOCKER.md 2025-02-28 16:47:21 +01:00
sam
218c756a70
feat(backend): make field limits configurable 2025-02-28 16:37:15 +01:00
sam
7ea6c62d67
chore(backend): update dependencies 2025-02-28 16:36:45 +01:00
sam
64ea25e89e
feat(frontend): avatar cropping 2025-02-24 21:32:20 +01:00
sam
f1f777ff82
fix(frontend): localize footer 2025-02-24 20:37:51 +01:00
sam
a72c0f41c3
add build script 2025-02-24 18:25:49 +01:00
sam
6fe816404f
rename rate/ to Foxnouns.RateLimiter/ for consistency 2025-02-24 17:47:37 +01:00
sam
d1faf1ddee
feat(frontend): store pending profile changes in sessionStorage 2025-02-24 17:37:49 +01:00
sam
92bf933c10
feat(frontend): link custom preferences in profile editor 2025-02-24 17:13:46 +01:00
sam
c8e4078b35
fix: show 404 page if /api/v2/meta/page/{page} can't find a file 2025-02-23 21:42:01 +01:00
sam
0c6e3bf38f
feat(frontend): show closed reports button, add some alerts for auth 2025-02-23 20:02:40 +01:00
sam
30146556f5
chore: update frontend dockerfile to node 23 2025-02-11 14:57:07 +01:00
112 changed files with 2812 additions and 819 deletions

View file

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

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

View file

@ -1,10 +1,29 @@
# Running with Docker
# Running with Docker (pre-built backend and rate limiter) *(linux/arm64 only)*
Because SvelteKit is a pain in the ass to build in a container, and processes secrets at build time,
there is no pre-built frontend image available.
If you don't want to build images on your server, I recommend running the frontend outside of Docker.
This is preconfigured in `docker-compose.prebuilt.yml`: the backend, database, and rate limiter will run in Docker,
while the frontend is run as a normal, non-containerized service.
1. Copy `docker/config.example.ini` to `docker/config.ini`, and change the settings to your liking.
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.

View file

@ -26,7 +26,6 @@ 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();
@ -55,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
@ -99,6 +99,11 @@ public class Config
{
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;

View file

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

View file

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

View file

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

View file

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

View file

@ -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,7 +36,6 @@ public class MembersController(
MemberRendererService memberRenderer,
ISnowflakeGenerator snowflakeGenerator,
ObjectStorageService objectStorageService,
IQueue queue,
IClock clock,
ValidationService validationService,
Config config
@ -81,13 +79,13 @@ public class MembersController(
("display_name", validationService.ValidateDisplayName(req.DisplayName)),
("bio", validationService.ValidateBio(req.Bio)),
("avatar", validationService.ValidateAvatar(req.Avatar)),
.. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
.. ValidationUtils.ValidateFieldEntries(
.. validationService.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
.. validationService.ValidateFieldEntries(
req.Names?.ToArray(),
CurrentUser!.CustomPreferences,
"names"
),
.. ValidationUtils.ValidatePronouns(
.. validationService.ValidatePronouns(
req.Pronouns?.ToArray(),
CurrentUser!.CustomPreferences
),
@ -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));
@ -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();
}
@ -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();

View file

@ -13,20 +13,23 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Text.RegularExpressions;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Services.Caching;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/meta")]
public partial class MetaController(Config config) : 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,
@ -45,10 +48,14 @@ public partial class MetaController(Config config) : ApiControllerBase
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)
{
@ -58,13 +65,20 @@ public partial class MetaController(Config config) : ApiControllerBase
}
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();

View file

@ -0,0 +1,77 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/notices")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class NoticesController(
DatabaseContext db,
UserRendererService userRenderer,
ISnowflakeGenerator snowflakeGenerator,
IClock clock
) : ApiControllerBase
{
[HttpGet]
public async Task<IActionResult> GetNoticesAsync(CancellationToken ct = default)
{
List<Notice> notices = await db
.Notices.Include(n => n.Author)
.OrderByDescending(n => n.Id)
.ToListAsync(ct);
return Ok(notices.Select(RenderNotice));
}
[HttpPost]
public async Task<IActionResult> CreateNoticeAsync(CreateNoticeRequest req)
{
Instant now = clock.GetCurrentInstant();
if (req.StartTime < now)
{
throw new ApiError.BadRequest(
"Start time cannot be in the past",
"start_time",
req.StartTime
);
}
if (req.EndTime < now)
{
throw new ApiError.BadRequest(
"End time cannot be in the past",
"end_time",
req.EndTime
);
}
var notice = new Notice
{
Id = snowflakeGenerator.GenerateSnowflake(),
Message = req.Message,
StartTime = req.StartTime ?? clock.GetCurrentInstant(),
EndTime = req.EndTime,
Author = CurrentUser!,
};
db.Add(notice);
await db.SaveChangesAsync();
return Ok(RenderNotice(notice));
}
private NoticeResponse RenderNotice(Notice notice) =>
new(
notice.Id,
notice.Message,
notice.StartTime,
notice.EndTime,
userRenderer.RenderPartialUser(notice.Author)
);
}

View file

@ -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,7 +33,6 @@ public class UsersController(
ILogger logger,
UserRendererService userRenderer,
ISnowflakeGenerator snowflakeGenerator,
IQueue queue,
IClock clock,
ValidationService validationService
) : ApiControllerBase
@ -48,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
)
);
}
@ -91,7 +97,7 @@ public class UsersController(
if (req.Names != null)
{
errors.AddRange(
ValidationUtils.ValidateFieldEntries(
validationService.ValidateFieldEntries(
req.Names,
CurrentUser!.CustomPreferences,
"names"
@ -103,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();
}
@ -111,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();
}
@ -174,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);
@ -254,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)]
@ -280,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);

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations
{
#pragma warning disable 612, 618
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")

View file

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

View file

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

View file

@ -95,4 +95,5 @@ public enum PreferenceSize
public class UserSettings
{
public bool? DarkMode { get; set; }
public Snowflake? LastReadNotice { get; set; }
}

View file

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

View file

@ -122,3 +122,13 @@ public record QueryUserResponse(
);
public record QuerySensitiveUserDataRequest(string Reason);
public record NoticeResponse(
Snowflake Id,
string Message,
Instant StartTime,
Instant EndTime,
PartialUser Author
);
public record CreateNoticeRequest(string Message, Instant? StartTime, Instant EndTime);

View file

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

View file

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

View file

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

View file

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

View file

@ -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,24 +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>();
@ -157,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>>());

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,39 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Services.Caching;
public class NoticeCacheService(IServiceProvider serviceProvider, IClock clock, ILogger logger)
: SingletonCacheService<Notice>(serviceProvider, clock, logger)
{
public override Duration MaxAge { get; init; } = Duration.FromMinutes(5);
public override Func<
DatabaseContext,
CancellationToken,
Task<Notice?>
> FetchFunc { get; init; } =
async (db, ct) =>
await db
.Notices.Where(n =>
n.StartTime < clock.GetCurrentInstant() && n.EndTime > clock.GetCurrentInstant()
)
.OrderByDescending(n => n.Id)
.FirstOrDefaultAsync(ct);
}

View file

@ -0,0 +1,63 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using NodaTime;
namespace Foxnouns.Backend.Services.Caching;
public abstract class SingletonCacheService<T>(
IServiceProvider serviceProvider,
IClock clock,
ILogger logger
)
where T : class
{
private T? _item;
private Instant _lastUpdated = Instant.MinValue;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly ILogger _logger = logger.ForContext<SingletonCacheService<T>>();
public virtual Duration MaxAge { get; init; } = Duration.FromMinutes(5);
public virtual Func<DatabaseContext, CancellationToken, Task<T?>> FetchFunc { get; init; } =
(_, __) => Task.FromResult<T?>(null);
public async Task<T?> GetAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct);
try
{
if (_lastUpdated > clock.GetCurrentInstant() - MaxAge)
{
return _item;
}
_logger.Debug("Cached item of type {Type} is expired, fetching it.", typeof(T));
await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope();
await using DatabaseContext db =
scope.ServiceProvider.GetRequiredService<DatabaseContext>();
T? item = await FetchFunc(db, ct);
_item = item;
_lastUpdated = clock.GetCurrentInstant();
return item;
}
finally
{
_semaphore.Release();
}
}
}

View file

@ -23,8 +23,11 @@ public class EmailRateLimiter
{
private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new();
private readonly 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));

View file

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

View file

@ -27,7 +27,6 @@ public class ModerationService(
ILogger logger,
DatabaseContext db,
ISnowflakeGenerator snowflakeGenerator,
IQueue queue,
IClock clock
)
{
@ -181,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?
@ -264,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;
@ -306,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;

View file

@ -33,11 +33,9 @@ public class PeriodicTasksService(ILogger logger, IServiceProvider services) : B
// The type is literally written on the same line, we can just use `var`
// 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);
}
}

View file

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

View file

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

View file

@ -31,6 +31,7 @@ public partial class ValidationService
"settings",
"pronouns.cc",
"pronounscc",
"null",
];
private static readonly string[] InvalidMemberNames =
@ -38,8 +39,10 @@ public partial class ValidationService
// these break routing outright
".",
"..",
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
// 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)

View file

@ -1,23 +0,0 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Utils;
public static class Limits
{
public const int FieldLimit = 25;
public const int FieldNameLimit = 100;
public const int FieldEntryTextLimit = 100;
public const int FieldEntriesLimit = 100;
}

View file

@ -22,8 +22,10 @@ namespace Foxnouns.Backend.Utils.OpenApi;
public class PropertyKeySchemaTransformer : IOpenApiSchemaTransformer
{
private static readonly DefaultContractResolver SnakeCaseConverter =
new() { NamingStrategy = new SnakeCaseNamingStrategy() };
private static readonly DefaultContractResolver SnakeCaseConverter = new()
{
NamingStrategy = new SnakeCaseNamingStrategy(),
};
public Task TransformAsync(
OpenApiSchema schema,

View file

@ -20,7 +20,7 @@ public static partial class ValidationUtils
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,8 +55,8 @@ importers:
specifier: ^5.0.2
version: 5.0.2(svelte@5.14.3)(vite@6.0.3(@types/node@22.12.0)(sass@1.83.0))
'@sveltestrap/sveltestrap':
specifier: ^6.2.7
version: 6.2.7(svelte@5.14.3)
specifier: ^7.1.0
version: 7.1.0(svelte@5.14.3)
'@types/eslint':
specifier: ^9.6.1
version: 9.6.1
@ -72,6 +72,9 @@ importers:
bootstrap:
specifier: ^5.3.3
version: 5.3.3(@popperjs/core@2.11.8)
dotenv:
specifier: ^16.4.7
version: 16.4.7
eslint:
specifier: ^9.17.0
version: 9.17.0
@ -102,6 +105,9 @@ importers:
svelte-check:
specifier: ^4.1.1
version: 4.1.1(picomatch@4.0.2)(svelte@5.14.3)(typescript@5.7.2)
svelte-easy-crop:
specifier: ^4.0.0
version: 4.0.0(svelte@5.14.3)
sveltekit-i18n:
specifier: ^2.4.2
version: 2.4.2(svelte@5.14.3)
@ -1002,8 +1008,8 @@ packages:
'@sveltekit-i18n/parser-default@1.1.1':
resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==}
'@sveltestrap/sveltestrap@6.2.7':
resolution: {integrity: sha512-WwLLfAFUb42BGuRrf3Vbct30bQMzlEMMipN/MfxhjuLTmLQeW9muVJfPyvjtWS+mY+RjkSCoHvAp/ZobP1NLlQ==}
'@sveltestrap/sveltestrap@7.1.0':
resolution: {integrity: sha512-TpIx25kqLV+z+VD3yfqYayOI1IaCeWFbT0uqM6NfA4vQgDs9PjFwmjkU4YEAlV/ngs9e7xPmaRWE7lkrg4Miow==}
peerDependencies:
svelte: ^4.0.0 || ^5.0.0 || ^5.0.0-next.0
@ -1967,6 +1973,11 @@ packages:
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: '>=5.0.0'
svelte-easy-crop@4.0.0:
resolution: {integrity: sha512-/asrrCYypXwCsPqJ07m7s7QArJwrdfEt7D1UN9hC4WF3GgEtuqmGuVi5DGeJVtBpKu5388gYFtCgQz9lA+/Rtg==}
peerDependencies:
svelte: ^4.0.0 || ^5.0.0
svelte-eslint-parser@0.43.0:
resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -3079,7 +3090,7 @@ snapshots:
'@sveltekit-i18n/parser-default@1.1.1': {}
'@sveltestrap/sveltestrap@6.2.7(svelte@5.14.3)':
'@sveltestrap/sveltestrap@7.1.0(svelte@5.14.3)':
dependencies:
'@popperjs/core': 2.11.8
svelte: 5.14.3
@ -4051,6 +4062,10 @@ snapshots:
transitivePeerDependencies:
- picomatch
svelte-easy-crop@4.0.0(svelte@5.14.3):
dependencies:
svelte: 5.14.3
svelte-eslint-parser@0.43.0(svelte@5.14.3):
dependencies:
eslint-scope: 7.2.2

View file

@ -21,12 +21,8 @@ Sentry.init({
});
export const handleError: HandleServerError = async ({ error, status, message }) => {
// as far as i know, sentry IDs are just UUIDs with the dashes removed. use those here as well
let id = crypto.randomUUID().replaceAll("-", "");
if (error instanceof ApiError) {
return {
error_id: id,
status: error.raw?.status || status,
message: error.raw?.message || "Unknown error",
code: error.code,
@ -34,11 +30,11 @@ export const handleError: HandleServerError = async ({ error, status, message })
}
if (status >= 400 && status <= 499) {
return { error_id: 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.
id = Sentry.captureException(error, {
const id = Sentry.captureException(error, {
mechanism: {
type: "sveltekit",
handled: false,

View file

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

View file

@ -10,6 +10,7 @@ export type Meta = {
};
members: number;
limits: Limits;
notice: { id: string; message: string } | null;
};
export type Limits = {

View file

@ -112,3 +112,12 @@ export enum ClearableField {
Flags = "FLAGS",
CustomPreferences = "CUSTOM_PREFERENCES",
}
export type Notification = {
id: string;
type: "NOTICE" | "WARNING" | "SUSPENSION";
message?: string;
localization_key?: string;
localization_params: Record<string, string>;
acknowledged: boolean;
};

View file

@ -28,6 +28,7 @@ export type MeUser = UserWithMembers & {
timezone: string;
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 = {

View file

@ -12,6 +12,8 @@
<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}

View file

@ -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.toLocaleString()} <strong>users</strong></li>
<li>{meta.members.toLocaleString()} <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>

View file

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

View file

@ -13,13 +13,21 @@
import Logo from "$components/Logo.svelte";
import { 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}
@ -87,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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -48,7 +48,7 @@
icon="chevron-down"
color="secondary"
tooltip={$t("editor.move-entry-down")}
onclick={() => moveValue(index, true)}
onclick={() => moveValue(index, false)}
/>
<input type="text" class="form-control" bind:value={value.value} autocomplete="off" />
<ButtonDropdown>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import type { Notification } from "$api/models/moderation";
import { t } from "$lib/i18n";
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
import ExclamationTriangleFill from "svelte-bootstrap-icons/lib/ExclamationTriangleFill.svelte";
import XOctagonFill from "svelte-bootstrap-icons/lib/XOctagonFill.svelte";
import QuestionCircleFill from "svelte-bootstrap-icons/lib/QuestionCircleFill.svelte";
import { idTimestamp } from "$lib";
import { DateTime } from "luxon";
type Props = { notification: Notification };
let { notification }: Props = $props();
let Icon = $derived.by(() => {
if (notification.type === "NOTICE") return InfoCircleFill;
if (notification.type === "WARNING") return ExclamationTriangleFill;
if (notification.type === "SUSPENSION") return XOctagonFill;
return QuestionCircleFill;
});
</script>
<div class="card mb-2">
<div class="card-body">
<div class="d-flex">
<div aria-hidden="true">
<Icon width={48} height={48} />
</div>
<div class="mx-3">
<p class="card-text text-has-newline">
{#if notification.localization_key}
{$t(notification.localization_key, notification.localization_params)}
{:else}
{notification.message}
{/if}
</p>
</div>
</div>
</div>
<div class="card-footer text-body-secondary">
{idTimestamp(notification.id).toLocaleString(DateTime.DATETIME_MED)}
<a href="/settings/notifications/ack/{notification.id}">{$t("notification.mark-as-read")}</a>
</div>
</div>

View file

@ -29,6 +29,8 @@ export default function errorDescription(t: TranslateFn, code: ErrorCode): strin
return t("error.account-already-linked");
case ErrorCode.LastAuthMethod:
return t("error.last-auth-method");
case ErrorCode.PageNotFound:
return t("error.page-not-found");
case ErrorCode.Non204Response:
return t("error.generic-error");
}

View file

@ -9,7 +9,9 @@
"reactivate-account-link": "Reactivate account",
"delete-permanently-link": "I want my account deleted permanently",
"reactivate-or-delete-link": "I want to reactivate my account or delete all my data",
"export-link": "I want to export a copy of my data"
"export-link": "I want to export a copy of my data",
"unread-notification-text": "You have an unread notification.",
"unread-notification-link": "Go to your notifications"
},
"avatar-tooltip": "Avatar for {{name}}",
"profile": {
@ -86,7 +88,8 @@
"unlink-discord-header": "Unlink Discord account",
"unlink-confirmation-1": "Are you sure you want to unlink {{username}} from your account?",
"unlink-confirmation-2": "You will no longer be able to use this account to log in. Please make sure at least one of your other linked accounts is accessible before continuing.",
"unlink-button": "Unlink account"
"unlink-button": "Unlink account",
"log-in-3rd-party-desc-no-email": "You can use any of the following services to log in. You can add or remove others at any time."
},
"error": {
"bad-request-header": "Something was wrong with your input",
@ -120,7 +123,9 @@
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
"500-description": "Something went wrong on the server. Please try again later.",
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again.",
"error-id": "If you report this error to the developers, please give them this ID:"
"error-id": "If you report this error to the developers, please give them this ID:",
"page-not-found": "No page exists at this URL.",
"not-found-header": "That page could not be found"
},
"settings": {
"general-information-tab": "General information",
@ -287,7 +292,11 @@
"custom-preference-size-small": "Small",
"custom-preference-size": "Text size",
"custom-preference-muted": "Show as muted text",
"custom-preference-favourite": "Treat like favourite"
"custom-preference-favourite": "Treat like favourite",
"custom-preference-notice": "Want to edit your custom preferences?",
"custom-preference-notice-link": "Go to settings",
"crop-avatar-header": "Crop avatar",
"crop-avatar-button": "Crop"
},
"cancel": "Cancel",
"report": {
@ -321,6 +330,28 @@
"required": "Required"
},
"alert": {
"auth-method-remove-success": "Successfully unlinked account!"
"auth-method-remove-success": "Successfully unlinked account!",
"auth-required": "You must log in to access this page.",
"notif-ack-successful": "Successfully marked notification as read!",
"notif-ack-fail": "Could not mark notification as read."
},
"footer": {
"version": "Version",
"users": "users",
"members": "members",
"source": "Source code",
"status": "Status",
"terms": "Terms of service",
"privacy": "Privacy policy",
"changelog": "Changelog",
"donate": "Donate",
"about-contact": "About and contact"
},
"notification": {
"suspension": "Your account has been suspended for the following reason: {{reason}}",
"warning": "You have been warned for the following reason: {{reason}}",
"warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}",
"mark-as-read": "Mark as read",
"no-notifications": "You have no notifications."
}
}

View file

@ -0,0 +1,37 @@
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
import log from "./log";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Snapshot } from "@sveltejs/kit";
/**
* Store ephemeral state in sessionStorage to persist between navigations.
* Similar to {@link Snapshot}, but doesn't attach it to a history entry.
* @param key Unique key to use for this state.
* @param capture Function that returns the state to store.
* @param restore Function that takes the state that was stored previously and assigns it back to component variables.
*/
export default function ephemeralState<T>(
key: string,
capture: () => T,
restore: (data: T) => void,
): void {
if (!browser) return;
onMount(() => {
if (!("sessionStorage" in window)) return;
const rawData = sessionStorage.getItem("ephemeral-" + key);
if (!rawData) return;
log.debug("Restoring data %s from session storage", key);
const data = JSON.parse(rawData) as T;
restore(data);
});
onDestroy(() => {
if (!("sessionStorage" in window)) return;
log.debug("Saving data %s to session storage", key);
sessionStorage.setItem("ephemeral-" + key, JSON.stringify(capture()));
});
}

View file

@ -2,16 +2,24 @@ import { clearToken, TOKEN_COOKIE_NAME } from "$lib";
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import type { Meta, MeUser } from "$api/models";
import type { Notification } from "$api/models/moderation";
import log from "$lib/log";
import type { LayoutServerLoad } from "./$types";
export const load = (async ({ fetch, cookies }) => {
let token: string | null = null;
let meUser: MeUser | null = null;
let unreadNotifications: boolean = false;
if (cookies.get(TOKEN_COOKIE_NAME)) {
try {
meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies });
token = cookies.get(TOKEN_COOKIE_NAME) || null;
const notifications = await apiRequest<Notification[]>("GET", "/notifications", {
fetch,
cookies,
});
unreadNotifications = notifications.filter((n) => !n.acknowledged).length > 0;
} catch (e) {
if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies);
else log.error("Could not fetch /users/@me and token has not expired:", e);
@ -19,5 +27,5 @@ export const load = (async ({ fetch, cookies }) => {
}
const meta = await apiRequest<Meta>("GET", "/meta", { fetch, cookies });
return { meta, meUser, token };
return { meta, meUser, token, unreadNotifications };
}) satisfies LayoutServerLoad;

View file

@ -11,7 +11,7 @@
<div class="d-flex flex-column min-vh-100">
<div class="flex-grow-1">
<Navbar user={data.meUser} meta={data.meta} />
<Navbar user={data.meUser} meta={data.meta} unreadNotifications={data.unreadNotifications} />
{@render children?.()}
</div>
<Footer meta={data.meta} />

View file

@ -1,4 +1,5 @@
<script lang="ts">
import GlobalNotice from "$components/GlobalNotice.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
@ -10,6 +11,15 @@
</svelte:head>
<div class="container">
{#if data.meta.notice}
<GlobalNotice
id={data.meta.notice.id}
message={data.meta.notice.message}
settings={data.meUser?.settings}
token={data.token}
/>
{/if}
<h1>pronouns.cc</h1>
<p>

View file

@ -19,5 +19,5 @@ export const load = async ({ url, fetch, cookies }) => {
fetch,
cookies,
});
return { reports, url: url.toString(), byReporter, byTarget, before, after };
return { reports, url: url.toString(), includeClosed, byReporter, byTarget, before, after };
};

View file

@ -18,6 +18,14 @@
return url.toString();
};
const addClosed = () => {
const url = new URL(data.url);
if (!data.includeClosed) url.searchParams.set("include-closed", "true");
else url.searchParams.delete("include-closed");
return url.toString();
};
const addTarget = (id: string | null) => {
const url = new URL(data.url);
if (id) url.searchParams.set("by-target", id);
@ -56,6 +64,11 @@
{#if data.byReporter}
<li>Filtering by reporter (<a href={addReporter(null)}>clear</a>)</li>
{/if}
{#if data.includeClosed}
<li>Showing all reports (<a href={addClosed()}>only show open reports</a>)</li>
{:else}
<li>Showing open reports (<a href={addClosed()}>show all reports</a>)</li>
{/if}
</ul>
{#if data.before}

View file

@ -2,15 +2,15 @@ import { isRedirect, redirect } from "@sveltejs/kit";
import { apiRequest } from "$api";
import type { AuthResponse, AuthUrls } from "$api/models/auth";
import { setToken } from "$lib";
import { alertKey, setToken } from "$lib";
import ApiError, { ErrorCode } from "$api/error";
export const load = async ({ fetch, parent }) => {
export const load = async ({ fetch, parent, url }) => {
const parentData = await parent();
if (parentData.meUser) redirect(303, `/@${parentData.meUser.username}`);
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
return { urls };
return { urls, alertKey: alertKey(url) };
};
export const actions = {

View file

@ -3,6 +3,7 @@
import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
import ErrorAlert from "$components/ErrorAlert.svelte";
import UrlAlert from "$components/URLAlert.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -13,6 +14,7 @@
</svelte:head>
<div class="container">
<UrlAlert {data} />
<div class="row">
{#if form?.error}
<ErrorAlert error={form.error} />
@ -48,8 +50,13 @@
<div class="col-lg-3"></div>
{/if}
<div class="col-md">
{#if data.urls.email_enabled}
<h3>{$t("auth.log-in-3rd-party-header")}</h3>
<p>{$t("auth.log-in-3rd-party-desc")}</p>
{:else}
<h3>{$t("title.log-in")}</h3>
<p>{$t("auth.log-in-3rd-party-desc-no-email")}</p>
{/if}
<form method="POST" action="?/fediToggle" use:enhance>
<div class="list-group">
{#if data.urls.discord}

View file

@ -2,5 +2,5 @@ import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/auth/log-in");
if (!meUser) redirect(303, "/auth/log-in?alert=auth-required");
};

View file

@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const data = await parent();
if (!data.meUser) redirect(303, "/auth/log-in");
if (!data.meUser) redirect(303, "/auth/log-in?alert=auth-required");
return { user: data.meUser!, token: data.token! };
};

View file

@ -3,15 +3,28 @@
import { t } from "$lib/i18n";
import { Nav, NavLink } from "@sveltestrap/sveltestrap";
import { isActive } from "$lib/pageUtils.svelte";
import type { LayoutData } from "./$types";
import GlobalNotice from "$components/GlobalNotice.svelte";
type Props = { children: Snippet };
let { children }: Props = $props();
type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props();
</script>
<svelte:head>
<title>{$t("title.settings")} • pronouns.cc</title>
</svelte:head>
{#if data.meta.notice}
<div class="container">
<GlobalNotice
id={data.meta.notice.id}
message={data.meta.notice.message}
settings={data.meUser?.settings}
token={data.token}
/>
</div>
{/if}
<div class="container">
<Nav pills justified fill class="flex-column flex-md-row mb-2">
<NavLink active={isActive(["/settings", "/settings/force-log-out"])} href="/settings">

View file

@ -15,6 +15,7 @@
import BioEditor from "$components/editor/BioEditor.svelte";
import { PUBLIC_BASE_URL } from "$env/static/public";
import { enhance } from "$app/forms";
import ephemeralState from "$lib/state.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -61,6 +62,12 @@
// Bio is stored in a $state() so we have a markdown preview
let bio = $state(data.member.bio || "");
ephemeralState(
"member-" + data.member.id + "-bio",
() => bio,
(data) => (bio = data),
);
</script>
{#if error}

View file

@ -4,6 +4,7 @@
import { mergePreferences, type User } from "$api/models/user";
import FieldsEditor from "$components/editor/FieldsEditor.svelte";
import log from "$lib/log";
import ephemeralState from "$lib/state.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
@ -27,6 +28,12 @@
if (e instanceof ApiError) ok.error = e.obj;
}
};
ephemeralState(
"member-" + data.member.id + "-fields",
() => fields,
(data) => (fields = data),
);
</script>
<FieldsEditor bind:fields {ok} {allPreferences} {update} />

View file

@ -41,10 +41,16 @@
</script>
<ProfileFlagsEditor
stateKey="member-{data.member.id}-flags"
profileFlags={data.member.flags}
allFlags={data.flags}
save={flagSave}
form={flagForm}
/>
<LinksEditor currentLinks={data.member.links} save={linksSave} form={linksForm} />
<LinksEditor
stateKey="member-{data.member.id}-links"
currentLinks={data.member.links}
save={linksSave}
form={linksForm}
/>

View file

@ -3,12 +3,14 @@
import ApiError, { type RawApiError } from "$api/error";
import type { Member } from "$api/models";
import { mergePreferences } from "$api/models/user";
import CustomPreferencesNotice from "$components/editor/CustomPreferencesNotice.svelte";
import FieldEditor from "$components/editor/FieldEditor.svelte";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
import { t } from "$lib/i18n";
import log from "$lib/log";
import ephemeralState from "$lib/state.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
@ -21,6 +23,15 @@
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
ephemeralState(
"member-" + data.member.id + "-names-pronouns",
() => ({ names, pronouns }),
(data) => {
names = data.names;
pronouns = data.pronouns;
},
);
const update = async () => {
try {
const resp = await apiRequest<Member>("PATCH", `/users/@me/members/${data.member.id}`, {
@ -40,6 +51,7 @@
<NoscriptWarning />
<FormStatusMarker form={ok} />
<CustomPreferencesNotice />
<div>
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />

View file

@ -0,0 +1,11 @@
import { apiRequest } from "$api";
import type { Notification } from "$api/models/moderation";
import { alertKey } from "$lib";
export const load = async ({ url, fetch, cookies }) => {
const notifications = await apiRequest<Notification[]>("GET", "/notifications", {
fetch,
cookies,
});
return { notifications, alertKey: alertKey(url) };
};

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { PageData } from "./$types";
import Notification from "$components/settings/Notification.svelte";
import UrlAlert from "$components/URLAlert.svelte";
import { t } from "$lib/i18n";
type Props = { data: PageData };
let { data }: Props = $props();
</script>
<UrlAlert {data} />
{#each data.notifications as notification (notification.id)}
<Notification {notification} />
{:else}
{$t("notification.no-notifications")}
{/each}

View file

@ -0,0 +1,14 @@
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { fastRequest } from "$api";
import log from "$lib/log";
export const GET: RequestHandler = async ({ params, fetch, cookies }) => {
try {
await fastRequest("PUT", `/notifications/${params.id}/ack`, { fetch, cookies });
} catch (e) {
log.error("error acking notification %s:", params.id, e);
redirect(303, "/settings/notifications?alert=notif-ack-fail");
}
redirect(303, "/settings/notifications?alert=notif-ack-successful");
};

View file

@ -3,11 +3,18 @@
import type { ActionData, PageData } from "./$types";
import BioEditor from "$components/editor/BioEditor.svelte";
import { t } from "$lib/i18n";
import ephemeralState from "$lib/state.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let bio = $state(data.user.bio || "");
ephemeralState(
"user-bio",
() => bio,
(data) => (bio = data),
);
</script>
<h4>{$t("edit-profile.bio-tab")}</h4>

View file

@ -4,6 +4,7 @@
import { mergePreferences, type User } from "$api/models/user";
import FieldsEditor from "$components/editor/FieldsEditor.svelte";
import log from "$lib/log";
import ephemeralState from "$lib/state.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
@ -27,6 +28,12 @@
if (e instanceof ApiError) ok.error = e.obj;
}
};
ephemeralState(
"user-fields",
() => fields,
(data) => (fields = data),
);
</script>
<FieldsEditor bind:fields {ok} {allPreferences} {update} />

View file

@ -41,10 +41,16 @@
</script>
<ProfileFlagsEditor
stateKey="user-flags"
profileFlags={data.user.flags}
allFlags={data.flags}
save={flagSave}
form={flagForm}
/>
<LinksEditor currentLinks={data.user.links} save={linksSave} form={linksForm} />
<LinksEditor
stateKey="user-links"
currentLinks={data.user.links}
save={linksSave}
form={linksForm}
/>

View file

@ -2,12 +2,14 @@
import { apiRequest } from "$api";
import ApiError, { type RawApiError } from "$api/error";
import { mergePreferences, type User } from "$api/models/user";
import CustomPreferencesNotice from "$components/editor/CustomPreferencesNotice.svelte";
import FieldEditor from "$components/editor/FieldEditor.svelte";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
import { t } from "$lib/i18n";
import log from "$lib/log";
import ephemeralState from "$lib/state.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
@ -18,6 +20,15 @@
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
ephemeralState(
"user-names-pronouns",
() => ({ names, pronouns }),
(data) => {
names = data.names;
pronouns = data.pronouns;
},
);
const update = async () => {
try {
const resp = await apiRequest<User>("PATCH", "/users/@me", {
@ -37,6 +48,7 @@
<NoscriptWarning />
<FormStatusMarker form={ok} />
<CustomPreferencesNotice />
<div>
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />

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