Compare commits
	
		
			34 commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 77e74dd331 | |||
| bcdb2f9540 | |||
| a89a5b3494 | |||
| 1adb26e8b8 | |||
| 35c5b520db | |||
| b0431ff962 | |||
| b07f4b75c0 | |||
| 22be49976a | |||
| 3527acb8ba | |||
| 978b8e100e | |||
| f00f5b400e | |||
| f5f0416346 | |||
| 5d452824cd | |||
| bba322bd22 | |||
| 200e648772 | |||
| 790b39f730 | |||
| 7d0df67c06 | |||
| dd9d35249c | |||
| f99d10ecf0 | |||
| 7759225428 | |||
| cd24196cd1 | |||
| 7d6d4631b8 | |||
| a248536789 | |||
| 218c756a70 | |||
| 7ea6c62d67 | |||
| 64ea25e89e | |||
| f1f777ff82 | |||
| a72c0f41c3 | |||
| 6fe816404f | |||
| d1faf1ddee | |||
| 92bf933c10 | |||
| c8e4078b35 | |||
| 0c6e3bf38f | |||
| 30146556f5 | 
					 135 changed files with 11378 additions and 5082 deletions
				
			
		|  | @ -7,7 +7,7 @@ resharper_not_accessed_positional_property_local_highlighting = none | |||
| 
 | ||||
| # Microsoft .NET properties | ||||
| csharp_new_line_before_members_in_object_initializers = false | ||||
| csharp_preferred_modifier_order = public, internal, protected, private, file, new, required, abstract, virtual, sealed, static, override, extern, unsafe, volatile, async, readonly:suggestion | ||||
| csharp_preferred_modifier_order = public, internal, protected, private, file, new, virtual, override, required, abstract, sealed, static, extern, unsafe, volatile, async, readonly:suggestion | ||||
| 
 | ||||
| # ReSharper properties | ||||
| resharper_align_multiline_binary_expressions_chain = false | ||||
|  |  | |||
							
								
								
									
										5
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -14,3 +14,8 @@ docker/proxy-config.json | |||
| docker/frontend.env | ||||
| 
 | ||||
| Foxnouns.DataMigrator/apps.json | ||||
| migration-tools/avatar-proxy/config.json | ||||
| migration-tools/avatar-migrator/.env | ||||
| 
 | ||||
| out/ | ||||
| build/ | ||||
|  |  | |||
|  | @ -3,12 +3,8 @@ | |||
|   "tasks": [ | ||||
|     { | ||||
|       "name": "run-prettier", | ||||
|       "command": "pnpm", | ||||
|       "args": [ | ||||
|         "prettier", | ||||
|         "-w", | ||||
|         "${staged}" | ||||
|       ], | ||||
|       "command": "npx", | ||||
|       "args": ["prettier", "-w", "${staged}"], | ||||
|       "include": [ | ||||
|         "Foxnouns.Frontend/**/*.ts", | ||||
|         "Foxnouns.Frontend/**/*.json", | ||||
|  | @ -22,13 +18,8 @@ | |||
|     { | ||||
|       "name": "run-csharpier", | ||||
|       "command": "dotnet", | ||||
|       "args": [ | ||||
|         "csharpier", | ||||
|         "${staged}" | ||||
|       ], | ||||
|       "include": [ | ||||
|         "**/*.cs" | ||||
|       ] | ||||
|       "args": ["csharpier", "${staged}"], | ||||
|       "include": ["**/*.cs"] | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  |  | |||
							
								
								
									
										27
									
								
								DOCKER.md
									
										
									
									
									
								
							
							
						
						
									
										27
									
								
								DOCKER.md
									
										
									
									
									
								
							|  | @ -1,10 +1,29 @@ | |||
| # Running with Docker | ||||
| # Running with Docker (pre-built backend and rate limiter) *(linux/arm64 only)* | ||||
| 
 | ||||
| Because SvelteKit is a pain in the ass to build in a container, and processes secrets at build time, | ||||
| there is no pre-built frontend image available. | ||||
| If you don't want to build images on your server, I recommend running the frontend outside of Docker. | ||||
| This is preconfigured in `docker-compose.prebuilt.yml`: the backend, database, and rate limiter will run in Docker, | ||||
| while the frontend is run as a normal, non-containerized service. | ||||
| 
 | ||||
| 1. Copy `docker/config.example.ini` to `docker/config.ini`, and change the settings to your liking. | ||||
| 2. Copy `docker/proxy-config.example.json` to `docker/proxy-config.json`, and do the same. | ||||
| 3. Copy `docker/frontend.example.env` to `docker/frontend.env`, and do th esame. | ||||
| 4. Build with `docker compose build` | ||||
| 5. Run with `docker compose up` | ||||
| 3. Run with `docker compose up -f docker-compose.prebuilt.yml` | ||||
| 
 | ||||
| The backend will listen on port 5001 and metrics will be available on port 5002. | ||||
| The rate limiter (which is what should be exposed to the outside) will listen on port 5003. | ||||
| You can use `docker/Caddyfile` as an example for your reverse proxy. If you use nginx, good luck. | ||||
| 
 | ||||
| # Running with Docker (local builds) | ||||
| 
 | ||||
| In order to run *everything* in Docker, you'll have to build every container yourself. | ||||
| The advantage of this is that it's an all-in-one solution, where you only have to point your reverse proxy at a single container. | ||||
| The disadvantage is that you'll likely have to build the images on the server you'll be running them on.  | ||||
| 
 | ||||
| 1. Configure the backend and rate limiter as in the section above. | ||||
| 2. Copy `docker/frontend.example.env` to `docker/frontend.env`, and configure it. | ||||
| 3. Build with `docker compose build -f docker-compose.local.yml` | ||||
| 4. Run with `docker compose up -f docker-compose.local.yml` | ||||
| 
 | ||||
| The Caddy server will listen on `localhost:5004` for the frontend and API, | ||||
| and on `localhost:5005` for the profile URL shortener. | ||||
|  |  | |||
|  | @ -26,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; | ||||
|  |  | |||
|  | @ -46,7 +46,7 @@ public class AuthController( | |||
|             config.GoogleAuth.Enabled, | ||||
|             config.TumblrAuth.Enabled | ||||
|         ); | ||||
|         string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); | ||||
|         string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync()); | ||||
|         string? discord = null; | ||||
|         string? google = null; | ||||
|         string? tumblr = null; | ||||
|  |  | |||
|  | @ -56,7 +56,7 @@ public class EmailAuthController( | |||
|         if (!req.Email.Contains('@')) | ||||
|             throw new ApiError.BadRequest("Email is invalid", "email", req.Email); | ||||
| 
 | ||||
|         string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null, ct); | ||||
|         string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null); | ||||
| 
 | ||||
|         // If there's already a user with that email address, pretend we sent an email but actually ignore it | ||||
|         if ( | ||||
|  |  | |||
|  | @ -12,7 +12,6 @@ | |||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Coravel.Queuing.Interfaces; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Dto; | ||||
|  | @ -28,13 +27,8 @@ namespace Foxnouns.Backend.Controllers; | |||
| [Authorize("identify")] | ||||
| [Limit(UsableByDeletedUsers = true)] | ||||
| [ApiExplorerSettings(IgnoreApi = true)] | ||||
| public class ExportsController( | ||||
|     ILogger logger, | ||||
|     Config config, | ||||
|     IClock clock, | ||||
|     DatabaseContext db, | ||||
|     IQueue queue | ||||
| ) : ApiControllerBase | ||||
| public class ExportsController(ILogger logger, Config config, IClock clock, DatabaseContext db) | ||||
|     : ApiControllerBase | ||||
| { | ||||
|     private static readonly Duration MinimumTimeBetween = Duration.FromDays(1); | ||||
|     private readonly ILogger _logger = logger.ForContext<ExportsController>(); | ||||
|  | @ -80,10 +74,7 @@ public class ExportsController( | |||
|             throw new ApiError.BadRequest("You can't request a new data export so soon."); | ||||
|         } | ||||
| 
 | ||||
|         queue.QueueInvocableWithPayload<CreateDataExportInvocable, CreateDataExportPayload>( | ||||
|             new CreateDataExportPayload(CurrentUser.Id) | ||||
|         ); | ||||
| 
 | ||||
|         CreateDataExportJob.Enqueue(CurrentUser.Id); | ||||
|         return NoContent(); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -12,7 +12,6 @@ | |||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Coravel.Queuing.Interfaces; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Dto; | ||||
|  | @ -30,8 +29,7 @@ namespace Foxnouns.Backend.Controllers; | |||
| public class FlagsController( | ||||
|     DatabaseContext db, | ||||
|     UserRendererService userRenderer, | ||||
|     ISnowflakeGenerator snowflakeGenerator, | ||||
|     IQueue queue | ||||
|     ISnowflakeGenerator snowflakeGenerator | ||||
| ) : ApiControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|  | @ -74,10 +72,7 @@ public class FlagsController( | |||
|         db.Add(flag); | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>( | ||||
|             new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image) | ||||
|         ); | ||||
| 
 | ||||
|         CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image)); | ||||
|         return Accepted(userRenderer.RenderPrideFlag(flag)); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -12,7 +12,6 @@ | |||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Coravel.Queuing.Interfaces; | ||||
| using EntityFramework.Exceptions.Common; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
|  | @ -37,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(); | ||||
|  |  | |||
|  | @ -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(); | ||||
|  |  | |||
							
								
								
									
										77
									
								
								Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,77 @@ | |||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Dto; | ||||
| using Foxnouns.Backend.Middleware; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Controllers.Moderation; | ||||
| 
 | ||||
| [Route("/api/v2/notices")] | ||||
| [Authorize("user.moderation")] | ||||
| [Limit(RequireModerator = true)] | ||||
| public class NoticesController( | ||||
|     DatabaseContext db, | ||||
|     UserRendererService userRenderer, | ||||
|     ISnowflakeGenerator snowflakeGenerator, | ||||
|     IClock clock | ||||
| ) : ApiControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|     public async Task<IActionResult> GetNoticesAsync(CancellationToken ct = default) | ||||
|     { | ||||
|         List<Notice> notices = await db | ||||
|             .Notices.Include(n => n.Author) | ||||
|             .OrderByDescending(n => n.Id) | ||||
|             .ToListAsync(ct); | ||||
|         return Ok(notices.Select(RenderNotice)); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost] | ||||
|     public async Task<IActionResult> CreateNoticeAsync(CreateNoticeRequest req) | ||||
|     { | ||||
|         Instant now = clock.GetCurrentInstant(); | ||||
|         if (req.StartTime < now) | ||||
|         { | ||||
|             throw new ApiError.BadRequest( | ||||
|                 "Start time cannot be in the past", | ||||
|                 "start_time", | ||||
|                 req.StartTime | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         if (req.EndTime < now) | ||||
|         { | ||||
|             throw new ApiError.BadRequest( | ||||
|                 "End time cannot be in the past", | ||||
|                 "end_time", | ||||
|                 req.EndTime | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         var notice = new Notice | ||||
|         { | ||||
|             Id = snowflakeGenerator.GenerateSnowflake(), | ||||
|             Message = req.Message, | ||||
|             StartTime = req.StartTime ?? clock.GetCurrentInstant(), | ||||
|             EndTime = req.EndTime, | ||||
|             Author = CurrentUser!, | ||||
|         }; | ||||
| 
 | ||||
|         db.Add(notice); | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         return Ok(RenderNotice(notice)); | ||||
|     } | ||||
| 
 | ||||
|     private NoticeResponse RenderNotice(Notice notice) => | ||||
|         new( | ||||
|             notice.Id, | ||||
|             notice.Message, | ||||
|             notice.StartTime, | ||||
|             notice.EndTime, | ||||
|             userRenderer.RenderPartialUser(notice.Author) | ||||
|         ); | ||||
| } | ||||
|  | @ -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); | ||||
| 
 | ||||
|  |  | |||
|  | @ -64,7 +64,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|     public DbSet<FediverseApplication> FediverseApplications { get; init; } = null!; | ||||
|     public DbSet<Token> Tokens { get; init; } = null!; | ||||
|     public DbSet<Application> Applications { get; init; } = null!; | ||||
|     public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!; | ||||
|     public DbSet<DataExport> DataExports { get; init; } = null!; | ||||
| 
 | ||||
|     public DbSet<PrideFlag> PrideFlags { get; init; } = null!; | ||||
|  | @ -74,6 +73,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|     public DbSet<Report> Reports { get; init; } = null!; | ||||
|     public DbSet<AuditLogEntry> AuditLog { get; init; } = null!; | ||||
|     public DbSet<Notification> Notifications { get; init; } = null!; | ||||
|     public DbSet<Notice> Notices { get; init; } = null!; | ||||
| 
 | ||||
|     protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) | ||||
|     { | ||||
|  | @ -87,7 +87,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|         modelBuilder.Entity<User>().HasIndex(u => u.Sid).IsUnique(); | ||||
|         modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); | ||||
|         modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique(); | ||||
|         modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); | ||||
|         modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique(); | ||||
| 
 | ||||
|         // Two indexes on auth_methods, one for fediverse auth and one for all other types. | ||||
|  |  | |||
|  | @ -0,0 +1,55 @@ | |||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
| 
 | ||||
| #nullable disable | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [DbContext(typeof(DatabaseContext))] | ||||
|     [Migration("20250304155708_RemoveTemporaryKeys")] | ||||
|     public partial class RemoveTemporaryKeys : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropTable(name: "temporary_keys"); | ||||
|         } | ||||
| 
 | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "temporary_keys", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table | ||||
|                         .Column<long>(type: "bigint", nullable: false) | ||||
|                         .Annotation( | ||||
|                             "Npgsql:ValueGenerationStrategy", | ||||
|                             NpgsqlValueGenerationStrategy.IdentityByDefaultColumn | ||||
|                         ), | ||||
|                     expires = table.Column<Instant>( | ||||
|                         type: "timestamp with time zone", | ||||
|                         nullable: false | ||||
|                     ), | ||||
|                     key = table.Column<string>(type: "text", nullable: false), | ||||
|                     value = table.Column<string>(type: "text", nullable: false), | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_temporary_keys", x => x.id); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_temporary_keys_key", | ||||
|                 table: "temporary_keys", | ||||
|                 column: "key", | ||||
|                 unique: true | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										915
									
								
								Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										915
									
								
								Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs
									
										
									
										generated
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,915 @@ | |||
| // <auto-generated /> | ||||
| using System.Collections.Generic; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
| 
 | ||||
| #nullable disable | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Migrations | ||||
| { | ||||
|     [DbContext(typeof(DatabaseContext))] | ||||
|     [Migration("20250329131053_AddNotices")] | ||||
|     partial class AddNotices | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.2") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
| 
 | ||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<string>("ClientId") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("client_id"); | ||||
| 
 | ||||
|                     b.Property<string>("ClientSecret") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("client_secret"); | ||||
| 
 | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("name"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("RedirectUris") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("redirect_uris"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("Scopes") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("scopes"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_applications"); | ||||
| 
 | ||||
|                     b.ToTable("applications", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("ClearedFields") | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("cleared_fields"); | ||||
| 
 | ||||
|                     b.Property<long>("ModeratorId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("moderator_id"); | ||||
| 
 | ||||
|                     b.Property<string>("ModeratorUsername") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("moderator_username"); | ||||
| 
 | ||||
|                     b.Property<string>("Reason") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("reason"); | ||||
| 
 | ||||
|                     b.Property<long?>("ReportId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("report_id"); | ||||
| 
 | ||||
|                     b.Property<long?>("TargetMemberId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_member_id"); | ||||
| 
 | ||||
|                     b.Property<string>("TargetMemberName") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("target_member_name"); | ||||
| 
 | ||||
|                     b.Property<long?>("TargetUserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_user_id"); | ||||
| 
 | ||||
|                     b.Property<string>("TargetUsername") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("target_username"); | ||||
| 
 | ||||
|                     b.Property<int>("Type") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("type"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_audit_log"); | ||||
| 
 | ||||
|                     b.HasIndex("ReportId") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_audit_log_report_id"); | ||||
| 
 | ||||
|                     b.ToTable("audit_log", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<int>("AuthType") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("auth_type"); | ||||
| 
 | ||||
|                     b.Property<long?>("FediverseApplicationId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("fediverse_application_id"); | ||||
| 
 | ||||
|                     b.Property<string>("RemoteId") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("remote_id"); | ||||
| 
 | ||||
|                     b.Property<string>("RemoteUsername") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("remote_username"); | ||||
| 
 | ||||
|                     b.Property<long>("UserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_auth_methods"); | ||||
| 
 | ||||
|                     b.HasIndex("FediverseApplicationId") | ||||
|                         .HasDatabaseName("ix_auth_methods_fediverse_application_id"); | ||||
| 
 | ||||
|                     b.HasIndex("UserId") | ||||
|                         .HasDatabaseName("ix_auth_methods_user_id"); | ||||
| 
 | ||||
|                     b.HasIndex("AuthType", "RemoteId") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_auth_methods_auth_type_remote_id") | ||||
|                         .HasFilter("fediverse_application_id IS NULL"); | ||||
| 
 | ||||
|                     b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id") | ||||
|                         .HasFilter("fediverse_application_id IS NOT NULL"); | ||||
| 
 | ||||
|                     b.ToTable("auth_methods", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<string>("Filename") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("filename"); | ||||
| 
 | ||||
|                     b.Property<long>("UserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_data_exports"); | ||||
| 
 | ||||
|                     b.HasIndex("Filename") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_data_exports_filename"); | ||||
| 
 | ||||
|                     b.HasIndex("UserId") | ||||
|                         .HasDatabaseName("ix_data_exports_user_id"); | ||||
| 
 | ||||
|                     b.ToTable("data_exports", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<string>("ClientId") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("client_id"); | ||||
| 
 | ||||
|                     b.Property<string>("ClientSecret") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("client_secret"); | ||||
| 
 | ||||
|                     b.Property<string>("Domain") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("domain"); | ||||
| 
 | ||||
|                     b.Property<bool>("ForceRefresh") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("force_refresh"); | ||||
| 
 | ||||
|                     b.Property<int>("InstanceType") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("instance_type"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_fediverse_applications"); | ||||
| 
 | ||||
|                     b.ToTable("fediverse_applications", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<string>("Avatar") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("avatar"); | ||||
| 
 | ||||
|                     b.Property<string>("Bio") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("bio"); | ||||
| 
 | ||||
|                     b.Property<string>("DisplayName") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("display_name"); | ||||
| 
 | ||||
|                     b.Property<List<Field>>("Fields") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("fields"); | ||||
| 
 | ||||
|                     b.Property<string>("LegacyId") | ||||
|                         .IsRequired() | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("legacy_id") | ||||
|                         .HasDefaultValueSql("gen_random_uuid()"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("Links") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("links"); | ||||
| 
 | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("name"); | ||||
| 
 | ||||
|                     b.Property<List<FieldEntry>>("Names") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("names"); | ||||
| 
 | ||||
|                     b.Property<List<Pronoun>>("Pronouns") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("pronouns"); | ||||
| 
 | ||||
|                     b.Property<string>("Sid") | ||||
|                         .IsRequired() | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("sid") | ||||
|                         .HasDefaultValueSql("find_free_member_sid()"); | ||||
| 
 | ||||
|                     b.Property<bool>("Unlisted") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("unlisted"); | ||||
| 
 | ||||
|                     b.Property<long>("UserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_members"); | ||||
| 
 | ||||
|                     b.HasIndex("LegacyId") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_members_legacy_id"); | ||||
| 
 | ||||
|                     b.HasIndex("Sid") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_members_sid"); | ||||
| 
 | ||||
|                     b.HasIndex("UserId", "Name") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_members_user_id_name"); | ||||
| 
 | ||||
|                     b.ToTable("members", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); | ||||
| 
 | ||||
|                     b.Property<long>("MemberId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("member_id"); | ||||
| 
 | ||||
|                     b.Property<long>("PrideFlagId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("pride_flag_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_member_flags"); | ||||
| 
 | ||||
|                     b.HasIndex("MemberId") | ||||
|                         .HasDatabaseName("ix_member_flags_member_id"); | ||||
| 
 | ||||
|                     b.HasIndex("PrideFlagId") | ||||
|                         .HasDatabaseName("ix_member_flags_pride_flag_id"); | ||||
| 
 | ||||
|                     b.ToTable("member_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<long>("AuthorId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("author_id"); | ||||
| 
 | ||||
|                     b.Property<Instant>("EndTime") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("end_time"); | ||||
| 
 | ||||
|                     b.Property<string>("Message") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("message"); | ||||
| 
 | ||||
|                     b.Property<Instant>("StartTime") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("start_time"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_notices"); | ||||
| 
 | ||||
|                     b.HasIndex("AuthorId") | ||||
|                         .HasDatabaseName("ix_notices_author_id"); | ||||
| 
 | ||||
|                     b.ToTable("notices", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<Instant?>("AcknowledgedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("acknowledged_at"); | ||||
| 
 | ||||
|                     b.Property<string>("LocalizationKey") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("localization_key"); | ||||
| 
 | ||||
|                     b.Property<Dictionary<string, string>>("LocalizationParams") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("hstore") | ||||
|                         .HasColumnName("localization_params"); | ||||
| 
 | ||||
|                     b.Property<string>("Message") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("message"); | ||||
| 
 | ||||
|                     b.Property<long>("TargetId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_id"); | ||||
| 
 | ||||
|                     b.Property<int>("Type") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("type"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_notifications"); | ||||
| 
 | ||||
|                     b.HasIndex("TargetId") | ||||
|                         .HasDatabaseName("ix_notifications_target_id"); | ||||
| 
 | ||||
|                     b.ToTable("notifications", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("description"); | ||||
| 
 | ||||
|                     b.Property<string>("Hash") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("hash"); | ||||
| 
 | ||||
|                     b.Property<string>("LegacyId") | ||||
|                         .IsRequired() | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("legacy_id") | ||||
|                         .HasDefaultValueSql("gen_random_uuid()"); | ||||
| 
 | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("name"); | ||||
| 
 | ||||
|                     b.Property<long>("UserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_pride_flags"); | ||||
| 
 | ||||
|                     b.HasIndex("LegacyId") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_pride_flags_legacy_id"); | ||||
| 
 | ||||
|                     b.HasIndex("UserId") | ||||
|                         .HasDatabaseName("ix_pride_flags_user_id"); | ||||
| 
 | ||||
|                     b.ToTable("pride_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<string>("Context") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("context"); | ||||
| 
 | ||||
|                     b.Property<int>("Reason") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("reason"); | ||||
| 
 | ||||
|                     b.Property<long>("ReporterId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("reporter_id"); | ||||
| 
 | ||||
|                     b.Property<int>("Status") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("status"); | ||||
| 
 | ||||
|                     b.Property<long?>("TargetMemberId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_member_id"); | ||||
| 
 | ||||
|                     b.Property<string>("TargetSnapshot") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("target_snapshot"); | ||||
| 
 | ||||
|                     b.Property<int>("TargetType") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("target_type"); | ||||
| 
 | ||||
|                     b.Property<long>("TargetUserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_reports"); | ||||
| 
 | ||||
|                     b.HasIndex("ReporterId") | ||||
|                         .HasDatabaseName("ix_reports_reporter_id"); | ||||
| 
 | ||||
|                     b.HasIndex("TargetMemberId") | ||||
|                         .HasDatabaseName("ix_reports_target_member_id"); | ||||
| 
 | ||||
|                     b.HasIndex("TargetUserId") | ||||
|                         .HasDatabaseName("ix_reports_target_user_id"); | ||||
| 
 | ||||
|                     b.ToTable("reports", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<long>("ApplicationId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("application_id"); | ||||
| 
 | ||||
|                     b.Property<Instant>("ExpiresAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expires_at"); | ||||
| 
 | ||||
|                     b.Property<byte[]>("Hash") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("bytea") | ||||
|                         .HasColumnName("hash"); | ||||
| 
 | ||||
|                     b.Property<bool>("ManuallyExpired") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("manually_expired"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("Scopes") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("scopes"); | ||||
| 
 | ||||
|                     b.Property<long>("UserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_tokens"); | ||||
| 
 | ||||
|                     b.HasIndex("ApplicationId") | ||||
|                         .HasDatabaseName("ix_tokens_application_id"); | ||||
| 
 | ||||
|                     b.HasIndex("UserId") | ||||
|                         .HasDatabaseName("ix_tokens_user_id"); | ||||
| 
 | ||||
|                     b.ToTable("tokens", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<string>("Avatar") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("avatar"); | ||||
| 
 | ||||
|                     b.Property<string>("Bio") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("bio"); | ||||
| 
 | ||||
|                     b.Property<Dictionary<Snowflake, User.CustomPreference>>("CustomPreferences") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("custom_preferences"); | ||||
| 
 | ||||
|                     b.Property<bool>("Deleted") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("deleted"); | ||||
| 
 | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
| 
 | ||||
|                     b.Property<long?>("DeletedBy") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("deleted_by"); | ||||
| 
 | ||||
|                     b.Property<string>("DisplayName") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("display_name"); | ||||
| 
 | ||||
|                     b.Property<List<Field>>("Fields") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("fields"); | ||||
| 
 | ||||
|                     b.Property<Instant>("LastActive") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_active"); | ||||
| 
 | ||||
|                     b.Property<Instant>("LastSidReroll") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_sid_reroll"); | ||||
| 
 | ||||
|                     b.Property<string>("LegacyId") | ||||
|                         .IsRequired() | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("legacy_id") | ||||
|                         .HasDefaultValueSql("gen_random_uuid()"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("Links") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("links"); | ||||
| 
 | ||||
|                     b.Property<bool>("ListHidden") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("list_hidden"); | ||||
| 
 | ||||
|                     b.Property<string>("MemberTitle") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("member_title"); | ||||
| 
 | ||||
|                     b.Property<List<FieldEntry>>("Names") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("names"); | ||||
| 
 | ||||
|                     b.Property<string>("Password") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("password"); | ||||
| 
 | ||||
|                     b.Property<List<Pronoun>>("Pronouns") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("pronouns"); | ||||
| 
 | ||||
|                     b.Property<int>("Role") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("role"); | ||||
| 
 | ||||
|                     b.Property<UserSettings>("Settings") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("settings"); | ||||
| 
 | ||||
|                     b.Property<string>("Sid") | ||||
|                         .IsRequired() | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("sid") | ||||
|                         .HasDefaultValueSql("find_free_user_sid()"); | ||||
| 
 | ||||
|                     b.Property<string>("Timezone") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("timezone"); | ||||
| 
 | ||||
|                     b.Property<string>("Username") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("username"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_users"); | ||||
| 
 | ||||
|                     b.HasIndex("LegacyId") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_users_legacy_id"); | ||||
| 
 | ||||
|                     b.HasIndex("Sid") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_users_sid"); | ||||
| 
 | ||||
|                     b.HasIndex("Username") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_users_username"); | ||||
| 
 | ||||
|                     b.ToTable("users", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); | ||||
| 
 | ||||
|                     b.Property<long>("PrideFlagId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("pride_flag_id"); | ||||
| 
 | ||||
|                     b.Property<long>("UserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_user_flags"); | ||||
| 
 | ||||
|                     b.HasIndex("PrideFlagId") | ||||
|                         .HasDatabaseName("ix_user_flags_pride_flag_id"); | ||||
| 
 | ||||
|                     b.HasIndex("UserId") | ||||
|                         .HasDatabaseName("ix_user_flags_user_id"); | ||||
| 
 | ||||
|                     b.ToTable("user_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report") | ||||
|                         .WithOne("AuditLogEntry") | ||||
|                         .HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId") | ||||
|                         .OnDelete(DeleteBehavior.SetNull) | ||||
|                         .HasConstraintName("fk_audit_log_reports_report_id"); | ||||
| 
 | ||||
|                     b.Navigation("Report"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("FediverseApplicationId") | ||||
|                         .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany("AuthMethods") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_auth_methods_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("FediverseApplication"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany("DataExports") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_data_exports_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany("Members") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_members_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Member", null) | ||||
|                         .WithMany("ProfileFlags") | ||||
|                         .HasForeignKey("MemberId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_member_flags_members_member_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PrideFlagId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_member_flags_pride_flags_pride_flag_id"); | ||||
| 
 | ||||
|                     b.Navigation("PrideFlag"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Author") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("AuthorId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_notices_users_author_id"); | ||||
| 
 | ||||
|                     b.Navigation("Author"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Target") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_notifications_users_target_id"); | ||||
| 
 | ||||
|                     b.Navigation("Target"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", null) | ||||
|                         .WithMany("Flags") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_pride_flags_users_user_id"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ReporterId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_reports_users_reporter_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetMemberId") | ||||
|                         .HasConstraintName("fk_reports_members_target_member_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetUserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_reports_users_target_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("Reporter"); | ||||
| 
 | ||||
|                     b.Navigation("TargetMember"); | ||||
| 
 | ||||
|                     b.Navigation("TargetUser"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ApplicationId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_tokens_applications_application_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_tokens_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("Application"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PrideFlagId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_user_flags_pride_flags_pride_flag_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", null) | ||||
|                         .WithMany("ProfileFlags") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_user_flags_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("PrideFlag"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => | ||||
|                 { | ||||
|                     b.Navigation("ProfileFlags"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => | ||||
|                 { | ||||
|                     b.Navigation("AuditLogEntry"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => | ||||
|                 { | ||||
|                     b.Navigation("AuthMethods"); | ||||
| 
 | ||||
|                     b.Navigation("DataExports"); | ||||
| 
 | ||||
|                     b.Navigation("Flags"); | ||||
| 
 | ||||
|                     b.Navigation("Members"); | ||||
| 
 | ||||
|                     b.Navigation("ProfileFlags"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,56 @@ | |||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NodaTime; | ||||
| 
 | ||||
| #nullable disable | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddNotices : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "notices", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     message = table.Column<string>(type: "text", nullable: false), | ||||
|                     start_time = table.Column<Instant>( | ||||
|                         type: "timestamp with time zone", | ||||
|                         nullable: false | ||||
|                     ), | ||||
|                     end_time = table.Column<Instant>( | ||||
|                         type: "timestamp with time zone", | ||||
|                         nullable: false | ||||
|                     ), | ||||
|                     author_id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_notices", x => x.id); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_notices_users_author_id", | ||||
|                         column: x => x.author_id, | ||||
|                         principalTable: "users", | ||||
|                         principalColumn: "id", | ||||
|                         onDelete: ReferentialAction.Cascade | ||||
|                     ); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_notices_author_id", | ||||
|                 table: "notices", | ||||
|                 column: "author_id" | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropTable(name: "notices"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										923
									
								
								Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.Designer.cs
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										923
									
								
								Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.Designer.cs
									
										
									
										generated
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,923 @@ | |||
| // <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("20250410192220_AddAvatarMigrations")] | ||||
|     partial class AddAvatarMigrations | ||||
|     { | ||||
|         /// <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<bool>("AvatarMigrated") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("avatar_migrated"); | ||||
| 
 | ||||
|                     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<bool>("AvatarMigrated") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("avatar_migrated"); | ||||
| 
 | ||||
|                     b.Property<string>("Bio") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("bio"); | ||||
| 
 | ||||
|                     b.Property<Dictionary<Snowflake, User.CustomPreference>>("CustomPreferences") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("custom_preferences"); | ||||
| 
 | ||||
|                     b.Property<bool>("Deleted") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("deleted"); | ||||
| 
 | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
| 
 | ||||
|                     b.Property<long?>("DeletedBy") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("deleted_by"); | ||||
| 
 | ||||
|                     b.Property<string>("DisplayName") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("display_name"); | ||||
| 
 | ||||
|                     b.Property<List<Field>>("Fields") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("fields"); | ||||
| 
 | ||||
|                     b.Property<Instant>("LastActive") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_active"); | ||||
| 
 | ||||
|                     b.Property<Instant>("LastSidReroll") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_sid_reroll"); | ||||
| 
 | ||||
|                     b.Property<string>("LegacyId") | ||||
|                         .IsRequired() | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("legacy_id") | ||||
|                         .HasDefaultValueSql("gen_random_uuid()"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("Links") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("links"); | ||||
| 
 | ||||
|                     b.Property<bool>("ListHidden") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("list_hidden"); | ||||
| 
 | ||||
|                     b.Property<string>("MemberTitle") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("member_title"); | ||||
| 
 | ||||
|                     b.Property<List<FieldEntry>>("Names") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("names"); | ||||
| 
 | ||||
|                     b.Property<string>("Password") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("password"); | ||||
| 
 | ||||
|                     b.Property<List<Pronoun>>("Pronouns") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("pronouns"); | ||||
| 
 | ||||
|                     b.Property<int>("Role") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("role"); | ||||
| 
 | ||||
|                     b.Property<UserSettings>("Settings") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("settings"); | ||||
| 
 | ||||
|                     b.Property<string>("Sid") | ||||
|                         .IsRequired() | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("sid") | ||||
|                         .HasDefaultValueSql("find_free_user_sid()"); | ||||
| 
 | ||||
|                     b.Property<string>("Timezone") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("timezone"); | ||||
| 
 | ||||
|                     b.Property<string>("Username") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("username"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_users"); | ||||
| 
 | ||||
|                     b.HasIndex("LegacyId") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_users_legacy_id"); | ||||
| 
 | ||||
|                     b.HasIndex("Sid") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_users_sid"); | ||||
| 
 | ||||
|                     b.HasIndex("Username") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_users_username"); | ||||
| 
 | ||||
|                     b.ToTable("users", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); | ||||
| 
 | ||||
|                     b.Property<long>("PrideFlagId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("pride_flag_id"); | ||||
| 
 | ||||
|                     b.Property<long>("UserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_user_flags"); | ||||
| 
 | ||||
|                     b.HasIndex("PrideFlagId") | ||||
|                         .HasDatabaseName("ix_user_flags_pride_flag_id"); | ||||
| 
 | ||||
|                     b.HasIndex("UserId") | ||||
|                         .HasDatabaseName("ix_user_flags_user_id"); | ||||
| 
 | ||||
|                     b.ToTable("user_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report") | ||||
|                         .WithOne("AuditLogEntry") | ||||
|                         .HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId") | ||||
|                         .OnDelete(DeleteBehavior.SetNull) | ||||
|                         .HasConstraintName("fk_audit_log_reports_report_id"); | ||||
| 
 | ||||
|                     b.Navigation("Report"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("FediverseApplicationId") | ||||
|                         .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany("AuthMethods") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_auth_methods_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("FediverseApplication"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany("DataExports") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_data_exports_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany("Members") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_members_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Member", null) | ||||
|                         .WithMany("ProfileFlags") | ||||
|                         .HasForeignKey("MemberId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_member_flags_members_member_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PrideFlagId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_member_flags_pride_flags_pride_flag_id"); | ||||
| 
 | ||||
|                     b.Navigation("PrideFlag"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Author") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("AuthorId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_notices_users_author_id"); | ||||
| 
 | ||||
|                     b.Navigation("Author"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Target") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_notifications_users_target_id"); | ||||
| 
 | ||||
|                     b.Navigation("Target"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", null) | ||||
|                         .WithMany("Flags") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_pride_flags_users_user_id"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ReporterId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_reports_users_reporter_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetMemberId") | ||||
|                         .HasConstraintName("fk_reports_members_target_member_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetUserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_reports_users_target_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("Reporter"); | ||||
| 
 | ||||
|                     b.Navigation("TargetMember"); | ||||
| 
 | ||||
|                     b.Navigation("TargetUser"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ApplicationId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_tokens_applications_application_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "User") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_tokens_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("Application"); | ||||
| 
 | ||||
|                     b.Navigation("User"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PrideFlagId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_user_flags_pride_flags_pride_flag_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", null) | ||||
|                         .WithMany("ProfileFlags") | ||||
|                         .HasForeignKey("UserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_user_flags_users_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("PrideFlag"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => | ||||
|                 { | ||||
|                     b.Navigation("ProfileFlags"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => | ||||
|                 { | ||||
|                     b.Navigation("AuditLogEntry"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => | ||||
|                 { | ||||
|                     b.Navigation("AuthMethods"); | ||||
| 
 | ||||
|                     b.Navigation("DataExports"); | ||||
| 
 | ||||
|                     b.Navigation("Flags"); | ||||
| 
 | ||||
|                     b.Navigation("Members"); | ||||
| 
 | ||||
|                     b.Navigation("ProfileFlags"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,38 @@ | |||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| 
 | ||||
| #nullable disable | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddAvatarMigrations : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AddColumn<bool>( | ||||
|                 name: "avatar_migrated", | ||||
|                 table: "users", | ||||
|                 type: "boolean", | ||||
|                 nullable: false, | ||||
|                 defaultValue: false | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.AddColumn<bool>( | ||||
|                 name: "avatar_migrated", | ||||
|                 table: "members", | ||||
|                 type: "boolean", | ||||
|                 nullable: false, | ||||
|                 defaultValue: false | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn(name: "avatar_migrated", table: "users"); | ||||
| 
 | ||||
|             migrationBuilder.DropColumn(name: "avatar_migrated", table: "members"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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"); | ||||
|  | @ -241,6 +241,10 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("avatar"); | ||||
| 
 | ||||
|                     b.Property<bool>("AvatarMigrated") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("avatar_migrated"); | ||||
| 
 | ||||
|                     b.Property<string>("Bio") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("bio"); | ||||
|  | @ -343,6 +347,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 +515,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") | ||||
|  | @ -566,6 +569,10 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("avatar"); | ||||
| 
 | ||||
|                     b.Property<bool>("AvatarMigrated") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("avatar_migrated"); | ||||
| 
 | ||||
|                     b.Property<string>("Bio") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("bio"); | ||||
|  | @ -783,6 +790,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") | ||||
|  |  | |||
|  | @ -29,6 +29,9 @@ public class Member : BaseModel | |||
|     public List<Pronoun> Pronouns { get; set; } = []; | ||||
|     public List<Field> Fields { get; set; } = []; | ||||
| 
 | ||||
|     // Only used by avatar-proxy and avatar-migration. | ||||
|     public bool AvatarMigrated { get; set; } = true; | ||||
| 
 | ||||
|     public List<MemberFlag> ProfileFlags { get; set; } = []; | ||||
| 
 | ||||
|     public Snowflake UserId { get; init; } | ||||
|  |  | |||
							
								
								
									
										13
									
								
								Foxnouns.Backend/Database/Models/Notice.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								Foxnouns.Backend/Database/Models/Notice.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| public class Notice : BaseModel | ||||
| { | ||||
|     public required string Message { get; set; } | ||||
|     public required Instant StartTime { get; set; } | ||||
|     public required Instant EndTime { get; set; } | ||||
| 
 | ||||
|     public Snowflake AuthorId { get; init; } | ||||
|     public User Author { get; init; } = null!; | ||||
| } | ||||
|  | @ -1,25 +0,0 @@ | |||
| // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published | ||||
| // by the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| public class TemporaryKey | ||||
| { | ||||
|     public long Id { get; init; } | ||||
|     public required string Key { get; init; } | ||||
|     public required string Value { get; set; } | ||||
|     public Instant Expires { get; init; } | ||||
| } | ||||
|  | @ -57,6 +57,9 @@ public class User : BaseModel | |||
|     public Instant? DeletedAt { get; set; } | ||||
|     public Snowflake? DeletedBy { get; set; } | ||||
| 
 | ||||
|     // Only used by avatar-proxy and avatar-migration. | ||||
|     public bool AvatarMigrated { get; set; } = true; | ||||
| 
 | ||||
|     [NotMapped] | ||||
|     public bool? SelfDelete => Deleted ? DeletedBy != null : null; | ||||
| 
 | ||||
|  | @ -95,4 +98,5 @@ public enum PreferenceSize | |||
| public class UserSettings | ||||
| { | ||||
|     public bool? DarkMode { get; set; } | ||||
|     public Snowflake? LastReadNotice { get; set; } | ||||
| } | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -49,7 +49,8 @@ public record UserResponse( | |||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Suspended, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserSettings? Settings | ||||
| ); | ||||
| 
 | ||||
| public record CustomPreferenceResponse( | ||||
|  | @ -79,6 +80,7 @@ public record PartialUser( | |||
| public class UpdateUserSettingsRequest : PatchRequest | ||||
| { | ||||
|     public bool? DarkMode { get; init; } | ||||
|     public Snowflake? LastReadNotice { get; init; } | ||||
| } | ||||
| 
 | ||||
| public class CustomPreferenceUpdateRequest | ||||
|  |  | |||
|  | @ -164,6 +164,7 @@ public enum ErrorCode | |||
|     GenericApiError, | ||||
|     UserNotFound, | ||||
|     MemberNotFound, | ||||
|     PageNotFound, | ||||
|     AccountAlreadyLinked, | ||||
|     LastAuthMethod, | ||||
|     InvalidReportTarget, | ||||
|  |  | |||
|  | @ -33,24 +33,20 @@ public static class ImageObjectExtensions | |||
|         Snowflake id, | ||||
|         string hash, | ||||
|         CancellationToken ct = default | ||||
|     ) => | ||||
|         await objectStorageService.RemoveObjectAsync( | ||||
|             MemberAvatarUpdateInvocable.Path(id, hash), | ||||
|             ct | ||||
|         ); | ||||
|     ) => await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateJob.Path(id, hash), ct); | ||||
| 
 | ||||
|     public static async Task DeleteUserAvatarAsync( | ||||
|         this ObjectStorageService objectStorageService, | ||||
|         Snowflake id, | ||||
|         string hash, | ||||
|         CancellationToken ct = default | ||||
|     ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); | ||||
|     ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateJob.Path(id, hash), ct); | ||||
| 
 | ||||
|     public static async Task DeleteFlagAsync( | ||||
|         this ObjectStorageService objectStorageService, | ||||
|         string hash, | ||||
|         CancellationToken ct = default | ||||
|     ) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); | ||||
|     ) => await objectStorageService.RemoveObjectAsync(CreateFlagJob.Path(hash), ct); | ||||
| 
 | ||||
|     public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage( | ||||
|         string uri, | ||||
|  |  | |||
|  | @ -23,23 +23,19 @@ namespace Foxnouns.Backend.Extensions; | |||
| 
 | ||||
| public static class KeyCacheExtensions | ||||
| { | ||||
|     public static async Task<string> GenerateAuthStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheService) | ||||
|     { | ||||
|         string state = AuthUtils.RandomToken(); | ||||
|         await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); | ||||
|         await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); | ||||
|         return state; | ||||
|     } | ||||
| 
 | ||||
|     public static async Task ValidateAuthStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         string state, | ||||
|         CancellationToken ct = default | ||||
|         string state | ||||
|     ) | ||||
|     { | ||||
|         string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct); | ||||
|         string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}"); | ||||
|         if (val == null) | ||||
|             throw new ApiError.BadRequest("Invalid OAuth state"); | ||||
|     } | ||||
|  | @ -47,63 +43,55 @@ public static class KeyCacheExtensions | |||
|     public static async Task<string> GenerateRegisterEmailStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         string email, | ||||
|         Snowflake? userId = null, | ||||
|         CancellationToken ct = default | ||||
|         Snowflake? userId = null | ||||
|     ) | ||||
|     { | ||||
|         string state = AuthUtils.RandomToken(); | ||||
|         await keyCacheService.SetKeyAsync( | ||||
|             $"email_state:{state}", | ||||
|             new RegisterEmailState(email, userId), | ||||
|             Duration.FromDays(1), | ||||
|             ct | ||||
|             Duration.FromDays(1) | ||||
|         ); | ||||
|         return state; | ||||
|     } | ||||
| 
 | ||||
|     public static async Task<RegisterEmailState?> GetRegisterEmailStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         string state, | ||||
|         CancellationToken ct = default | ||||
|     ) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", ct: ct); | ||||
|         string state | ||||
|     ) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}"); | ||||
| 
 | ||||
|     public static async Task<string> GenerateAddExtraAccountStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         AuthType authType, | ||||
|         Snowflake userId, | ||||
|         string? instance = null, | ||||
|         CancellationToken ct = default | ||||
|         string? instance = null | ||||
|     ) | ||||
|     { | ||||
|         string state = AuthUtils.RandomToken(); | ||||
|         await keyCacheService.SetKeyAsync( | ||||
|             $"add_account:{state}", | ||||
|             new AddExtraAccountState(authType, userId, instance), | ||||
|             Duration.FromDays(1), | ||||
|             ct | ||||
|             Duration.FromDays(1) | ||||
|         ); | ||||
|         return state; | ||||
|     } | ||||
| 
 | ||||
|     public static async Task<AddExtraAccountState?> GetAddExtraAccountStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         string state, | ||||
|         CancellationToken ct = default | ||||
|     ) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct); | ||||
|         string state | ||||
|     ) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true); | ||||
| 
 | ||||
|     public static async Task<string> GenerateForgotPasswordStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         string email, | ||||
|         Snowflake userId, | ||||
|         CancellationToken ct = default | ||||
|         Snowflake userId | ||||
|     ) | ||||
|     { | ||||
|         string state = AuthUtils.RandomToken(); | ||||
|         await keyCacheService.SetKeyAsync( | ||||
|             $"forgot_password:{state}", | ||||
|             new ForgotPasswordState(email, userId), | ||||
|             Duration.FromHours(1), | ||||
|             ct | ||||
|             Duration.FromHours(1) | ||||
|         ); | ||||
|         return state; | ||||
|     } | ||||
|  | @ -111,14 +99,8 @@ public static class KeyCacheExtensions | |||
|     public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         string state, | ||||
|         bool delete = true, | ||||
|         CancellationToken ct = default | ||||
|     ) => | ||||
|         await keyCacheService.GetKeyAsync<ForgotPasswordState>( | ||||
|             $"forgot_password:{state}", | ||||
|             delete, | ||||
|             ct | ||||
|         ); | ||||
|         bool delete = true | ||||
|     ) => await keyCacheService.GetKeyAsync<ForgotPasswordState>($"forgot_password:{state}", delete); | ||||
| } | ||||
| 
 | ||||
| public record RegisterEmailState( | ||||
|  |  | |||
|  | @ -15,14 +15,18 @@ | |||
| using Coravel; | ||||
| using Coravel.Queuing.Interfaces; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Jobs; | ||||
| using Foxnouns.Backend.Middleware; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Foxnouns.Backend.Services.Auth; | ||||
| using Foxnouns.Backend.Services.Caching; | ||||
| using Foxnouns.Backend.Services.V1; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Http.Resilience; | ||||
| using Minio; | ||||
| using NodaTime; | ||||
| using Polly; | ||||
| using Prometheus; | ||||
| using Serilog; | ||||
| using Serilog.Events; | ||||
|  | @ -51,9 +55,12 @@ public static class WebApplicationExtensions | |||
|                 "Microsoft.EntityFrameworkCore.Database.Command", | ||||
|                 config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal | ||||
|             ) | ||||
|             // These spam the output even on INF level | ||||
|             .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) | ||||
|             .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) | ||||
|             .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) | ||||
|             // Hangfire's debug-level logs are extremely spammy for no reason | ||||
|             .MinimumLevel.Override("Hangfire", LogEventLevel.Information) | ||||
|             .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen); | ||||
| 
 | ||||
|         if (config.Logging.SeqLogUrl != null) | ||||
|  | @ -97,6 +104,40 @@ public static class WebApplicationExtensions | |||
|         builder.Host.ConfigureServices( | ||||
|             (ctx, services) => | ||||
|             { | ||||
|                 // create a single HTTP client for all requests. | ||||
|                 // it's also configured with a retry mechanism, so that requests aren't immediately lost to the void if they fail | ||||
|                 services.AddSingleton<HttpClient>(_ => | ||||
|                 { | ||||
|                     // ReSharper disable once SuggestVarOrType_Elsewhere | ||||
|                     var retryPipeline = new ResiliencePipelineBuilder<HttpResponseMessage>() | ||||
|                         .AddRetry( | ||||
|                             new HttpRetryStrategyOptions | ||||
|                             { | ||||
|                                 BackoffType = DelayBackoffType.Linear, | ||||
|                                 MaxRetryAttempts = 3, | ||||
|                             } | ||||
|                         ) | ||||
|                         .Build(); | ||||
| 
 | ||||
|                     var resilienceHandler = new ResilienceHandler(retryPipeline) | ||||
|                     { | ||||
|                         InnerHandler = new SocketsHttpHandler | ||||
|                         { | ||||
|                             PooledConnectionLifetime = TimeSpan.FromMinutes(15), | ||||
|                         }, | ||||
|                     }; | ||||
| 
 | ||||
|                     var client = new HttpClient(resilienceHandler); | ||||
|                     client.DefaultRequestHeaders.Remove("User-Agent"); | ||||
|                     client.DefaultRequestHeaders.Remove("Accept"); | ||||
|                     client.DefaultRequestHeaders.Add( | ||||
|                         "User-Agent", | ||||
|                         $"pronouns.cc/{BuildInfo.Version}" | ||||
|                     ); | ||||
|                     client.DefaultRequestHeaders.Add("Accept", "application/json"); | ||||
|                     return client; | ||||
|                 }); | ||||
| 
 | ||||
|                 services | ||||
|                     .AddQueue() | ||||
|                     .AddSmtpMailer(ctx.Configuration) | ||||
|  | @ -112,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>>()); | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,41 +8,46 @@ | |||
|     </PropertyGroup> | ||||
| 
 | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="Coravel" Version="6.0.0"/> | ||||
|         <PackageReference Include="Coravel.Mailer" Version="7.0.0"/> | ||||
|         <PackageReference Include="Coravel" Version="6.0.2"/> | ||||
|         <PackageReference Include="Coravel.Mailer" Version="7.1.0"/> | ||||
|         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/> | ||||
|         <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3"/> | ||||
|         <PackageReference Include="Hangfire" Version="1.8.18"/> | ||||
|         <PackageReference Include="Hangfire.Core" Version="1.8.18"/> | ||||
|         <PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.4"/> | ||||
|         <PackageReference Include="Humanizer.Core" Version="2.14.1"/> | ||||
|         <PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/> | ||||
|         <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0"/> | ||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/> | ||||
|         <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0"/> | ||||
|         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"> | ||||
|         <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.2"/> | ||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2"/> | ||||
|         <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.2"/> | ||||
|         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2"> | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/> | ||||
|         <PackageReference Include="MimeKit" Version="4.9.0"/> | ||||
|         <PackageReference Include="Minio" Version="6.0.3"/> | ||||
|         <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2"/> | ||||
|         <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.2.0"/> | ||||
|         <PackageReference Include="MimeKit" Version="4.10.0"/> | ||||
|         <PackageReference Include="Minio" Version="6.0.4"/> | ||||
|         <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> | ||||
|         <PackageReference Include="NodaTime" Version="3.2.0"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.2"/> | ||||
|         <PackageReference Include="Npgsql.Json.NET" Version="9.0.2"/> | ||||
|         <PackageReference Include="NodaTime" Version="3.2.1"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/> | ||||
|         <PackageReference Include="Npgsql.Json.NET" Version="9.0.3"/> | ||||
|         <PackageReference Include="prometheus-net" Version="8.2.1"/> | ||||
|         <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> | ||||
|         <PackageReference Include="Roslynator.Analyzers" Version="4.12.9"> | ||||
|         <PackageReference Include="Roslynator.Analyzers" Version="4.13.1"> | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="Scalar.AspNetCore" Version="1.2.55"/> | ||||
|         <PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/> | ||||
|         <PackageReference Include="Scalar.AspNetCore" Version="2.0.26"/> | ||||
|         <PackageReference Include="Sentry.AspNetCore" Version="5.3.0"/> | ||||
|         <PackageReference Include="Serilog" Version="4.2.0"/> | ||||
|         <PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/> | ||||
|         <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/> | ||||
|         <PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/> | ||||
|         <PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/> | ||||
|         <PackageReference Include="System.Text.Json" Version="9.0.0"/> | ||||
|         <PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/> | ||||
|         <PackageReference Include="SixLabors.ImageSharp" Version="3.1.7"/> | ||||
|         <PackageReference Include="StackExchange.Redis" Version="2.8.31"/> | ||||
|         <PackageReference Include="System.Text.Json" Version="9.0.2"/> | ||||
|         <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/> | ||||
|         <PackageReference Include="Yort.Xid.Net" Version="2.0.1"/> | ||||
|     </ItemGroup> | ||||
|  |  | |||
|  | @ -14,11 +14,11 @@ | |||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using System.IO.Compression; | ||||
| using System.Net; | ||||
| using Coravel.Invocable; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Foxnouns.Backend.Utils; | ||||
| using Hangfire; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Newtonsoft.Json; | ||||
| using NodaTime; | ||||
|  | @ -26,7 +26,8 @@ using NodaTime.Text; | |||
| 
 | ||||
| namespace Foxnouns.Backend.Jobs; | ||||
| 
 | ||||
| public class CreateDataExportInvocable( | ||||
| public class CreateDataExportJob( | ||||
|     HttpClient client, | ||||
|     DatabaseContext db, | ||||
|     IClock clock, | ||||
|     UserRendererService userRenderer, | ||||
|  | @ -34,37 +35,40 @@ public class CreateDataExportInvocable( | |||
|     ObjectStorageService objectStorageService, | ||||
|     ISnowflakeGenerator snowflakeGenerator, | ||||
|     ILogger logger | ||||
| ) : IInvocable, IInvocableWithPayload<CreateDataExportPayload> | ||||
| ) | ||||
| { | ||||
|     private static readonly HttpClient Client = new(); | ||||
|     private readonly ILogger _logger = logger.ForContext<CreateDataExportInvocable>(); | ||||
|     public required CreateDataExportPayload Payload { get; set; } | ||||
|     private readonly ILogger _logger = logger.ForContext<CreateDataExportJob>(); | ||||
| 
 | ||||
|     public async Task Invoke() | ||||
|     public static void Enqueue(Snowflake userId) | ||||
|     { | ||||
|         BackgroundJob.Enqueue<CreateDataExportJob>(j => j.InvokeAsync(userId)); | ||||
|     } | ||||
| 
 | ||||
|     public async Task InvokeAsync(Snowflake userId) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             await InvokeAsync(); | ||||
|             await InvokeAsyncInner(userId); | ||||
|         } | ||||
|         catch (Exception e) | ||||
|         { | ||||
|             _logger.Error(e, "Error generating data export for user {UserId}", Payload.UserId); | ||||
|             _logger.Error(e, "Error generating data export for user {UserId}", userId); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async Task InvokeAsync() | ||||
|     private async Task InvokeAsyncInner(Snowflake userId) | ||||
|     { | ||||
|         User? user = await db | ||||
|             .Users.Include(u => u.AuthMethods) | ||||
|             .Include(u => u.Flags) | ||||
|             .Include(u => u.ProfileFlags) | ||||
|             .AsSplitQuery() | ||||
|             .FirstOrDefaultAsync(u => u.Id == Payload.UserId); | ||||
|             .FirstOrDefaultAsync(u => u.Id == userId); | ||||
|         if (user == null) | ||||
|         { | ||||
|             _logger.Warning( | ||||
|                 "Received create data export request for user {UserId} but no such user exists, deleted or otherwise. Ignoring request", | ||||
|                 Payload.UserId | ||||
|                 userId | ||||
|             ); | ||||
|             return; | ||||
|         } | ||||
|  | @ -197,7 +201,7 @@ public class CreateDataExportInvocable( | |||
|         if (s3Path == null) | ||||
|             return; | ||||
| 
 | ||||
|         HttpResponseMessage resp = await Client.GetAsync(s3Path); | ||||
|         HttpResponseMessage resp = await client.GetAsync(s3Path); | ||||
|         if (resp.StatusCode != HttpStatusCode.OK) | ||||
|         { | ||||
|             _logger.Warning("S3 path {S3Path} returned a non-200 status, not saving file", s3Path); | ||||
|  | @ -12,49 +12,53 @@ | |||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Coravel.Invocable; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Extensions; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Hangfire; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Jobs; | ||||
| 
 | ||||
| public class CreateFlagInvocable( | ||||
| public class CreateFlagJob( | ||||
|     DatabaseContext db, | ||||
|     ObjectStorageService objectStorageService, | ||||
|     ILogger logger | ||||
| ) : IInvocable, IInvocableWithPayload<CreateFlagPayload> | ||||
| ) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<CreateFlagInvocable>(); | ||||
|     public required CreateFlagPayload Payload { get; set; } | ||||
|     private readonly ILogger _logger = logger.ForContext<CreateFlagJob>(); | ||||
| 
 | ||||
|     public async Task Invoke() | ||||
|     public static void Enqueue(CreateFlagPayload payload) | ||||
|     { | ||||
|         BackgroundJob.Enqueue<CreateFlagJob>(j => j.InvokeAsync(payload)); | ||||
|     } | ||||
| 
 | ||||
|     public async Task InvokeAsync(CreateFlagPayload payload) | ||||
|     { | ||||
|         _logger.Information( | ||||
|             "Creating flag {FlagId} for user {UserId} with image data length {DataLength}", | ||||
|             Payload.Id, | ||||
|             Payload.UserId, | ||||
|             Payload.ImageData.Length | ||||
|             payload.Id, | ||||
|             payload.UserId, | ||||
|             payload.ImageData.Length | ||||
|         ); | ||||
| 
 | ||||
|         try | ||||
|         { | ||||
|             PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => | ||||
|                 f.Id == Payload.Id && f.UserId == Payload.UserId | ||||
|                 f.Id == payload.Id && f.UserId == payload.UserId | ||||
|             ); | ||||
|             if (flag == null) | ||||
|             { | ||||
|                 _logger.Warning( | ||||
|                     "Got a flag create job for {FlagId} but it doesn't exist, aborting", | ||||
|                     Payload.Id | ||||
|                     payload.Id | ||||
|                 ); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             (string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage( | ||||
|                 Payload.ImageData, | ||||
|                 payload.ImageData, | ||||
|                 256, | ||||
|                 false | ||||
|             ); | ||||
|  | @ -68,7 +72,7 @@ public class CreateFlagInvocable( | |||
|         } | ||||
|         catch (ArgumentException ae) | ||||
|         { | ||||
|             _logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", Payload.Id, ae.Message); | ||||
|             _logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", payload.Id, ae.Message); | ||||
|         } | ||||
| 
 | ||||
|         throw new NotImplementedException(); | ||||
|  |  | |||
|  | @ -12,29 +12,33 @@ | |||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Coravel.Invocable; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Extensions; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Hangfire; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Jobs; | ||||
| 
 | ||||
| public class MemberAvatarUpdateInvocable( | ||||
| public class MemberAvatarUpdateJob( | ||||
|     DatabaseContext db, | ||||
|     ObjectStorageService objectStorageService, | ||||
|     ILogger logger | ||||
| ) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload> | ||||
| ) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); | ||||
|     public required AvatarUpdatePayload Payload { get; set; } | ||||
|     private readonly ILogger _logger = logger.ForContext<MemberAvatarUpdateJob>(); | ||||
| 
 | ||||
|     public async Task Invoke() | ||||
|     public static void Enqueue(AvatarUpdatePayload payload) | ||||
|     { | ||||
|         if (Payload.NewAvatar != null) | ||||
|             await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar); | ||||
|         BackgroundJob.Enqueue<MemberAvatarUpdateJob>(j => j.InvokeAsync(payload)); | ||||
|     } | ||||
| 
 | ||||
|     public async Task InvokeAsync(AvatarUpdatePayload payload) | ||||
|     { | ||||
|         if (payload.NewAvatar != null) | ||||
|             await UpdateMemberAvatarAsync(payload.Id, payload.NewAvatar); | ||||
|         else | ||||
|             await ClearMemberAvatarAsync(Payload.Id); | ||||
|             await ClearMemberAvatarAsync(payload.Id); | ||||
|     } | ||||
| 
 | ||||
|     private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar) | ||||
|  | @ -63,6 +67,7 @@ public class MemberAvatarUpdateInvocable( | |||
|             await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp"); | ||||
| 
 | ||||
|             member.Avatar = hash; | ||||
|             member.AvatarMigrated = true; | ||||
|             await db.SaveChangesAsync(); | ||||
| 
 | ||||
|             if (prevHash != null && prevHash != hash) | ||||
|  | @ -19,5 +19,3 @@ namespace Foxnouns.Backend.Jobs; | |||
| public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); | ||||
| 
 | ||||
| public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string ImageData); | ||||
| 
 | ||||
| public record CreateDataExportPayload(Snowflake UserId); | ||||
|  |  | |||
|  | @ -12,29 +12,33 @@ | |||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Coravel.Invocable; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Extensions; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Hangfire; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Jobs; | ||||
| 
 | ||||
| public class UserAvatarUpdateInvocable( | ||||
| public class UserAvatarUpdateJob( | ||||
|     DatabaseContext db, | ||||
|     ObjectStorageService objectStorageService, | ||||
|     ILogger logger | ||||
| ) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload> | ||||
| ) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); | ||||
|     public required AvatarUpdatePayload Payload { get; set; } | ||||
|     private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateJob>(); | ||||
| 
 | ||||
|     public async Task Invoke() | ||||
|     public static void Enqueue(AvatarUpdatePayload payload) | ||||
|     { | ||||
|         if (Payload.NewAvatar != null) | ||||
|             await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar); | ||||
|         BackgroundJob.Enqueue<UserAvatarUpdateJob>(j => j.InvokeAsync(payload)); | ||||
|     } | ||||
| 
 | ||||
|     public async Task InvokeAsync(AvatarUpdatePayload payload) | ||||
|     { | ||||
|         if (payload.NewAvatar != null) | ||||
|             await UpdateUserAvatarAsync(payload.Id, payload.NewAvatar); | ||||
|         else | ||||
|             await ClearUserAvatarAsync(Payload.Id); | ||||
|             await ClearUserAvatarAsync(payload.Id); | ||||
|     } | ||||
| 
 | ||||
|     private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar) | ||||
|  | @ -64,6 +68,7 @@ public class UserAvatarUpdateInvocable( | |||
|             await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp"); | ||||
| 
 | ||||
|             user.Avatar = hash; | ||||
|             user.AvatarMigrated = true; | ||||
|             await db.SaveChangesAsync(); | ||||
| 
 | ||||
|             if (prevHash != null && prevHash != hash) | ||||
|  | @ -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 => | ||||
|  |  | |||
|  | @ -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." | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,20 +25,20 @@ namespace Foxnouns.Backend.Services.Auth; | |||
| public partial class FediverseAuthService | ||||
| { | ||||
|     private string MastodonRedirectUri(string instance) => | ||||
|         $"{_config.BaseUrl}/auth/callback/mastodon/{instance}"; | ||||
|         $"{config.BaseUrl}/auth/callback/mastodon/{instance}"; | ||||
| 
 | ||||
|     private async Task<FediverseApplication> CreateMastodonApplicationAsync( | ||||
|         string instance, | ||||
|         Snowflake? existingAppId = null | ||||
|     ) | ||||
|     { | ||||
|         HttpResponseMessage resp = await _client.PostAsJsonAsync( | ||||
|         HttpResponseMessage resp = await client.PostAsJsonAsync( | ||||
|             $"https://{instance}/api/v1/apps", | ||||
|             new CreateMastodonApplicationRequest( | ||||
|                 $"pronouns.cc (+{_config.BaseUrl})", | ||||
|                 $"pronouns.cc (+{config.BaseUrl})", | ||||
|                 MastodonRedirectUri(instance), | ||||
|                 "read read:accounts", | ||||
|                 _config.BaseUrl | ||||
|                 config.BaseUrl | ||||
|             ) | ||||
|         ); | ||||
|         resp.EnsureSuccessStatusCode(); | ||||
|  | @ -58,19 +58,19 @@ public partial class FediverseAuthService | |||
|         { | ||||
|             app = new FediverseApplication | ||||
|             { | ||||
|                 Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(), | ||||
|                 Id = existingAppId ?? snowflakeGenerator.GenerateSnowflake(), | ||||
|                 ClientId = mastodonApp.ClientId, | ||||
|                 ClientSecret = mastodonApp.ClientSecret, | ||||
|                 Domain = instance, | ||||
|                 InstanceType = FediverseInstanceType.MastodonApi, | ||||
|             }; | ||||
| 
 | ||||
|             _db.Add(app); | ||||
|             db.Add(app); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             app = | ||||
|                 await _db.FediverseApplications.FindAsync(existingAppId) | ||||
|                 await db.FediverseApplications.FindAsync(existingAppId) | ||||
|                 ?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null"); | ||||
| 
 | ||||
|             app.ClientId = mastodonApp.ClientId; | ||||
|  | @ -78,7 +78,7 @@ public partial class FediverseAuthService | |||
|             app.InstanceType = FediverseInstanceType.MastodonApi; | ||||
|         } | ||||
| 
 | ||||
|         await _db.SaveChangesAsync(); | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         return app; | ||||
|     } | ||||
|  | @ -90,9 +90,9 @@ public partial class FediverseAuthService | |||
|     ) | ||||
|     { | ||||
|         if (state != null) | ||||
|             await _keyCacheService.ValidateAuthStateAsync(state); | ||||
|             await keyCacheService.ValidateAuthStateAsync(state); | ||||
| 
 | ||||
|         HttpResponseMessage tokenResp = await _client.PostAsync( | ||||
|         HttpResponseMessage tokenResp = await client.PostAsync( | ||||
|             MastodonTokenUri(app.Domain), | ||||
|             new FormUrlEncodedContent( | ||||
|                 new Dictionary<string, string> | ||||
|  | @ -123,7 +123,7 @@ public partial class FediverseAuthService | |||
|         var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentUserUri(app.Domain)); | ||||
|         req.Headers.Add("Authorization", $"Bearer {token}"); | ||||
| 
 | ||||
|         HttpResponseMessage currentUserResp = await _client.SendAsync(req); | ||||
|         HttpResponseMessage currentUserResp = await client.SendAsync(req); | ||||
|         currentUserResp.EnsureSuccessStatusCode(); | ||||
|         FediverseUser? user = await currentUserResp.Content.ReadFromJsonAsync<FediverseUser>(); | ||||
|         if (user == null) | ||||
|  | @ -151,7 +151,7 @@ public partial class FediverseAuthService | |||
|             app = await CreateMastodonApplicationAsync(app.Domain, app.Id); | ||||
|         } | ||||
| 
 | ||||
|         state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); | ||||
|         state ??= HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync()); | ||||
| 
 | ||||
|         return $"https://{app.Domain}/oauth/authorize?response_type=code" | ||||
|             + $"&client_id={app.ClientId}" | ||||
|  |  | |||
|  | @ -34,11 +34,11 @@ public partial class FediverseAuthService | |||
|         Snowflake? existingAppId = null | ||||
|     ) | ||||
|     { | ||||
|         HttpResponseMessage resp = await _client.PostAsJsonAsync( | ||||
|         HttpResponseMessage resp = await client.PostAsJsonAsync( | ||||
|             MisskeyAppUri(instance), | ||||
|             new CreateMisskeyApplicationRequest( | ||||
|                 $"pronouns.cc (+{_config.BaseUrl})", | ||||
|                 $"pronouns.cc on {_config.BaseUrl}", | ||||
|                 $"pronouns.cc (+{config.BaseUrl})", | ||||
|                 $"pronouns.cc on {config.BaseUrl}", | ||||
|                 ["read:account"], | ||||
|                 MastodonRedirectUri(instance) | ||||
|             ) | ||||
|  | @ -60,19 +60,19 @@ public partial class FediverseAuthService | |||
|         { | ||||
|             app = new FediverseApplication | ||||
|             { | ||||
|                 Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(), | ||||
|                 Id = existingAppId ?? snowflakeGenerator.GenerateSnowflake(), | ||||
|                 ClientId = misskeyApp.Id, | ||||
|                 ClientSecret = misskeyApp.Secret, | ||||
|                 Domain = instance, | ||||
|                 InstanceType = FediverseInstanceType.MisskeyApi, | ||||
|             }; | ||||
| 
 | ||||
|             _db.Add(app); | ||||
|             db.Add(app); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             app = | ||||
|                 await _db.FediverseApplications.FindAsync(existingAppId) | ||||
|                 await db.FediverseApplications.FindAsync(existingAppId) | ||||
|                 ?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null"); | ||||
| 
 | ||||
|             app.ClientId = misskeyApp.Id; | ||||
|  | @ -80,7 +80,7 @@ public partial class FediverseAuthService | |||
|             app.InstanceType = FediverseInstanceType.MisskeyApi; | ||||
|         } | ||||
| 
 | ||||
|         await _db.SaveChangesAsync(); | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         return app; | ||||
|     } | ||||
|  | @ -96,7 +96,7 @@ public partial class FediverseAuthService | |||
| 
 | ||||
|     private async Task<FediverseUser> GetMisskeyUserAsync(FediverseApplication app, string code) | ||||
|     { | ||||
|         HttpResponseMessage resp = await _client.PostAsJsonAsync( | ||||
|         HttpResponseMessage resp = await client.PostAsJsonAsync( | ||||
|             MisskeyTokenUri(app.Domain), | ||||
|             new GetMisskeySessionUserKeyRequest(app.ClientSecret, code) | ||||
|         ); | ||||
|  | @ -130,7 +130,7 @@ public partial class FediverseAuthService | |||
|             app = await CreateMisskeyApplicationAsync(app.Domain, app.Id); | ||||
|         } | ||||
| 
 | ||||
|         HttpResponseMessage resp = await _client.PostAsJsonAsync( | ||||
|         HttpResponseMessage resp = await client.PostAsJsonAsync( | ||||
|             MisskeyGenerateSessionUri(app.Domain), | ||||
|             new CreateMisskeySessionUriRequest(app.ClientSecret) | ||||
|         ); | ||||
|  |  | |||
|  | @ -19,37 +19,17 @@ using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; | |||
| 
 | ||||
| namespace Foxnouns.Backend.Services.Auth; | ||||
| 
 | ||||
| public partial class FediverseAuthService | ||||
| { | ||||
|     private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0"; | ||||
| 
 | ||||
|     private readonly HttpClient _client; | ||||
|     private readonly ILogger _logger; | ||||
|     private readonly Config _config; | ||||
|     private readonly DatabaseContext _db; | ||||
|     private readonly KeyCacheService _keyCacheService; | ||||
|     private readonly ISnowflakeGenerator _snowflakeGenerator; | ||||
| 
 | ||||
|     public FediverseAuthService( | ||||
| public partial class FediverseAuthService( | ||||
|     ILogger logger, | ||||
|     Config config, | ||||
|     DatabaseContext db, | ||||
|     HttpClient client, | ||||
|     KeyCacheService keyCacheService, | ||||
|     ISnowflakeGenerator snowflakeGenerator | ||||
|     ) | ||||
|     { | ||||
|         _logger = logger.ForContext<FediverseAuthService>(); | ||||
|         _config = config; | ||||
|         _db = db; | ||||
|         _keyCacheService = keyCacheService; | ||||
|         _snowflakeGenerator = snowflakeGenerator; | ||||
| 
 | ||||
|         _client = new HttpClient(); | ||||
|         _client.DefaultRequestHeaders.Remove("User-Agent"); | ||||
|         _client.DefaultRequestHeaders.Remove("Accept"); | ||||
|         _client.DefaultRequestHeaders.Add("User-Agent", $"pronouns.cc/{BuildInfo.Version}"); | ||||
|         _client.DefaultRequestHeaders.Add("Accept", "application/json"); | ||||
|     } | ||||
| ) | ||||
| { | ||||
|     private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0"; | ||||
|     private readonly ILogger _logger = logger.ForContext<FediverseAuthService>(); | ||||
| 
 | ||||
|     public async Task<string> GenerateAuthUrlAsync( | ||||
|         string instance, | ||||
|  | @ -70,7 +50,7 @@ public partial class FediverseAuthService | |||
| 
 | ||||
|     public async Task<FediverseApplication> GetApplicationAsync(string instance) | ||||
|     { | ||||
|         FediverseApplication? app = await _db.FediverseApplications.FirstOrDefaultAsync(a => | ||||
|         FediverseApplication? app = await db.FediverseApplications.FirstOrDefaultAsync(a => | ||||
|             a.Domain == instance | ||||
|         ); | ||||
|         if (app != null) | ||||
|  | @ -92,7 +72,7 @@ public partial class FediverseAuthService | |||
|     { | ||||
|         _logger.Debug("Requesting software name for fediverse instance {Instance}", instance); | ||||
| 
 | ||||
|         HttpResponseMessage wellKnownResp = await _client.GetAsync( | ||||
|         HttpResponseMessage wellKnownResp = await client.GetAsync( | ||||
|             new Uri($"https://{instance}/.well-known/nodeinfo") | ||||
|         ); | ||||
|         wellKnownResp.EnsureSuccessStatusCode(); | ||||
|  | @ -107,7 +87,7 @@ public partial class FediverseAuthService | |||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         HttpResponseMessage nodeInfoResp = await _client.GetAsync(nodeInfoUrl); | ||||
|         HttpResponseMessage nodeInfoResp = await client.GetAsync(nodeInfoUrl); | ||||
|         nodeInfoResp.EnsureSuccessStatusCode(); | ||||
| 
 | ||||
|         PartialNodeInfo? nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync<PartialNodeInfo>(); | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ public partial class RemoteAuthService | |||
|     ) | ||||
|     { | ||||
|         var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; | ||||
|         HttpResponseMessage resp = await _httpClient.PostAsync( | ||||
|         HttpResponseMessage resp = await client.PostAsync( | ||||
|             _discordTokenUri, | ||||
|             new FormUrlEncodedContent( | ||||
|                 new Dictionary<string, string> | ||||
|  | @ -59,7 +59,7 @@ public partial class RemoteAuthService | |||
|         var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); | ||||
|         req.Headers.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); | ||||
| 
 | ||||
|         HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); | ||||
|         HttpResponseMessage resp2 = await client.SendAsync(req, ct); | ||||
|         resp2.EnsureSuccessStatusCode(); | ||||
|         DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct); | ||||
|         if (user == null) | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ public partial class RemoteAuthService | |||
|     ) | ||||
|     { | ||||
|         var redirectUri = $"{config.BaseUrl}/auth/callback/google"; | ||||
|         HttpResponseMessage resp = await _httpClient.PostAsync( | ||||
|         HttpResponseMessage resp = await client.PostAsync( | ||||
|             _googleTokenUri, | ||||
|             new FormUrlEncodedContent( | ||||
|                 new Dictionary<string, string> | ||||
|  |  | |||
|  | @ -29,7 +29,7 @@ public partial class RemoteAuthService | |||
|     ) | ||||
|     { | ||||
|         var redirectUri = $"{config.BaseUrl}/auth/callback/tumblr"; | ||||
|         HttpResponseMessage resp = await _httpClient.PostAsync( | ||||
|         HttpResponseMessage resp = await client.PostAsync( | ||||
|             _tumblrTokenUri, | ||||
|             new FormUrlEncodedContent( | ||||
|                 new Dictionary<string, string> | ||||
|  | @ -62,7 +62,7 @@ public partial class RemoteAuthService | |||
|         var req = new HttpRequestMessage(HttpMethod.Get, _tumblrUserUri); | ||||
|         req.Headers.Add("Authorization", $"Bearer {token.AccessToken}"); | ||||
| 
 | ||||
|         HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); | ||||
|         HttpResponseMessage resp2 = await client.SendAsync(req, ct); | ||||
|         if (!resp2.IsSuccessStatusCode) | ||||
|         { | ||||
|             string respBody = await resp2.Content.ReadAsStringAsync(ct); | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ using Microsoft.EntityFrameworkCore; | |||
| namespace Foxnouns.Backend.Services.Auth; | ||||
| 
 | ||||
| public partial class RemoteAuthService( | ||||
|     HttpClient client, | ||||
|     Config config, | ||||
|     ILogger logger, | ||||
|     DatabaseContext db, | ||||
|  | @ -32,7 +33,6 @@ public partial class RemoteAuthService( | |||
| ) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<RemoteAuthService>(); | ||||
|     private readonly HttpClient _httpClient = new(); | ||||
| 
 | ||||
|     public record RemoteUser(string Id, string Username); | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										39
									
								
								Foxnouns.Backend/Services/Caching/NoticeCacheService.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								Foxnouns.Backend/Services/Caching/NoticeCacheService.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,39 @@ | |||
| // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published | ||||
| // by the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Services.Caching; | ||||
| 
 | ||||
| public class NoticeCacheService(IServiceProvider serviceProvider, IClock clock, ILogger logger) | ||||
|     : SingletonCacheService<Notice>(serviceProvider, clock, logger) | ||||
| { | ||||
|     public override Duration MaxAge { get; init; } = Duration.FromMinutes(5); | ||||
| 
 | ||||
|     public override Func< | ||||
|         DatabaseContext, | ||||
|         CancellationToken, | ||||
|         Task<Notice?> | ||||
|     > FetchFunc { get; init; } = | ||||
|         async (db, ct) => | ||||
|             await db | ||||
|                 .Notices.Where(n => | ||||
|                     n.StartTime < clock.GetCurrentInstant() && n.EndTime > clock.GetCurrentInstant() | ||||
|                 ) | ||||
|                 .OrderByDescending(n => n.Id) | ||||
|                 .FirstOrDefaultAsync(ct); | ||||
| } | ||||
							
								
								
									
										63
									
								
								Foxnouns.Backend/Services/Caching/SingletonCacheService.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								Foxnouns.Backend/Services/Caching/SingletonCacheService.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,63 @@ | |||
| // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published | ||||
| // by the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| using Foxnouns.Backend.Database; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Services.Caching; | ||||
| 
 | ||||
| public abstract class SingletonCacheService<T>( | ||||
|     IServiceProvider serviceProvider, | ||||
|     IClock clock, | ||||
|     ILogger logger | ||||
| ) | ||||
|     where T : class | ||||
| { | ||||
|     private T? _item; | ||||
|     private Instant _lastUpdated = Instant.MinValue; | ||||
|     private readonly SemaphoreSlim _semaphore = new(1, 1); | ||||
|     private readonly ILogger _logger = logger.ForContext<SingletonCacheService<T>>(); | ||||
| 
 | ||||
|     public virtual Duration MaxAge { get; init; } = Duration.FromMinutes(5); | ||||
| 
 | ||||
|     public virtual Func<DatabaseContext, CancellationToken, Task<T?>> FetchFunc { get; init; } = | ||||
|         (_, __) => Task.FromResult<T?>(null); | ||||
| 
 | ||||
|     public async Task<T?> GetAsync(CancellationToken ct = default) | ||||
|     { | ||||
|         await _semaphore.WaitAsync(ct); | ||||
|         try | ||||
|         { | ||||
|             if (_lastUpdated > clock.GetCurrentInstant() - MaxAge) | ||||
|             { | ||||
|                 return _item; | ||||
|             } | ||||
| 
 | ||||
|             _logger.Debug("Cached item of type {Type} is expired, fetching it.", typeof(T)); | ||||
| 
 | ||||
|             await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); | ||||
|             await using DatabaseContext db = | ||||
|                 scope.ServiceProvider.GetRequiredService<DatabaseContext>(); | ||||
| 
 | ||||
|             T? item = await FetchFunc(db, ct); | ||||
|             _item = item; | ||||
|             _lastUpdated = clock.GetCurrentInstant(); | ||||
|             return item; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _semaphore.Release(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -23,8 +23,11 @@ public class EmailRateLimiter | |||
| { | ||||
|     private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new(); | ||||
| 
 | ||||
|     private readonly FixedWindowRateLimiterOptions _limiterOptions = | ||||
|         new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 }; | ||||
|     private readonly FixedWindowRateLimiterOptions _limiterOptions = new() | ||||
|     { | ||||
|         Window = TimeSpan.FromHours(2), | ||||
|         PermitLimit = 3, | ||||
|     }; | ||||
| 
 | ||||
|     private RateLimiter GetLimiter(string bucket) => | ||||
|         _limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions)); | ||||
|  |  | |||
|  | @ -17,94 +17,39 @@ using Foxnouns.Backend.Database.Models; | |||
| using Microsoft.EntityFrameworkCore; | ||||
| using Newtonsoft.Json; | ||||
| using NodaTime; | ||||
| using StackExchange.Redis; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Services; | ||||
| 
 | ||||
| public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) | ||||
| public class KeyCacheService(Config config) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<KeyCacheService>(); | ||||
|     public ConnectionMultiplexer Multiplexer { get; } = | ||||
|         ConnectionMultiplexer.Connect(config.Database.Redis); | ||||
| 
 | ||||
|     public Task SetKeyAsync( | ||||
|         string key, | ||||
|         string value, | ||||
|         Duration expireAfter, | ||||
|         CancellationToken ct = default | ||||
|     ) => SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); | ||||
|     public async Task SetKeyAsync(string key, string value, Duration expireAfter) => | ||||
|         await Multiplexer | ||||
|             .GetDatabase() | ||||
|             .StringSetAsync(key, value, expiry: expireAfter.ToTimeSpan()); | ||||
| 
 | ||||
|     public async Task SetKeyAsync( | ||||
|         string key, | ||||
|         string value, | ||||
|         Instant expires, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         db.TemporaryKeys.Add( | ||||
|             new TemporaryKey | ||||
|             { | ||||
|                 Expires = expires, | ||||
|                 Key = key, | ||||
|                 Value = value, | ||||
|             } | ||||
|         ); | ||||
|         await db.SaveChangesAsync(ct); | ||||
|     } | ||||
|     public async Task<string?> GetKeyAsync(string key, bool delete = false) => | ||||
|         delete | ||||
|             ? await Multiplexer.GetDatabase().StringGetDeleteAsync(key) | ||||
|             : await Multiplexer.GetDatabase().StringGetAsync(key); | ||||
| 
 | ||||
|     public async Task<string?> GetKeyAsync( | ||||
|         string key, | ||||
|         bool delete = false, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         TemporaryKey? value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct); | ||||
|         if (value == null) | ||||
|             return null; | ||||
|     public async Task DeleteKeyAsync(string key) => | ||||
|         await Multiplexer.GetDatabase().KeyDeleteAsync(key); | ||||
| 
 | ||||
|         if (delete) | ||||
|             await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); | ||||
| 
 | ||||
|         return value.Value; | ||||
|     } | ||||
| 
 | ||||
|     public async Task DeleteKeyAsync(string key, CancellationToken ct = default) => | ||||
|         await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); | ||||
| 
 | ||||
|     public async Task DeleteExpiredKeysAsync(CancellationToken ct) | ||||
|     { | ||||
|         int count = await db | ||||
|             .TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()) | ||||
|             .ExecuteDeleteAsync(ct); | ||||
|         if (count != 0) | ||||
|             _logger.Information("Removed {Count} expired keys from the database", count); | ||||
|     } | ||||
| 
 | ||||
|     public Task SetKeyAsync<T>( | ||||
|         string key, | ||||
|         T obj, | ||||
|         Duration expiresAt, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|         where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); | ||||
| 
 | ||||
|     public async Task SetKeyAsync<T>( | ||||
|         string key, | ||||
|         T obj, | ||||
|         Instant expires, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     public async Task SetKeyAsync<T>(string key, T obj, Duration expiresAt) | ||||
|         where T : class | ||||
|     { | ||||
|         string value = JsonConvert.SerializeObject(obj); | ||||
|         await SetKeyAsync(key, value, expires, ct); | ||||
|         await SetKeyAsync(key, value, expiresAt); | ||||
|     } | ||||
| 
 | ||||
|     public async Task<T?> GetKeyAsync<T>( | ||||
|         string key, | ||||
|         bool delete = false, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     public async Task<T?> GetKeyAsync<T>(string key, bool delete = false) | ||||
|         where T : class | ||||
|     { | ||||
|         string? value = await GetKeyAsync(key, delete, ct); | ||||
|         string? value = await GetKeyAsync(key, delete); | ||||
|         return value == null ? default : JsonConvert.DeserializeObject<T>(value); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -33,11 +33,9 @@ public class PeriodicTasksService(ILogger logger, IServiceProvider services) : B | |||
| 
 | ||||
|         // The type is literally written on the same line, we can just use `var` | ||||
|         // ReSharper disable SuggestVarOrType_SimpleTypes | ||||
|         var keyCacheService = scope.ServiceProvider.GetRequiredService<KeyCacheService>(); | ||||
|         var dataCleanupService = scope.ServiceProvider.GetRequiredService<DataCleanupService>(); | ||||
|         // ReSharper restore SuggestVarOrType_SimpleTypes | ||||
| 
 | ||||
|         await keyCacheService.DeleteExpiredKeysAsync(ct); | ||||
|         await dataCleanupService.InvokeAsync(ct); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -33,6 +33,7 @@ public class UserRendererService( | |||
|         bool renderMembers = true, | ||||
|         bool renderAuthMethods = false, | ||||
|         string? overrideSid = null, | ||||
|         bool renderSettings = false, | ||||
|         CancellationToken ct = default | ||||
|     ) => | ||||
|         await RenderUserInnerAsync( | ||||
|  | @ -42,6 +43,7 @@ public class UserRendererService( | |||
|             renderMembers, | ||||
|             renderAuthMethods, | ||||
|             overrideSid, | ||||
|             renderSettings, | ||||
|             ct | ||||
|         ); | ||||
| 
 | ||||
|  | @ -52,6 +54,7 @@ public class UserRendererService( | |||
|         bool renderMembers = true, | ||||
|         bool renderAuthMethods = false, | ||||
|         string? overrideSid = null, | ||||
|         bool renderSettings = false, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|  | @ -62,6 +65,7 @@ public class UserRendererService( | |||
| 
 | ||||
|         renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers); | ||||
|         renderAuthMethods = renderAuthMethods && tokenPrivileged; | ||||
|         renderSettings = renderSettings && tokenHidden; | ||||
| 
 | ||||
|         IEnumerable<Member> members = renderMembers | ||||
|             ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct) | ||||
|  | @ -117,7 +121,8 @@ public class UserRendererService( | |||
|             tokenHidden ? user.LastSidReroll : null, | ||||
|             tokenHidden ? user.Timezone ?? "<none>" : null, | ||||
|             tokenHidden ? user is { Deleted: true, DeletedBy: not null } : null, | ||||
|             tokenHidden ? user.Deleted : null | ||||
|             tokenHidden ? user.Deleted : null, | ||||
|             renderSettings ? user.Settings : null | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -15,9 +15,9 @@ | |||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Utils; | ||||
| namespace Foxnouns.Backend.Services; | ||||
| 
 | ||||
| public static partial class ValidationUtils | ||||
| public partial class ValidationService | ||||
| { | ||||
|     public static readonly string[] DefaultStatusOptions = | ||||
|     [ | ||||
|  | @ -28,7 +28,7 @@ public static partial class ValidationUtils | |||
|         "avoid", | ||||
|     ]; | ||||
| 
 | ||||
|     public static IEnumerable<(string, ValidationError?)> ValidateFields( | ||||
|     public IEnumerable<(string, ValidationError?)> ValidateFields( | ||||
|         List<Field>? fields, | ||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences | ||||
|     ) | ||||
|  | @ -37,7 +37,7 @@ public static partial class ValidationUtils | |||
|             return []; | ||||
| 
 | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
|         if (fields.Count > 25) | ||||
|         if (fields.Count > _limits.MaxFields) | ||||
|         { | ||||
|             errors.Add( | ||||
|                 ( | ||||
|  | @ -45,7 +45,7 @@ public static partial class ValidationUtils | |||
|                     ValidationError.LengthError( | ||||
|                         "Too many fields", | ||||
|                         0, | ||||
|                         Limits.FieldLimit, | ||||
|                         _limits.MaxFields, | ||||
|                         fields.Count | ||||
|                     ) | ||||
|                 ) | ||||
|  | @ -53,39 +53,38 @@ public static partial class ValidationUtils | |||
|         } | ||||
| 
 | ||||
|         // No overwhelming this function, thank you | ||||
|         if (fields.Count > 100) | ||||
|         if (fields.Count > _limits.MaxFields + 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         foreach ((Field? field, int index) in fields.Select((field, index) => (field, index))) | ||||
|         { | ||||
|             switch (field.Name.Length) | ||||
|             if (field.Name.Length > _limits.MaxFieldNameLength) | ||||
|             { | ||||
|                 case > Limits.FieldNameLimit: | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"fields.{index}.name", | ||||
|                         ValidationError.LengthError( | ||||
|                             "Field name is too long", | ||||
|                             1, | ||||
|                                 Limits.FieldNameLimit, | ||||
|                             _limits.MaxFieldNameLength, | ||||
|                             field.Name.Length | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|             } | ||||
|             else if (field.Name.Length < 1) | ||||
|             { | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"fields.{index}.name", | ||||
|                         ValidationError.LengthError( | ||||
|                             "Field name is too short", | ||||
|                             1, | ||||
|                                 Limits.FieldNameLimit, | ||||
|                             _limits.MaxFieldNameLength, | ||||
|                             field.Name.Length | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
|             errors = errors | ||||
|  | @ -102,7 +101,7 @@ public static partial class ValidationUtils | |||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries( | ||||
|     public IEnumerable<(string, ValidationError?)> ValidateFieldEntries( | ||||
|         FieldEntry[]? entries, | ||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, | ||||
|         string errorPrefix = "fields" | ||||
|  | @ -112,7 +111,7 @@ public static partial class ValidationUtils | |||
|             return []; | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
| 
 | ||||
|         if (entries.Length > Limits.FieldEntriesLimit) | ||||
|         if (entries.Length > _limits.MaxFieldEntries) | ||||
|         { | ||||
|             errors.Add( | ||||
|                 ( | ||||
|  | @ -120,7 +119,7 @@ public static partial class ValidationUtils | |||
|                     ValidationError.LengthError( | ||||
|                         "Field has too many entries", | ||||
|                         0, | ||||
|                         Limits.FieldEntriesLimit, | ||||
|                         _limits.MaxFieldEntries, | ||||
|                         entries.Length | ||||
|                     ) | ||||
|                 ) | ||||
|  | @ -128,7 +127,7 @@ public static partial class ValidationUtils | |||
|         } | ||||
| 
 | ||||
|         // Same as above, no overwhelming this function with a ridiculous amount of entries | ||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||
|         if (entries.Length > _limits.MaxFieldEntries + 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray(); | ||||
|  | @ -139,34 +138,33 @@ public static partial class ValidationUtils | |||
|             ) | ||||
|         ) | ||||
|         { | ||||
|             switch (entry.Value.Length) | ||||
|             if (entry.Value.Length > _limits.MaxFieldEntryTextLength) | ||||
|             { | ||||
|                 case > Limits.FieldEntryTextLimit: | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"{errorPrefix}.{entryIdx}.value", | ||||
|                         ValidationError.LengthError( | ||||
|                             "Field value is too long", | ||||
|                             1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                             _limits.MaxFieldEntryTextLength, | ||||
|                             entry.Value.Length | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|             } | ||||
|             else if (entry.Value.Length < 1) | ||||
|             { | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"{errorPrefix}.{entryIdx}.value", | ||||
|                         ValidationError.LengthError( | ||||
|                             "Field value is too short", | ||||
|                             1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                             _limits.MaxFieldEntryTextLength, | ||||
|                             entry.Value.Length | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
|             if ( | ||||
|  | @ -186,7 +184,7 @@ public static partial class ValidationUtils | |||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     public static IEnumerable<(string, ValidationError?)> ValidatePronouns( | ||||
|     public IEnumerable<(string, ValidationError?)> ValidatePronouns( | ||||
|         Pronoun[]? entries, | ||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, | ||||
|         string errorPrefix = "pronouns" | ||||
|  | @ -196,7 +194,7 @@ public static partial class ValidationUtils | |||
|             return []; | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
| 
 | ||||
|         if (entries.Length > Limits.FieldEntriesLimit) | ||||
|         if (entries.Length > _limits.MaxFieldEntries) | ||||
|         { | ||||
|             errors.Add( | ||||
|                 ( | ||||
|  | @ -204,7 +202,7 @@ public static partial class ValidationUtils | |||
|                     ValidationError.LengthError( | ||||
|                         "Too many pronouns", | ||||
|                         0, | ||||
|                         Limits.FieldEntriesLimit, | ||||
|                         _limits.MaxFieldEntries, | ||||
|                         entries.Length | ||||
|                     ) | ||||
|                 ) | ||||
|  | @ -212,7 +210,7 @@ public static partial class ValidationUtils | |||
|         } | ||||
| 
 | ||||
|         // Same as above, no overwhelming this function with a ridiculous amount of entries | ||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||
|         if (entries.Length > _limits.MaxFieldEntries + 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray(); | ||||
|  | @ -221,66 +219,64 @@ public static partial class ValidationUtils | |||
|             (Pronoun? entry, int entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)) | ||||
|         ) | ||||
|         { | ||||
|             switch (entry.Value.Length) | ||||
|             if (entry.Value.Length > _limits.MaxFieldEntryTextLength) | ||||
|             { | ||||
|                 case > Limits.FieldEntryTextLimit: | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"{errorPrefix}.{entryIdx}.value", | ||||
|                         ValidationError.LengthError( | ||||
|                             "Pronoun value is too long", | ||||
|                             1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                             _limits.MaxFieldEntryTextLength, | ||||
|                             entry.Value.Length | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|             } | ||||
|             else if (entry.Value.Length < 1) | ||||
|             { | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"{errorPrefix}.{entryIdx}.value", | ||||
|                         ValidationError.LengthError( | ||||
|                             "Pronoun value is too short", | ||||
|                             1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                             _limits.MaxFieldEntryTextLength, | ||||
|                             entry.Value.Length | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
|             if (entry.DisplayText != null) | ||||
|             { | ||||
|                 switch (entry.DisplayText.Length) | ||||
|                 if (entry.DisplayText.Length > _limits.MaxFieldEntryTextLength) | ||||
|                 { | ||||
|                     case > Limits.FieldEntryTextLimit: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.display_text", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Pronoun display text is too long", | ||||
|                                 1, | ||||
|                                     Limits.FieldEntryTextLimit, | ||||
|                                 _limits.MaxFieldEntryTextLength, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                         break; | ||||
|                     case < 1: | ||||
|                 } | ||||
|                 else if (entry.DisplayText.Length < 1) | ||||
|                 { | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.display_text", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Pronoun display text is too short", | ||||
|                                 1, | ||||
|                                     Limits.FieldEntryTextLimit, | ||||
|                                 _limits.MaxFieldEntryTextLength, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                         break; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|  | @ -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) | ||||
|  |  | |||
|  | @ -1,23 +0,0 @@ | |||
| // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published | ||||
| // by the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| namespace Foxnouns.Backend.Utils; | ||||
| 
 | ||||
| public static class Limits | ||||
| { | ||||
|     public const int FieldLimit = 25; | ||||
|     public const int FieldNameLimit = 100; | ||||
|     public const int FieldEntryTextLimit = 100; | ||||
|     public const int FieldEntriesLimit = 100; | ||||
| } | ||||
|  | @ -22,8 +22,10 @@ namespace Foxnouns.Backend.Utils.OpenApi; | |||
| 
 | ||||
| public class PropertyKeySchemaTransformer : IOpenApiSchemaTransformer | ||||
| { | ||||
|     private static readonly DefaultContractResolver SnakeCaseConverter = | ||||
|         new() { NamingStrategy = new SnakeCaseNamingStrategy() }; | ||||
|     private static readonly DefaultContractResolver SnakeCaseConverter = new() | ||||
|     { | ||||
|         NamingStrategy = new SnakeCaseNamingStrategy(), | ||||
|     }; | ||||
| 
 | ||||
|     public Task TransformAsync( | ||||
|         OpenApiSchema schema, | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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==" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  |  | |||
|  | @ -12,9 +12,9 @@ | |||
|     </ItemGroup> | ||||
| 
 | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="Dapper" Version="2.1.35"/> | ||||
|         <PackageReference Include="Npgsql" Version="9.0.2"/> | ||||
|         <PackageReference Include="Npgsql.NodaTime" Version="9.0.2"/> | ||||
|         <PackageReference Include="Dapper" Version="2.1.66"/> | ||||
|         <PackageReference Include="Npgsql" Version="9.0.3"/> | ||||
|         <PackageReference Include="Npgsql.NodaTime" Version="9.0.3"/> | ||||
|     </ItemGroup> | ||||
| 
 | ||||
| </Project> | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; | |||
| using Dapper; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Utils; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Foxnouns.DataMigrator.Models; | ||||
| using NodaTime.Extensions; | ||||
| using Npgsql; | ||||
|  | @ -260,6 +260,6 @@ public class UserMigrator( | |||
|     { | ||||
|         if (_preferenceIds.TryGetValue(id, out Snowflake preferenceId)) | ||||
|             return preferenceId.ToString(); | ||||
|         return ValidationUtils.DefaultStatusOptions.Contains(id) ? id : "okay"; | ||||
|         return ValidationService.DefaultStatusOptions.Contains(id) ? id : "okay"; | ||||
|     } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										3
									
								
								Foxnouns.Frontend/.vscode/settings.json
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								Foxnouns.Frontend/.vscode/settings.json
									
										
									
									
										vendored
									
									
								
							|  | @ -4,5 +4,6 @@ | |||
| 	"i18n-ally.localesPaths": ["src/lib/i18n", "src/lib/i18n/locales"], | ||||
| 	"i18n-ally.keystyle": "nested", | ||||
| 	"explorer.sortOrder": "filesFirst", | ||||
| 	"explorer.compactFolders": false | ||||
| 	"explorer.compactFolders": false, | ||||
| 	"eslint.validate": ["javascript", "javascriptreact", "svelte"] | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,4 @@ | |||
| FROM docker.io/node:22-slim | ||||
| 
 | ||||
| ENV PNPM_HOME="/pnpm" | ||||
| ENV PATH="$PNPM_HOME:$PATH" | ||||
| RUN corepack enable | ||||
| FROM docker.io/node:23-slim | ||||
| 
 | ||||
| COPY ./Foxnouns.Frontend /app | ||||
| COPY ./docker/frontend.env /app/.env.local | ||||
|  | @ -11,7 +7,7 @@ WORKDIR /app | |||
| ENV PRIVATE_API_HOST=http://rate:5003/api | ||||
| ENV PRIVATE_INTERNAL_API_HOST=http://backend:5000/api | ||||
| 
 | ||||
| RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile | ||||
| RUN pnpm run build | ||||
| RUN npm ci | ||||
| RUN npm run build | ||||
| 
 | ||||
| CMD ["pnpm", "node", "build/index.js"] | ||||
| CMD ["node", "build/index.js"] | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| // This script regenerates the list of icons for the frontend (Foxnouns.Frontend/src/lib/icons.ts)
 | ||||
| // and the backend (Foxnouns.Backend/Utils/BootstrapIcons.Icons.cs) from the currently installed version of Bootstrap Icons.
 | ||||
| // Run with `pnpm node icons.js` in the frontend directory.
 | ||||
| // Run with `node icons.js` in the frontend directory.
 | ||||
| 
 | ||||
| import { writeFileSync } from "fs"; | ||||
| import icons from "bootstrap-icons/font/bootstrap-icons.json" with { type: "json" }; | ||||
|  |  | |||
							
								
								
									
										6354
									
								
								Foxnouns.Frontend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6354
									
								
								Foxnouns.Frontend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -12,41 +12,42 @@ | |||
| 		"lint": "prettier --check . && eslint ." | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@sveltejs/adapter-node": "^5.2.10", | ||||
| 		"@sveltejs/kit": "^2.12.1", | ||||
| 		"@sveltejs/vite-plugin-svelte": "^5.0.2", | ||||
| 		"@sveltestrap/sveltestrap": "^6.2.7", | ||||
| 		"@sveltejs/adapter-node": "^5.2.12", | ||||
| 		"@sveltejs/kit": "^2.20.4", | ||||
| 		"@sveltejs/vite-plugin-svelte": "^5.0.3", | ||||
| 		"@sveltestrap/sveltestrap": "^7.1.0", | ||||
| 		"@types/eslint": "^9.6.1", | ||||
| 		"@types/luxon": "^3.4.2", | ||||
| 		"@types/luxon": "^3.6.2", | ||||
| 		"@types/markdown-it": "^14.1.2", | ||||
| 		"@types/sanitize-html": "^2.13.0", | ||||
| 		"bootstrap": "^5.3.3", | ||||
| 		"eslint": "^9.17.0", | ||||
| 		"@types/sanitize-html": "^2.15.0", | ||||
| 		"bootstrap": "^5.3.5", | ||||
| 		"dotenv": "^16.4.7", | ||||
| 		"eslint": "^9.24.0", | ||||
| 		"eslint-config-prettier": "^9.1.0", | ||||
| 		"eslint-plugin-svelte": "^2.46.1", | ||||
| 		"globals": "^15.13.0", | ||||
| 		"prettier": "^3.4.2", | ||||
| 		"prettier-plugin-svelte": "^3.3.2", | ||||
| 		"sass": "^1.83.0", | ||||
| 		"svelte": "^5.14.3", | ||||
| 		"svelte-bootstrap-icons": "^3.1.1", | ||||
| 		"svelte-check": "^4.1.1", | ||||
| 		"globals": "^16.0.0", | ||||
| 		"prettier": "^3.5.3", | ||||
| 		"prettier-plugin-svelte": "^3.3.3", | ||||
| 		"sass": "^1.86.3", | ||||
| 		"svelte": "^5.25.7", | ||||
| 		"svelte-bootstrap-icons": "^3.1.2", | ||||
| 		"svelte-check": "^4.1.5", | ||||
| 		"svelte-easy-crop": "^4.0.1", | ||||
| 		"sveltekit-i18n": "^2.4.2", | ||||
| 		"typescript": "^5.7.2", | ||||
| 		"typescript-eslint": "^8.18.1", | ||||
| 		"vite": "^6.0.3" | ||||
| 		"typescript": "^5.8.3", | ||||
| 		"typescript-eslint": "^8.29.0", | ||||
| 		"vite": "^6.2.5" | ||||
| 	}, | ||||
| 	"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c", | ||||
| 	"dependencies": { | ||||
| 		"@fontsource/firago": "^5.1.0", | ||||
| 		"@sentry/sveltekit": "^8.52.0", | ||||
| 		"@fontsource/firago": "^5.2.5", | ||||
| 		"@sentry/sveltekit": "^9.11.0", | ||||
| 		"base64-arraybuffer": "^1.0.2", | ||||
| 		"bootstrap-icons": "^1.11.3", | ||||
| 		"luxon": "^3.5.0", | ||||
| 		"luxon": "^3.6.1", | ||||
| 		"markdown-it": "^14.1.0", | ||||
| 		"minidenticons": "^4.2.1", | ||||
| 		"pretty-bytes": "^6.1.1", | ||||
| 		"sanitize-html": "^2.13.1", | ||||
| 		"sanitize-html": "^2.15.0", | ||||
| 		"svelte-tippy": "^1.3.2", | ||||
| 		"tippy.js": "^6.3.7", | ||||
| 		"tslog": "^4.9.3" | ||||
|  |  | |||
							
								
								
									
										4195
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										4195
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -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, | ||||
|  |  | |||
|  | @ -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)", | ||||
| } | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ export type Meta = { | |||
| 	}; | ||||
| 	members: number; | ||||
| 	limits: Limits; | ||||
| 	notice: { id: string; message: string } | null; | ||||
| }; | ||||
| 
 | ||||
| export type Limits = { | ||||
|  |  | |||
|  | @ -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; | ||||
| }; | ||||
|  |  | |||
|  | @ -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 = { | ||||
|  |  | |||
|  | @ -5,8 +5,11 @@ | |||
| 		currentPage: number; | ||||
| 		pageCount: number; | ||||
| 		center?: boolean; | ||||
| 		listAllPages?: boolean; | ||||
| 	}; | ||||
| 	let { currentPage = $bindable(), pageCount, center }: Props = $props(); | ||||
| 	let { currentPage = $bindable(), pageCount, center, listAllPages }: Props = $props(); | ||||
| 
 | ||||
| 	let allPages = $derived(listAllPages === undefined || listAllPages); | ||||
| 
 | ||||
| 	let prevPage = $derived(currentPage > 0 ? currentPage - 1 : 0); | ||||
| 	let nextPage = $derived(currentPage < pageCount - 1 ? currentPage + 1 : pageCount - 1); | ||||
|  | @ -21,11 +24,27 @@ | |||
| 			<PaginationItem> | ||||
| 				<PaginationLink previous onclick={() => (currentPage = prevPage)} /> | ||||
| 			</PaginationItem> | ||||
| 			{#if allPages} | ||||
| 				{#each new Array(pageCount) as _, page} | ||||
| 					<PaginationItem active={page === currentPage}> | ||||
| 						<PaginationLink onclick={() => (currentPage = page)}>{page + 1}</PaginationLink> | ||||
| 					</PaginationItem> | ||||
| 				{/each} | ||||
| 			{:else} | ||||
| 				{#if currentPage !== 0} | ||||
| 					<PaginationItem onclick={() => (currentPage = prevPage)}> | ||||
| 						<PaginationLink>{currentPage}</PaginationLink> | ||||
| 					</PaginationItem> | ||||
| 				{/if} | ||||
| 				<PaginationItem active> | ||||
| 					<PaginationLink>{currentPage + 1}</PaginationLink> | ||||
| 				</PaginationItem> | ||||
| 				{#if currentPage !== pageCount - 1} | ||||
| 					<PaginationItem onclick={() => (currentPage = nextPage)}> | ||||
| 						<PaginationLink>{currentPage + 2}</PaginationLink> | ||||
| 					</PaginationItem> | ||||
| 				{/if} | ||||
| 			{/if} | ||||
| 			<PaginationItem> | ||||
| 				<PaginationLink next onclick={() => (currentPage = nextPage)} /> | ||||
| 			</PaginationItem> | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
							
								
								
									
										50
									
								
								Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| <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> | ||||
| 			<!-- eslint-disable-next-line svelte/no-at-html-tags --> | ||||
| 			{@html renderedMessage} | ||||
| 		</div> | ||||
| 		{#if canDismiss} | ||||
| 			<div> | ||||
| 				<!-- svelte-ignore a11y_invalid_attribute --> | ||||
| 				<a href="#" tabindex="0" role="button" onclick={() => dismiss()} onkeyup={() => dismiss()}> | ||||
| 					{$t("notification.mark-as-read")} | ||||
| 				</a> | ||||
| 				• {idTimestamp(id).toLocaleString(DateTime.DATETIME_MED)} | ||||
| 			</div> | ||||
| 		{/if} | ||||
| 	</div> | ||||
| {/if} | ||||
|  | @ -13,13 +13,21 @@ | |||
| 	import Logo from "$components/Logo.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 
 | ||||
| 	type Props = { user: MeUser | null; meta: Meta }; | ||||
| 	let { user, meta }: Props = $props(); | ||||
| 	type Props = { user: MeUser | null; meta: Meta; unreadNotifications?: boolean }; | ||||
| 	let { user, meta, unreadNotifications }: Props = $props(); | ||||
| 
 | ||||
| 	let isOpen = $state(true); | ||||
| 	const toggleMenu = () => (isOpen = !isOpen); | ||||
| </script> | ||||
| 
 | ||||
| {#if user && unreadNotifications} | ||||
| 	<div class="notification-alert text-center py-3 mb-2 px-2"> | ||||
| 		<strong>{$t("nav.unread-notification-text")}</strong> | ||||
| 		<br /> | ||||
| 		<a href="/settings/notifications">{$t("nav.unread-notification-link")}</a> | ||||
| 	</div> | ||||
| {/if} | ||||
| 
 | ||||
| {#if user && user.deleted} | ||||
| 	<div class="deleted-alert text-center py-3 mb-2 px-2"> | ||||
| 		{#if user.suspended} | ||||
|  | @ -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; | ||||
|  |  | |||
|  | @ -59,6 +59,7 @@ | |||
| 	{#if reason} | ||||
| 		<details> | ||||
| 			<summary>Reason</summary> | ||||
| 			<!-- eslint-disable-next-line svelte/no-at-html-tags --> | ||||
| 			{@html reason} | ||||
| 		</details> | ||||
| 	{:else} | ||||
|  |  | |||
|  | @ -29,6 +29,7 @@ | |||
| 	<h4>Reason</h4> | ||||
| 	<p> | ||||
| 		{#if entry.reason} | ||||
| 			<!-- eslint-disable-next-line svelte/no-at-html-tags --> | ||||
| 			{@html renderMarkdown(entry.reason)} | ||||
| 		{:else} | ||||
| 			<em class="text-secondary">(no reason given)</em> | ||||
|  |  | |||
|  | @ -1,7 +1,8 @@ | |||
| <script lang="ts"> | ||||
| 	import Avatar from "$components/Avatar.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { Icon, InputGroup } from "@sveltestrap/sveltestrap"; | ||||
| 	import { Icon, InputGroup, Modal } from "@sveltestrap/sveltestrap"; | ||||
| 	import Cropper, { type CropArea, type OnCropCompleteEvent } from "svelte-easy-crop"; | ||||
| 	import { encode } from "base64-arraybuffer"; | ||||
| 	import prettyBytes from "pretty-bytes"; | ||||
| 	import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte"; | ||||
|  | @ -21,6 +22,7 @@ | |||
| 	let avatar: string = $state(""); | ||||
| 	let avatarExists = $derived(avatar !== ""); | ||||
| 	let avatarTooLarge = $derived(avatar !== "" && avatar.length > MAX_AVATAR_BYTES); | ||||
| 	let cropperOpen = $state(false); | ||||
| 
 | ||||
| 	$effect(() => { | ||||
| 		getAvatar(avatarFiles); | ||||
|  | @ -28,7 +30,7 @@ | |||
| 
 | ||||
| 	const getAvatar = async (list: FileList | null) => { | ||||
| 		if (!list || list.length === 0) { | ||||
| 			avatar = ""; | ||||
| 			uncroppedAvatar = ""; | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
|  | @ -36,7 +38,47 @@ | |||
| 		const base64 = encode(buffer); | ||||
| 
 | ||||
| 		const uri = `data:${list[0].type};base64,${base64}`; | ||||
| 		avatar = uri; | ||||
| 		uncroppedAvatar = uri; | ||||
| 		cropperOpen = true; | ||||
| 	}; | ||||
| 
 | ||||
| 	let uncroppedAvatar: string = $state(""); | ||||
| 	let crop = $state({ x: 0, y: 0 }); | ||||
| 	let zoom = $state(1); | ||||
| 	let croppedArea = $state({ x: 0, y: 0, height: 0, width: 0 } satisfies CropArea); | ||||
| 
 | ||||
| 	const onCropComplete = (e: OnCropCompleteEvent) => { | ||||
| 		croppedArea = e.pixels; | ||||
| 	}; | ||||
| 
 | ||||
| 	const doCrop = () => { | ||||
| 		cropperOpen = false; | ||||
| 		if (!uncroppedAvatar) { | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		const canvas = document.createElement("canvas"); | ||||
| 		const ctx = canvas.getContext("2d"); | ||||
| 		const img = new Image(); | ||||
| 		img.onload = () => { | ||||
| 			canvas.width = croppedArea.width; | ||||
| 			canvas.height = croppedArea.height; | ||||
| 
 | ||||
| 			ctx?.drawImage( | ||||
| 				img, | ||||
| 				croppedArea.x, | ||||
| 				croppedArea.y, | ||||
| 				croppedArea.width, | ||||
| 				croppedArea.height, | ||||
| 				0, | ||||
| 				0, | ||||
| 				croppedArea.width, | ||||
| 				croppedArea.height, | ||||
| 			); | ||||
| 
 | ||||
| 			avatar = canvas.toDataURL("image/webp", 1); | ||||
| 		}; | ||||
| 		img.src = uncroppedAvatar; | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
|  | @ -44,6 +86,41 @@ | |||
| 	<Avatar {name} url={avatarExists ? avatar : current} {alt} /> | ||||
| </p> | ||||
| 
 | ||||
| <Modal | ||||
| 	isOpen={cropperOpen} | ||||
| 	autoFocus | ||||
| 	backdrop | ||||
| 	fade | ||||
| 	keyboard | ||||
| 	returnFocusAfterClose | ||||
| 	toggle={() => (cropperOpen = !cropperOpen)} | ||||
| > | ||||
| 	<div class="modal-header"> | ||||
| 		<h1 class="modal-title fs-5">{$t("editor.crop-avatar-header")}</h1> | ||||
| 	</div> | ||||
| 	<div class="modal-body cropper-wrapper"> | ||||
| 		{#if uncroppedAvatar} | ||||
| 			<Cropper | ||||
| 				image={uncroppedAvatar} | ||||
| 				{crop} | ||||
| 				{zoom} | ||||
| 				minZoom={1} | ||||
| 				maxZoom={4} | ||||
| 				aspect={1 / 1} | ||||
| 				oncropcomplete={onCropComplete} | ||||
| 			/> | ||||
| 		{/if} | ||||
| 	</div> | ||||
| 	<div class="modal-footer"> | ||||
| 		<button type="button" class="btn btn-primary" onclick={() => doCrop()}> | ||||
| 			{$t("editor.crop-avatar-button")} | ||||
| 		</button> | ||||
| 		<button type="button" class="btn btn-outline-secondary" onclick={() => (cropperOpen = false)}> | ||||
| 			{$t("cancel")} | ||||
| 		</button> | ||||
| 	</div> | ||||
| </Modal> | ||||
| 
 | ||||
| <InputGroup class="mb-2"> | ||||
| 	<input | ||||
| 		class="form-control" | ||||
|  | @ -53,7 +130,7 @@ | |||
| 		accept="image/png, image/jpeg, image/gif, image/webp" | ||||
| 	/> | ||||
| 	<button | ||||
| 		class="btn btn-secondary" | ||||
| 		class="btn btn-primary" | ||||
| 		disabled={!avatarExists || avatarTooLarge} | ||||
| 		onclick={() => onclick(avatar)} | ||||
| 	> | ||||
|  | @ -79,3 +156,9 @@ | |||
| 		})} | ||||
| 	</p> | ||||
| {/if} | ||||
| 
 | ||||
| <style> | ||||
| 	.cropper-wrapper { | ||||
| 		min-height: 30em; | ||||
| 	} | ||||
| </style> | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| <script lang="ts"> | ||||
| 	import { t } from "$lib/i18n"; | ||||
| </script> | ||||
| 
 | ||||
| <div class="alert alert-secondary"> | ||||
| 	{$t("editor.custom-preference-notice")} | ||||
| 	<a href="/settings/prefs" class="alert-link">{$t("editor.custom-preference-notice-link")}</a> | ||||
| </div> | ||||
|  | @ -6,6 +6,7 @@ | |||
| 	import FieldEditor from "./FieldEditor.svelte"; | ||||
| 	import FormStatusMarker from "./FormStatusMarker.svelte"; | ||||
| 	import NoscriptWarning from "./NoscriptWarning.svelte"; | ||||
| 	import CustomPreferencesNotice from "$components/editor/CustomPreferencesNotice.svelte"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		fields: Field[]; | ||||
|  | @ -45,6 +46,7 @@ | |||
| 
 | ||||
| <NoscriptWarning /> | ||||
| <FormStatusMarker form={ok} /> | ||||
| <CustomPreferencesNotice /> | ||||
| 
 | ||||
| <h4>{$t("edit-profile.editing-fields-header")}</h4> | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,23 +4,35 @@ | |||
| 	import Search from "svelte-bootstrap-icons/lib/Search.svelte"; | ||||
| 	import FlagButton from "./FlagButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import paginate from "$lib/paginate"; | ||||
| 	import ClientPaginator from "$components/ClientPaginator.svelte"; | ||||
| 
 | ||||
| 	type Props = { flags: PrideFlag[]; select(flag: PrideFlag): void }; | ||||
| 	let { flags, select }: Props = $props(); | ||||
| 
 | ||||
| 	const FLAGS_PER_PAGE = 5; | ||||
| 	let query = $state(""); | ||||
| 	let filteredFlags = $derived(search(query)); | ||||
| 
 | ||||
| 	let arr: PrideFlag[] = $state([]); | ||||
| 	let currentPage = $state(0); | ||||
| 	let pageCount = $state(0); | ||||
| 
 | ||||
| 	$effect(() => { | ||||
| 		const pages = paginate(filteredFlags, currentPage, FLAGS_PER_PAGE); | ||||
| 		arr = pages.data; | ||||
| 		pageCount = pages.pageCount; | ||||
| 	}); | ||||
| 
 | ||||
| 	function search(q: string) { | ||||
| 		if (!q) return flags.slice(0, 20); | ||||
| 		return flags.filter((f) => f.name.toLowerCase().indexOf(q.toLowerCase()) !== -1).slice(0, 20); | ||||
| 		return flags.filter((f) => f.name.toLowerCase().indexOf(q.toLowerCase()) !== -1); | ||||
| 	} | ||||
| </script> | ||||
| 
 | ||||
| <input class="form-control" placeholder={$t("editor.flag-search-placeholder")} bind:value={query} /> | ||||
| 
 | ||||
| <div class="mt-3"> | ||||
| 	{#each filteredFlags as flag (flag.id)} | ||||
| 	{#each arr as flag (flag.id)} | ||||
| 		<FlagButton {flag} onclick={() => select(flag)} padding /> | ||||
| 	{:else} | ||||
| 		<div class="text-secondary text-center"> | ||||
|  | @ -36,6 +48,7 @@ | |||
| 			</p> | ||||
| 		</div> | ||||
| 	{/each} | ||||
| 	<ClientPaginator bind:currentPage {pageCount} listAllPages={false} /> | ||||
| 	{#if flags.length > 0} | ||||
| 		<p class="text-secondary mt-2"> | ||||
| 			<InfoCircleFill aria-hidden /> | ||||
|  |  | |||
|  | @ -2,14 +2,16 @@ | |||
| 	import type { RawApiError } from "$api/error"; | ||||
| 	import IconButton from "$components/IconButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import ephemeralState from "$lib/state.svelte"; | ||||
| 	import FormStatusMarker from "./FormStatusMarker.svelte"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		stateKey: string; | ||||
| 		currentLinks: string[]; | ||||
| 		save(links: string[]): Promise<void>; | ||||
| 		form: { ok: boolean; error: RawApiError | null } | null; | ||||
| 	}; | ||||
| 	let { currentLinks, save, form }: Props = $props(); | ||||
| 	let { stateKey, currentLinks, save, form }: Props = $props(); | ||||
| 
 | ||||
| 	let links = $state(currentLinks); | ||||
| 	let newEntry = $state(""); | ||||
|  | @ -37,6 +39,12 @@ | |||
| 		links = [...links, newEntry]; | ||||
| 		newEntry = ""; | ||||
| 	}; | ||||
| 
 | ||||
| 	ephemeralState( | ||||
| 		stateKey, | ||||
| 		() => links, | ||||
| 		(data) => (links = data), | ||||
| 	); | ||||
| </script> | ||||
| 
 | ||||
| <h4> | ||||
|  |  | |||
|  | @ -4,16 +4,18 @@ | |||
| 	import FlagSearch from "$components/editor/FlagSearch.svelte"; | ||||
| 	import IconButton from "$components/IconButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import ephemeralState from "$lib/state.svelte"; | ||||
| 	import FlagButton from "./FlagButton.svelte"; | ||||
| 	import FormStatusMarker from "./FormStatusMarker.svelte"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		stateKey: string; | ||||
| 		profileFlags: PrideFlag[]; | ||||
| 		allFlags: PrideFlag[]; | ||||
| 		save(flags: string[]): Promise<void>; | ||||
| 		form: { ok: boolean; error: RawApiError | null } | null; | ||||
| 	}; | ||||
| 	let { profileFlags, allFlags, save, form }: Props = $props(); | ||||
| 	let { stateKey, profileFlags, allFlags, save, form }: Props = $props(); | ||||
| 
 | ||||
| 	let flags = $state(profileFlags); | ||||
| 
 | ||||
|  | @ -40,6 +42,12 @@ | |||
| 	}; | ||||
| 
 | ||||
| 	const saveChanges = () => save(flags.map((f) => f.id)); | ||||
| 
 | ||||
| 	ephemeralState( | ||||
| 		stateKey, | ||||
| 		() => flags, | ||||
| 		(data) => (flags = data), | ||||
| 	); | ||||
| </script> | ||||
| 
 | ||||
| <div class="row"> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -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"); | ||||
| 	} | ||||
|  |  | |||
|  | @ -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,12 @@ | |||
| 		"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", | ||||
| 		"max-custom-preferences": "You have reached the maximum amount of custom preferences ({{current}}/{{max}}), and cannot add new ones." | ||||
| 	}, | ||||
| 	"cancel": "Cancel", | ||||
| 	"report": { | ||||
|  | @ -321,6 +331,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." | ||||
| 	} | ||||
| } | ||||
|  |  | |||
							
								
								
									
										37
									
								
								Foxnouns.Frontend/src/lib/state.svelte.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								Foxnouns.Frontend/src/lib/state.svelte.ts
									
										
									
									
									
										Normal 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())); | ||||
| 	}); | ||||
| } | ||||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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} /> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 }; | ||||
| }; | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -56,6 +56,7 @@ | |||
| 		<h3>Context</h3> | ||||
| 		<p> | ||||
| 			{#if report.context} | ||||
| 				<!-- eslint-disable-next-line svelte/no-at-html-tags --> | ||||
| 				{@html renderMarkdown(report.context)} | ||||
| 			{:else} | ||||
| 				<em>(no context given)</em> | ||||
|  |  | |||
|  | @ -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 = { | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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"); | ||||
| }; | ||||
|  |  | |||
|  | @ -18,5 +18,6 @@ | |||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	<!-- eslint-disable-next-line svelte/no-at-html-tags --> | ||||
| 	{@html md} | ||||
| </div> | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue