Compare commits

...

26 commits

Author SHA1 Message Date
sam
53006ea313
feat(frontend): audit log 2024-12-26 16:33:32 -05:00
sam
49e9eabea0
refactor(frontend): deduplicate isActive function 2024-12-26 14:10:03 -05:00
sam
5077bd6a0b
fix(backend): return report context in mod api 2024-12-26 14:01:51 -05:00
sam
3f0edc4374
static pages volume in docker-compose.yml 2024-12-26 10:25:00 -05:00
sam
7468aa20ab
feat: static documentation pages 2024-12-25 17:53:31 -05:00
sam
fe1cf7ce8a
feat: GET /api/v1/users/@me 2024-12-25 16:04:32 -05:00
sam
478ba2a406
feat: GET /api/v1/users/{userRef}/members/{memberRef} 2024-12-25 14:53:36 -05:00
sam
78afb8b9c4
feat: GET /api/v1/users/{userRef}/members 2024-12-25 14:33:42 -05:00
sam
e908e67ca6
chore: license headers 2024-12-25 14:24:18 -05:00
sam
d182b07482
feat: GET /api/v1/members/{id}, api v1 flags 2024-12-25 14:23:16 -05:00
sam
2281b3e478
fix: replace port 5000 in example docs with port 6000
macOS runs a service on port 5000 by default. this doesn't actually
prevent the backend server from *starting*, or the rate limiter proxy
from working, but it *does* mean that when the backend restarts, if the
proxy sends a request, it will stop working until it's restarted.

the easiest way to work around this is by just changing the port the
backend listens on. this does not change the ports used in the docker
configuration.
2024-12-25 14:03:15 -05:00
sam
140419a1ca
feat: rate limiter lets api v1 requests through 2024-12-25 12:08:53 -05:00
sam
7791c91960
feat(backend): initial /api/v1/users endpoint 2024-12-25 11:19:50 -05:00
sam
5e7df2e074
feat(frontend): add footer 2024-12-25 11:04:20 -05:00
sam
e24c4f9b00
feat(frontend): self-service delete, force delete pages 2024-12-19 17:15:50 +01:00
sam
3f8f6d0f23
delete stray console.log 2024-12-19 16:24:17 +01:00
sam
661c3eab0f
fix(backend): save data exports as data-export.zip
change the random base 64 to a directory rather than part of the
filename, so that users downloading their exports aren't greeted with a
completely incomprehensible file in their downloads folder
2024-12-19 16:19:27 +01:00
sam
96725cc304
feat: self-service deletion API, reactivate account page 2024-12-19 16:13:05 +01:00
sam
8a2ffd7d69
feat(frontend): preference cheatsheet 2024-12-18 21:38:39 +01:00
sam
546e900204
feat(backend): report context, fix deleting reports 2024-12-18 21:26:35 +01:00
sam
bd21eeebcf
feat(frontend): report profile page 2024-12-18 21:26:17 +01:00
sam
05913a3b2f
chore: update svelte 2024-12-18 02:53:06 +01:00
sam
1fb1d8dd14
update gitignore 2024-12-18 02:30:21 +01:00
sam
ddd96e415a
refactor(frontend): use handleError hook for errors instead of try/catch 2024-12-18 02:25:47 +01:00
sam
397ffc2d5e
update sveltekit, migrate to $app/state 2024-12-17 23:33:05 +01:00
sam
80385893c7
feat: split migration into batches 2024-12-17 21:23:02 +01:00
100 changed files with 2867 additions and 412 deletions

View file

@ -20,4 +20,5 @@
**/secrets.dev.yaml **/secrets.dev.yaml
**/values.dev.yaml **/values.dev.yaml
LICENSE LICENSE
README.md README.md
static-pages/*

4
.gitignore vendored
View file

@ -6,7 +6,11 @@ config.ini
*.DotSettings.user *.DotSettings.user
proxy-config.json proxy-config.json
.DS_Store .DS_Store
.idea/.idea.Foxnouns.NET/.idea/dataSources.xml
.idea/.idea.Foxnouns.NET/.idea/sqldialects.xml
docker/config.ini docker/config.ini
docker/proxy-config.json docker/proxy-config.json
docker/frontend.env docker/frontend.env
Foxnouns.DataMigrator/apps.json

View file

@ -4,14 +4,31 @@
{ {
"name": "run-prettier", "name": "run-prettier",
"command": "pnpm", "command": "pnpm",
"args": ["format"], "args": [
"prettier",
"-w",
"${staged}"
],
"include": [
"Foxnouns.Frontend/**/*.ts",
"Foxnouns.Frontend/**/*.json",
"Foxnouns.Frontend/**/*.scss",
"Foxnouns.Frontend/**/*.js",
"Foxnouns.Frontend/**/*.svelte"
],
"cwd": "Foxnouns.Frontend/",
"pathMode": "absolute" "pathMode": "absolute"
}, },
{ {
"name": "run-csharpier", "name": "run-csharpier",
"command": "dotnet", "command": "dotnet",
"args": [ "csharpier", "${staged}" ], "args": [
"include": [ "**/*.cs" ] "csharpier",
"${staged}"
],
"include": [
"**/*.cs"
]
} }
] ]
} }

View file

@ -0,0 +1,89 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Middleware;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
namespace Foxnouns.Backend.Controllers;
[Route("/api/internal/self-delete")]
[Authorize("*")]
[ApiExplorerSettings(IgnoreApi = true)]
public class DeleteUserController(DatabaseContext db, IClock clock, ILogger logger)
: ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<DeleteUserController>();
[HttpPost("delete")]
public async Task<IActionResult> DeleteSelfAsync()
{
_logger.Information(
"User {UserId} has requested their account to be deleted",
CurrentUser!.Id
);
CurrentUser.Deleted = true;
CurrentUser.DeletedAt = clock.GetCurrentInstant();
db.Update(CurrentUser);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("force")]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> ForceDeleteAsync()
{
if (!CurrentUser!.Deleted)
throw new ApiError.BadRequest("Your account isn't deleted.");
_logger.Information(
"User {UserId} has requested an early full delete of their account",
CurrentUser.Id
);
// This is the easiest way to force delete a user, don't judge me
CurrentUser.DeletedAt = clock.GetCurrentInstant() - Duration.FromDays(365);
db.Update(CurrentUser);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("undelete")]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> UndeleteSelfAsync()
{
if (!CurrentUser!.Deleted)
throw new ApiError.BadRequest("Your account isn't deleted.");
if (CurrentUser!.DeletedBy != null)
{
throw new ApiError.BadRequest(
"Your account has been suspended and can't be reactivated by yourself."
);
}
_logger.Information(
"User {UserId} has requested to undelete their account",
CurrentUser.Id
);
CurrentUser.Deleted = false;
CurrentUser.DeletedAt = null;
db.Update(CurrentUser);
await db.SaveChangesAsync();
return NoContent();
}
}

View file

@ -26,6 +26,7 @@ namespace Foxnouns.Backend.Controllers;
[Route("/api/internal/data-exports")] [Route("/api/internal/data-exports")]
[Authorize("identify")] [Authorize("identify")]
[Limit(UsableByDeletedUsers = true)]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
public class ExportsController( public class ExportsController(
ILogger logger, ILogger logger,
@ -57,7 +58,7 @@ public class ExportsController(
} }
private string ExportUrl(Snowflake userId, string filename) => private string ExportUrl(Snowflake userId, string filename) =>
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip"; $"{config.MediaBaseUrl}/data-exports/{userId}/{filename}/data-export.zip";
[HttpPost] [HttpPost]
public async Task<IActionResult> QueueDataExportAsync() public async Task<IActionResult> QueueDataExportAsync()

View file

@ -22,6 +22,7 @@ using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using XidNet;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
@ -34,7 +35,7 @@ public class FlagsController(
) : ApiControllerBase ) : ApiControllerBase
{ {
[HttpGet] [HttpGet]
[Limit(UsableBySuspendedUsers = true)] [Limit(UsableByDeletedUsers = true)]
[Authorize("user.read_flags")] [Authorize("user.read_flags")]
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default) public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
@ -64,6 +65,7 @@ public class FlagsController(
var flag = new PrideFlag var flag = new PrideFlag
{ {
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
UserId = CurrentUser!.Id, UserId = CurrentUser!.Id,
Name = req.Name, Name = req.Name,
Description = req.Description, Description = req.Description,

View file

@ -38,6 +38,8 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
{ {
if (template.StartsWith("api/v2")) if (template.StartsWith("api/v2"))
template = template["api/v2".Length..]; template = template["api/v2".Length..];
else if (template.StartsWith("api/v1"))
template = template["api/v1".Length..];
template = PathVarRegex() template = PathVarRegex()
.Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}` .Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}`
.Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}` .Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}`

View file

@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using NodaTime; using NodaTime;
using XidNet;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
@ -44,7 +45,7 @@ public class MembersController(
[HttpGet] [HttpGet]
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)] [Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default) public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
{ {
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
@ -53,7 +54,7 @@ public class MembersController(
[HttpGet("{memberRef}")] [HttpGet("{memberRef}")]
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)] [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)] [Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetMemberAsync( public async Task<IActionResult> GetMemberAsync(
string userRef, string userRef,
string memberRef, string memberRef,
@ -101,6 +102,7 @@ public class MembersController(
var member = new Member var member = new Member
{ {
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
User = CurrentUser!, User = CurrentUser!,
Name = req.Name, Name = req.Name,
DisplayName = req.DisplayName, DisplayName = req.DisplayName,

View file

@ -12,6 +12,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Text.RegularExpressions;
using Foxnouns.Backend.Dto; using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -19,7 +20,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/meta")] [Route("/api/v2/meta")]
public class MetaController : ApiControllerBase public partial class MetaController : ApiControllerBase
{ {
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
@ -48,7 +49,23 @@ public class MetaController : ApiControllerBase
) )
); );
[HttpGet("page/{page}")]
public async Task<IActionResult> GetStaticPageAsync(string page, CancellationToken ct = default)
{
if (!PageRegex().IsMatch(page))
{
throw new ApiError.BadRequest("Invalid page name");
}
string path = Path.Join(Directory.GetCurrentDirectory(), "static-pages", $"{page}.md");
string text = await System.IO.File.ReadAllTextAsync(path, ct);
return Ok(text);
}
[HttpGet("/api/v2/coffee")] [HttpGet("/api/v2/coffee")]
public IActionResult BrewCoffee() => public IActionResult BrewCoffee() =>
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
[GeneratedRegex(@"^[a-z\-_]+$")]
private static partial Regex PageRegex();
} }

View file

@ -30,7 +30,9 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
public async Task<IActionResult> GetAuditLogAsync( public async Task<IActionResult> GetAuditLogAsync(
[FromQuery] AuditLogEntryType? type = null, [FromQuery] AuditLogEntryType? type = null,
[FromQuery] int? limit = null, [FromQuery] int? limit = null,
[FromQuery] Snowflake? before = null [FromQuery] Snowflake? before = null,
[FromQuery] Snowflake? after = null,
[FromQuery(Name = "by-moderator")] Snowflake? byModerator = null
) )
{ {
limit = limit switch limit = limit switch
@ -45,11 +47,30 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
if (before != null) if (before != null)
query = query.Where(e => e.Id < before.Value); query = query.Where(e => e.Id < before.Value);
else if (after != null)
query = query.Where(e => e.Id > after.Value);
if (type != null) if (type != null)
query = query.Where(e => e.Type == type); query = query.Where(e => e.Type == type);
if (byModerator != null)
query = query.Where(e => e.ModeratorId == byModerator.Value);
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync(); List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry)); return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
} }
[HttpGet("moderators")]
public async Task<IActionResult> GetModeratorsAsync(CancellationToken ct = default)
{
var moderators = await db
.Users.Where(u =>
!u.Deleted && (u.Role == UserRole.Admin || u.Role == UserRole.Moderator)
)
.Select(u => new { u.Id, u.Username })
.OrderBy(u => u.Id)
.ToListAsync(ct);
return Ok(moderators);
}
} }

View file

@ -18,6 +18,7 @@ using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto; using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -49,6 +50,8 @@ public class ReportsController(
[FromBody] CreateReportRequest req [FromBody] CreateReportRequest req
) )
{ {
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
User target = await db.ResolveUserAsync(id); User target = await db.ResolveUserAsync(id);
if (target.Id == CurrentUser!.Id) if (target.Id == CurrentUser!.Id)
@ -96,6 +99,7 @@ public class ReportsController(
TargetUserId = target.Id, TargetUserId = target.Id,
TargetMemberId = null, TargetMemberId = null,
Reason = req.Reason, Reason = req.Reason,
Context = req.Context,
TargetType = ReportTargetType.User, TargetType = ReportTargetType.User,
TargetSnapshot = snapshot, TargetSnapshot = snapshot,
}; };
@ -112,6 +116,8 @@ public class ReportsController(
[FromBody] CreateReportRequest req [FromBody] CreateReportRequest req
) )
{ {
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
Member target = await db.ResolveMemberAsync(id); Member target = await db.ResolveMemberAsync(id);
if (target.User.Id == CurrentUser!.Id) if (target.User.Id == CurrentUser!.Id)
@ -158,6 +164,7 @@ public class ReportsController(
TargetUserId = target.User.Id, TargetUserId = target.User.Id,
TargetMemberId = target.Id, TargetMemberId = target.Id,
Reason = req.Reason, Reason = req.Reason,
Context = req.Context,
TargetType = ReportTargetType.Member, TargetType = ReportTargetType.Member,
TargetSnapshot = snapshot, TargetSnapshot = snapshot,
}; };

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
@ -17,7 +31,7 @@ public class NotificationsController(
{ {
[HttpGet] [HttpGet]
[Authorize("user.moderation")] [Authorize("user.moderation")]
[Limit(UsableBySuspendedUsers = true)] [Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false) public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
{ {
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id); IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
@ -31,7 +45,7 @@ public class NotificationsController(
[HttpPut("{id}/ack")] [HttpPut("{id}/ack")]
[Authorize("user.moderation")] [Authorize("user.moderation")]
[Limit(UsableBySuspendedUsers = true)] [Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id) public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
{ {
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n => Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>

View file

@ -42,7 +42,7 @@ public class UsersController(
[HttpGet("{userRef}")] [HttpGet("{userRef}")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)] [Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default) public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{ {
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
@ -222,7 +222,7 @@ public class UsersController(
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)) .CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
.ToDictionary(); .ToDictionary();
foreach (CustomPreferenceUpdateRequest? r in req) foreach (CustomPreferenceUpdateRequest r in req)
{ {
if (r.Id != null && preferences.ContainsKey(r.Id.Value)) if (r.Id != null && preferences.ContainsKey(r.Id.Value))
{ {
@ -233,6 +233,7 @@ public class UsersController(
Muted = r.Muted, Muted = r.Muted,
Size = r.Size, Size = r.Size,
Tooltip = r.Tooltip, Tooltip = r.Tooltip,
LegacyId = preferences[r.Id.Value].LegacyId,
}; };
} }
else else
@ -244,6 +245,7 @@ public class UsersController(
Muted = r.Muted, Muted = r.Muted,
Size = r.Size, Size = r.Size,
Tooltip = r.Tooltip, Tooltip = r.Tooltip,
LegacyId = Guid.NewGuid(),
}; };
} }
} }

View file

@ -0,0 +1,120 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto.V1;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services.V1;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.V1;
[Route("/api/v1")]
public class V1ReadController(
UsersV1Service usersV1Service,
MembersV1Service membersV1Service,
DatabaseContext db
) : ApiControllerBase
{
[HttpGet("users/@me")]
[Authorize("identify")]
public async Task<IActionResult> GetMeAsync(CancellationToken ct = default)
{
User user = await usersV1Service.ResolveUserAsync("@me", CurrentToken, ct);
return Ok(await usersV1Service.RenderCurrentUserAsync(user, ct));
}
[HttpGet("users/{userRef}")]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{
User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(
await usersV1Service.RenderUserAsync(
user,
CurrentToken,
renderMembers: true,
renderFlags: true,
ct: ct
)
);
}
[HttpGet("members/{id}")]
public async Task<IActionResult> GetMemberAsync(string id, CancellationToken ct = default)
{
Member member = await membersV1Service.ResolveMemberAsync(id, ct);
return Ok(
await membersV1Service.RenderMemberAsync(
member,
CurrentToken,
renderFlags: true,
ct: ct
)
);
}
[HttpGet("users/{userRef}/members")]
public async Task<IActionResult> GetUserMembersAsync(
string userRef,
CancellationToken ct = default
)
{
User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
List<Member> members = await db
.Members.Where(m => m.UserId == user.Id)
.OrderBy(m => m.Name)
.ToListAsync(ct);
List<MemberResponse> responses = [];
foreach (Member member in members)
{
responses.Add(
await membersV1Service.RenderMemberAsync(
member,
CurrentToken,
user,
renderFlags: true,
ct: ct
)
);
}
return Ok(responses);
}
[HttpGet("users/{userRef}/members/{memberRef}")]
public async Task<IActionResult> GetUserMemberAsync(
string userRef,
string memberRef,
CancellationToken ct = default
)
{
Member member = await membersV1Service.ResolveMemberAsync(
userRef,
memberRef,
CurrentToken,
ct
);
return Ok(
await membersV1Service.RenderMemberAsync(
member,
CurrentToken,
renderFlags: true,
ct: ct
)
);
}
}

View file

@ -108,6 +108,12 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
.HasFilter("fediverse_application_id IS NULL") .HasFilter("fediverse_application_id IS NULL")
.IsUnique(); .IsUnique();
modelBuilder
.Entity<AuditLogEntry>()
.HasOne(e => e.Report)
.WithOne(e => e.AuditLogEntry)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()"); modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb"); modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb"); modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
@ -133,6 +139,26 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!) .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
.HasName("find_free_member_sid"); .HasName("find_free_member_sid");
// Indexes for legacy IDs for APIv1
modelBuilder.Entity<User>().HasIndex(u => u.LegacyId).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.LegacyId).IsUnique();
modelBuilder.Entity<PrideFlag>().HasIndex(f => f.LegacyId).IsUnique();
// a UUID is not an xid, but this should always be set by the application anyway.
// we're just setting it here to shut EFCore up because squashing migrations is for nerds
modelBuilder
.Entity<User>()
.Property(u => u.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<Member>()
.Property(m => m.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<PrideFlag>()
.Property(f => f.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,51 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241217195351_AddFediAppForceRefresh")]
public partial class AddFediAppForceRefresh : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Dictionary<string, string>>(
name: "localization_params",
table: "notifications",
type: "hstore",
nullable: false,
oldClrType: typeof(Dictionary<string, string>),
oldType: "hstore",
oldNullable: true
);
migrationBuilder.AddColumn<bool>(
name: "force_refresh",
table: "fediverse_applications",
type: "boolean",
nullable: false,
defaultValue: false
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "force_refresh", table: "fediverse_applications");
migrationBuilder.AlterColumn<Dictionary<string, string>>(
name: "localization_params",
table: "notifications",
type: "hstore",
nullable: true,
oldClrType: typeof(Dictionary<string, string>),
oldType: "hstore"
);
}
}
}

View file

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241218195457_AddContextToReports")]
public partial class AddContextToReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "context",
table: "reports",
type: "text",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "context", table: "reports");
}
}
}

View file

@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241218201855_MakeAuditLogReportsNullable")]
public partial class MakeAuditLogReportsNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log"
);
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id",
unique: true
);
migrationBuilder.AddForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log",
column: "report_id",
principalTable: "reports",
principalColumn: "id",
onDelete: ReferentialAction.SetNull
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log"
);
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id"
);
migrationBuilder.AddForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log",
column: "report_id",
principalTable: "reports",
principalColumn: "id"
);
}
}
}

View file

@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241225155818_AddLegacyIds")]
public partial class AddLegacyIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "users",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "pride_flags",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "members",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.CreateIndex(
name: "ix_users_legacy_id",
table: "users",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_pride_flags_legacy_id",
table: "pride_flags",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_members_legacy_id",
table: "members",
column: "legacy_id",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "ix_users_legacy_id", table: "users");
migrationBuilder.DropIndex(name: "ix_pride_flags_legacy_id", table: "pride_flags");
migrationBuilder.DropIndex(name: "ix_members_legacy_id", table: "members");
migrationBuilder.DropColumn(name: "legacy_id", table: "users");
migrationBuilder.DropColumn(name: "legacy_id", table: "pride_flags");
migrationBuilder.DropColumn(name: "legacy_id", table: "members");
}
}
}

View file

@ -113,6 +113,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasName("pk_audit_log"); .HasName("pk_audit_log");
b.HasIndex("ReportId") b.HasIndex("ReportId")
.IsUnique()
.HasDatabaseName("ix_audit_log_report_id"); .HasDatabaseName("ix_audit_log_report_id");
b.ToTable("audit_log", (string)null); b.ToTable("audit_log", (string)null);
@ -216,6 +217,10 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("domain"); .HasColumnName("domain");
b.Property<bool>("ForceRefresh")
.HasColumnType("boolean")
.HasColumnName("force_refresh");
b.Property<int>("InstanceType") b.Property<int>("InstanceType")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("instance_type"); .HasColumnName("instance_type");
@ -249,6 +254,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("fields"); .HasColumnName("fields");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
@ -287,6 +299,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_members"); .HasName("pk_members");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_members_legacy_id");
b.HasIndex("Sid") b.HasIndex("Sid")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_members_sid"); .HasDatabaseName("ix_members_sid");
@ -342,6 +358,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnName("localization_key"); .HasColumnName("localization_key");
b.Property<Dictionary<string, string>>("LocalizationParams") b.Property<Dictionary<string, string>>("LocalizationParams")
.IsRequired()
.HasColumnType("hstore") .HasColumnType("hstore")
.HasColumnName("localization_params"); .HasColumnName("localization_params");
@ -380,6 +397,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("hash"); .HasColumnName("hash");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@ -392,6 +416,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_pride_flags"); .HasName("pk_pride_flags");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_pride_flags_legacy_id");
b.HasIndex("UserId") b.HasIndex("UserId")
.HasDatabaseName("ix_pride_flags_user_id"); .HasDatabaseName("ix_pride_flags_user_id");
@ -404,6 +432,10 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("id"); .HasColumnName("id");
b.Property<string>("Context")
.HasColumnType("text")
.HasColumnName("context");
b.Property<int>("Reason") b.Property<int>("Reason")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("reason"); .HasColumnName("reason");
@ -411,7 +443,7 @@ namespace Foxnouns.Backend.Database.Migrations
b.Property<long>("ReporterId") b.Property<long>("ReporterId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("reporter_id"); .HasColumnName("reporter_id");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("status"); .HasColumnName("status");
@ -572,6 +604,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll"); .HasColumnName("last_sid_reroll");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
@ -627,6 +666,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_users"); .HasName("pk_users");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("Sid") b.HasIndex("Sid")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_users_sid"); .HasDatabaseName("ix_users_sid");
@ -670,8 +713,9 @@ namespace Foxnouns.Backend.Database.Migrations
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report") b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
.WithMany() .WithOne("AuditLogEntry")
.HasForeignKey("ReportId") .HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_audit_log_reports_report_id"); .HasConstraintName("fk_audit_log_reports_report_id");
b.Navigation("Report"); b.Navigation("Report");
@ -834,6 +878,11 @@ namespace Foxnouns.Backend.Database.Migrations
b.Navigation("ProfileFlags"); b.Navigation("ProfileFlags");
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Navigation("AuditLogEntry");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{ {
b.Navigation("AuthMethods"); b.Navigation("AuthMethods");

View file

@ -12,6 +12,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations.Schema;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Newtonsoft.Json; using Newtonsoft.Json;

View file

@ -20,6 +20,7 @@ public class FediverseApplication : BaseModel
public required string ClientId { get; set; } public required string ClientId { get; set; }
public required string ClientSecret { get; set; } public required string ClientSecret { get; set; }
public required FediverseInstanceType InstanceType { get; set; } public required FediverseInstanceType InstanceType { get; set; }
public bool ForceRefresh { get; set; }
} }
public enum FediverseInstanceType public enum FediverseInstanceType

View file

@ -18,6 +18,7 @@ public class Member : BaseModel
{ {
public required string Name { get; set; } public required string Name { get; set; }
public string Sid { get; set; } = string.Empty; public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? Bio { get; set; } public string? Bio { get; set; }
public string? Avatar { get; set; } public string? Avatar { get; set; }

View file

@ -17,6 +17,7 @@ namespace Foxnouns.Backend.Database.Models;
public class PrideFlag : BaseModel public class PrideFlag : BaseModel
{ {
public required Snowflake UserId { get; init; } public required Snowflake UserId { get; init; }
public required string LegacyId { get; init; }
// A null hash means the flag hasn't been processed yet. // A null hash means the flag hasn't been processed yet.
public string? Hash { get; set; } public string? Hash { get; set; }

View file

@ -29,9 +29,12 @@ public class Report : BaseModel
public ReportStatus Status { get; set; } public ReportStatus Status { get; set; }
public ReportReason Reason { get; init; } public ReportReason Reason { get; init; }
public string? Context { get; init; }
public ReportTargetType TargetType { get; init; } public ReportTargetType TargetType { get; init; }
public string? TargetSnapshot { get; init; } public string? TargetSnapshot { get; init; }
public AuditLogEntry? AuditLogEntry { get; set; }
} }
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]

View file

@ -25,6 +25,7 @@ public class User : BaseModel
{ {
public required string Username { get; set; } public required string Username { get; set; }
public string Sid { get; set; } = string.Empty; public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? Bio { get; set; } public string? Bio { get; set; }
public string? MemberTitle { get; set; } public string? MemberTitle { get; set; }
@ -69,6 +70,8 @@ public class User : BaseModel
// This type is generally serialized directly, so the converter is applied here. // This type is generally serialized directly, so the converter is applied here.
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public PreferenceSize Size { get; set; } public PreferenceSize Size { get; set; }
public Guid LegacyId { get; init; } = Guid.NewGuid();
} }
public static readonly Duration DeleteAfter = Duration.FromDays(30); public static readonly Duration DeleteAfter = Duration.FromDays(30);

View file

@ -29,6 +29,7 @@ public record ReportResponse(
PartialMember? TargetMember, PartialMember? TargetMember,
ReportStatus Status, ReportStatus Status,
ReportReason Reason, ReportReason Reason,
string? Context,
ReportTargetType TargetType, ReportTargetType TargetType,
JObject? Snapshot JObject? Snapshot
); );
@ -57,7 +58,7 @@ public record NotificationResponse(
public record AuditLogEntity(Snowflake Id, string Username); public record AuditLogEntity(Snowflake Id, string Username);
public record CreateReportRequest(ReportReason Reason); public record CreateReportRequest(ReportReason Reason, string? Context = null);
public record IgnoreReportRequest(string? Reason = null); public record IgnoreReportRequest(string? Reason = null);

View file

@ -36,7 +36,7 @@ public record UserResponse(
IEnumerable<FieldEntry> Names, IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns, IEnumerable<Pronoun> Pronouns,
IEnumerable<Field> Fields, IEnumerable<Field> Fields,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences, Dictionary<Snowflake, CustomPreferenceResponse> CustomPreferences,
IEnumerable<PrideFlagResponse> Flags, IEnumerable<PrideFlagResponse> Flags,
int? UtcOffset, int? UtcOffset,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
@ -52,6 +52,14 @@ public record UserResponse(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted
); );
public record CustomPreferenceResponse(
string Icon,
string Tooltip,
bool Muted,
bool Favourite,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] PreferenceSize Size
);
public record AuthMethodResponse( public record AuthMethodResponse(
Snowflake Id, Snowflake Id,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,

View file

@ -0,0 +1,59 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
using Foxnouns.Backend.Database;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Dto.V1;
public record PartialMember(
string Id,
Snowflake IdNew,
string Sid,
string Name,
string? DisplayName,
string? Bio,
string? Avatar,
string[] Links,
FieldEntry[] Names,
PronounEntry[] Pronouns
);
public record MemberResponse(
string Id,
Snowflake IdNew,
string Sid,
string Name,
string? DisplayName,
string? Bio,
string? Avatar,
string[] Links,
FieldEntry[] Names,
PronounEntry[] Pronouns,
ProfileField[] Fields,
PrideFlag[] Flags,
PartialUser User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
);
public record PartialUser(
string Id,
Snowflake IdNew,
string Name,
string? DisplayName,
string? Avatar,
Dictionary<Guid, CustomPreference> CustomPreferences
);

View file

@ -0,0 +1,130 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services.V1;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using NodaTime;
namespace Foxnouns.Backend.Dto.V1;
public record UserResponse(
string Id,
Snowflake IdNew,
string Sid,
string Name,
string? DisplayName,
string? Bio,
string? MemberTitle,
string? Avatar,
string[] Links,
FieldEntry[] Names,
PronounEntry[] Pronouns,
ProfileField[] Fields,
PrideFlag[] Flags,
PartialMember[] Members,
int? UtcOffset,
Dictionary<Guid, CustomPreference> CustomPreferences
);
public record CurrentUserResponse(
string Id,
Snowflake IdNew,
string Sid,
string Name,
string? DisplayName,
string? Bio,
string? MemberTitle,
string? Avatar,
string[] Links,
FieldEntry[] Names,
PronounEntry[] Pronouns,
ProfileField[] Fields,
PrideFlag[] Flags,
PartialMember[] Members,
int? UtcOffset,
Dictionary<Guid, CustomPreference> CustomPreferences,
Instant CreatedAt,
string? Timezone,
bool IsAdmin,
bool ListPrivate,
Instant LastSidReroll,
string? Discord,
string? DiscordUsername,
string? Google,
string? GoogleUsername,
string? Tumblr,
string? TumblrUsername,
string? Fediverse,
string? FediverseUsername,
string? FediverseInstance
);
public record CustomPreference(
string Icon,
string Tooltip,
[property: JsonConverter(typeof(StringEnumConverter), typeof(SnakeCaseNamingStrategy))]
PreferenceSize Size,
bool Muted,
bool Favourite
);
public record ProfileField(string Name, FieldEntry[] Entries)
{
public static ProfileField FromField(
Field field,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) => new(field.Name, FieldEntry.FromEntries(field.Entries, customPreferences));
public static ProfileField[] FromFields(
IEnumerable<Field> fields,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) => fields.Select(f => FromField(f, customPreferences)).ToArray();
}
public record FieldEntry(string Value, string Status)
{
public static FieldEntry[] FromEntries(
IEnumerable<Foxnouns.Backend.Database.Models.FieldEntry> entries,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
entries
.Select(e => new FieldEntry(
e.Value,
V1Utils.TranslateStatus(e.Status, customPreferences)
))
.ToArray();
}
public record PronounEntry(string Pronouns, string? DisplayText, string Status)
{
public static PronounEntry[] FromPronouns(
IEnumerable<Pronoun> pronouns,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
pronouns
.Select(p => new PronounEntry(
p.Value,
p.DisplayText,
V1Utils.TranslateStatus(p.Status, customPreferences)
))
.ToArray();
}
public record PrideFlag(string Id, Snowflake IdNew, string Hash, string Name, string? Description);

View file

@ -19,6 +19,7 @@ using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Services.V1;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Minio; using Minio;
using NodaTime; using NodaTime;
@ -127,7 +128,10 @@ public static class WebApplicationExtensions
.AddTransient<MemberAvatarUpdateInvocable>() .AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>() .AddTransient<UserAvatarUpdateInvocable>()
.AddTransient<CreateFlagInvocable>() .AddTransient<CreateFlagInvocable>()
.AddTransient<CreateDataExportInvocable>(); .AddTransient<CreateDataExportInvocable>()
// Legacy services
.AddScoped<UsersV1Service>()
.AddScoped<MembersV1Service>();
if (!config.Logging.EnableMetrics) if (!config.Logging.EnableMetrics)
services.AddHostedService<BackgroundMetricsCollectionService>(); services.AddHostedService<BackgroundMetricsCollectionService>();

View file

@ -44,6 +44,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/>
<PackageReference Include="System.Text.Json" Version="9.0.0"/> <PackageReference Include="System.Text.Json" Version="9.0.0"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/> <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageReference Include="Yort.Xid.Net" Version="2.0.1"/>
</ItemGroup> </ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation"> <Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">

View file

@ -220,5 +220,5 @@ public class CreateDataExportInvocable(
} }
private static string ExportPath(Snowflake userId, string b64) => private static string ExportPath(Snowflake userId, string b64) =>
$"data-exports/{userId}/{b64}.zip"; $"data-exports/{userId}/{b64}/data-export.zip";
} }

View file

@ -41,7 +41,7 @@ public class LimitMiddleware : IMiddleware
return; return;
} }
if (token?.User.Deleted == true && !attribute.UsableBySuspendedUsers) if (token?.User.Deleted == true && !attribute.UsableByDeletedUsers)
throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin) if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin)
@ -62,7 +62,7 @@ public class LimitMiddleware : IMiddleware
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class LimitAttribute : Attribute public class LimitAttribute : Attribute
{ {
public bool UsableBySuspendedUsers { get; init; } public bool UsableByDeletedUsers { get; init; }
public bool RequireAdmin { get; init; } public bool RequireAdmin { get; init; }
public bool RequireModerator { get; init; } public bool RequireModerator { get; init; }
} }

View file

@ -20,6 +20,7 @@ using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using XidNet;
namespace Foxnouns.Backend.Services.Auth; namespace Foxnouns.Backend.Services.Auth;
@ -70,6 +71,7 @@ public class AuthService(
}, },
LastActive = clock.GetCurrentInstant(), LastActive = clock.GetCurrentInstant(),
Sid = null!, Sid = null!,
LegacyId = Xid.NewXid().ToString(),
}; };
db.Add(user); db.Add(user);
@ -116,6 +118,7 @@ public class AuthService(
}, },
LastActive = clock.GetCurrentInstant(), LastActive = clock.GetCurrentInstant(),
Sid = null!, Sid = null!,
LegacyId = Xid.NewXid().ToString(),
}; };
db.Add(user); db.Add(user);

View file

@ -58,7 +58,7 @@ public partial class FediverseAuthService
) )
{ {
FediverseApplication app = await GetApplicationAsync(instance); FediverseApplication app = await GetApplicationAsync(instance);
return await GenerateAuthUrlAsync(app, forceRefresh, state); return await GenerateAuthUrlAsync(app, forceRefresh || app.ForceRefresh, state);
} }
// thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives,

View file

@ -128,5 +128,5 @@ public class DataCleanupService(
} }
private static string ExportPath(Snowflake userId, string b64) => private static string ExportPath(Snowflake userId, string b64) =>
$"data-exports/{userId}/{b64}.zip"; $"data-exports/{userId}/{b64}/data-export.zip";
} }

View file

@ -36,6 +36,7 @@ public class ModerationRendererService(
: null, : null,
report.Status, report.Status,
report.Reason, report.Reason,
report.Context,
report.TargetType, report.TargetType,
report.TargetSnapshot != null report.TargetSnapshot != null
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot) ? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)

View file

@ -103,7 +103,8 @@ public class UserRendererService(
user.Names, user.Names,
user.Pronouns, user.Pronouns,
user.Fields, user.Fields,
user.CustomPreferences, user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value)))
.ToDictionary(),
flags.Select(f => RenderPrideFlag(f.PrideFlag)), flags.Select(f => RenderPrideFlag(f.PrideFlag)),
utcOffset, utcOffset,
user.Role, user.Role,
@ -130,6 +131,14 @@ public class UserRendererService(
: a.RemoteUsername : a.RemoteUsername
); );
public static CustomPreferenceResponse RenderCustomPreference(User.CustomPreference pref) =>
new(pref.Icon, pref.Tooltip, pref.Muted, pref.Favourite, pref.Size);
public static Dictionary<Snowflake, CustomPreferenceResponse> RenderCustomPreferences(
User user
) =>
user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value))).ToDictionary();
public PartialUser RenderPartialUser(User user) => public PartialUser RenderPartialUser(User user) =>
new( new(
user.Id, user.Id,

View file

@ -0,0 +1,125 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto.V1;
using Microsoft.EntityFrameworkCore;
using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry;
using PrideFlag = Foxnouns.Backend.Dto.V1.PrideFlag;
namespace Foxnouns.Backend.Services.V1;
public class MembersV1Service(DatabaseContext db, UsersV1Service usersV1Service)
{
public async Task<Member> ResolveMemberAsync(string id, CancellationToken ct = default)
{
Member? member;
if (Snowflake.TryParse(id, out Snowflake? sf))
{
member = await db
.Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Id == sf && !m.User.Deleted, ct);
if (member != null)
return member;
}
member = await db
.Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.LegacyId == id && !m.User.Deleted, ct);
if (member != null)
return member;
throw new ApiError.NotFound("No member with that ID found.", ErrorCode.MemberNotFound);
}
public async Task<Member> ResolveMemberAsync(
string userRef,
string memberRef,
Token? token,
CancellationToken ct = default
)
{
User user = await usersV1Service.ResolveUserAsync(userRef, token, ct);
Member? member;
if (Snowflake.TryParse(memberRef, out Snowflake? sf))
{
member = await db
.Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Id == sf && m.UserId == user.Id, ct);
if (member != null)
return member;
}
member = await db
.Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.LegacyId == memberRef && m.UserId == user.Id, ct);
if (member != null)
return member;
member = await db
.Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == user.Id, ct);
if (member != null)
return member;
throw new ApiError.NotFound(
"No member with that ID or name found.",
ErrorCode.MemberNotFound
);
}
public async Task<MemberResponse> RenderMemberAsync(
Member m,
Token? token = default,
User? user = null,
bool renderFlags = true,
CancellationToken ct = default
)
{
user ??= m.User;
bool renderUnlisted = m.UserId == token?.UserId;
List<MemberFlag> flags = renderFlags
? await db.MemberFlags.Where(f => f.MemberId == m.Id).OrderBy(f => f.Id).ToListAsync(ct)
: [];
return new MemberResponse(
m.LegacyId,
m.Id,
m.Sid,
m.Name,
m.DisplayName,
m.Bio,
m.Avatar,
m.Links,
Names: FieldEntry.FromEntries(m.Names, user.CustomPreferences),
Pronouns: PronounEntry.FromPronouns(m.Pronouns, user.CustomPreferences),
Fields: ProfileField.FromFields(m.Fields, user.CustomPreferences),
Flags: flags
.Where(f => f.PrideFlag.Hash != null)
.Select(f => new PrideFlag(
f.PrideFlag.LegacyId,
f.PrideFlag.Id,
f.PrideFlag.Hash!,
f.PrideFlag.Name,
f.PrideFlag.Description
))
.ToArray(),
User: UsersV1Service.RenderPartialUser(user),
Unlisted: renderUnlisted ? m.Unlisted : null
);
}
}

View file

@ -0,0 +1,247 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto.V1;
using Microsoft.EntityFrameworkCore;
using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry;
using PrideFlag = Foxnouns.Backend.Dto.V1.PrideFlag;
namespace Foxnouns.Backend.Services.V1;
public class UsersV1Service(DatabaseContext db)
{
public async Task<User> ResolveUserAsync(
string userRef,
Token? token,
CancellationToken ct = default
)
{
if (userRef == "@me")
{
if (token == null)
{
throw new ApiError.Unauthorized(
"This endpoint requires an authenticated user.",
ErrorCode.AuthenticationRequired
);
}
return await db.Users.FirstAsync(u => u.Id == token.UserId, ct);
}
User? user;
if (Snowflake.TryParse(userRef, out Snowflake? sf))
{
user = await db.Users.FirstOrDefaultAsync(u => u.Id == sf && !u.Deleted, ct);
if (user != null)
return user;
}
user = await db.Users.FirstOrDefaultAsync(u => u.LegacyId == userRef && !u.Deleted, ct);
if (user != null)
return user;
user = await db.Users.FirstOrDefaultAsync(u => u.Username == userRef && !u.Deleted, ct);
if (user != null)
return user;
throw new ApiError.NotFound(
"No user with that ID or username found.",
ErrorCode.UserNotFound
);
}
public async Task<UserResponse> RenderUserAsync(
User user,
Token? token = null,
bool renderMembers = true,
bool renderFlags = true,
CancellationToken ct = default
)
{
bool isSelfUser = user.Id == token?.UserId;
renderMembers = renderMembers && (isSelfUser || !user.ListHidden);
// Only fetch members if we're rendering members (duh)
List<Member> members = renderMembers
? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct)
: [];
List<UserFlag> flags = renderFlags
? await db.UserFlags.Where(f => f.UserId == user.Id).OrderBy(f => f.Id).ToListAsync(ct)
: [];
int? utcOffset = null;
if (
user.Timezone != null
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz)
)
{
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
}
return new UserResponse(
user.LegacyId,
user.Id,
user.Sid,
user.Username,
user.DisplayName,
user.Bio,
user.MemberTitle,
user.Avatar,
user.Links,
Names: FieldEntry.FromEntries(user.Names, user.CustomPreferences),
Pronouns: PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences),
Fields: ProfileField.FromFields(user.Fields, user.CustomPreferences),
Flags: flags
.Where(f => f.PrideFlag.Hash != null)
.Select(f => new PrideFlag(
f.PrideFlag.LegacyId,
f.PrideFlag.Id,
f.PrideFlag.Hash!,
f.PrideFlag.Name,
f.PrideFlag.Description
))
.ToArray(),
Members: members.Select(m => RenderPartialMember(m, user.CustomPreferences)).ToArray(),
utcOffset,
CustomPreferences: RenderCustomPreferences(user.CustomPreferences)
);
}
public async Task<CurrentUserResponse> RenderCurrentUserAsync(
User user,
CancellationToken ct = default
)
{
List<Member> members = await db
.Members.Where(m => m.UserId == user.Id)
.OrderBy(m => m.Name)
.ToListAsync(ct);
List<UserFlag> flags = await db
.UserFlags.Where(f => f.UserId == user.Id)
.OrderBy(f => f.Id)
.ToListAsync(ct);
int? utcOffset = null;
if (
user.Timezone != null
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz)
)
{
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
}
List<AuthMethod> authMethods = await db
.AuthMethods.Include(a => a.FediverseApplication)
.Where(a => a.UserId == user.Id)
.OrderBy(a => a.Id)
.ToListAsync(ct);
AuthMethod? discord = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Discord);
AuthMethod? google = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Google);
AuthMethod? tumblr = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Tumblr);
AuthMethod? fediverse = authMethods.FirstOrDefault(a => a.AuthType is AuthType.Fediverse);
return new CurrentUserResponse(
user.LegacyId,
user.Id,
user.Sid,
user.Username,
user.DisplayName,
user.Bio,
user.MemberTitle,
user.Avatar,
user.Links,
Names: FieldEntry.FromEntries(user.Names, user.CustomPreferences),
Pronouns: PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences),
Fields: ProfileField.FromFields(user.Fields, user.CustomPreferences),
Flags: flags
.Where(f => f.PrideFlag.Hash != null)
.Select(f => new PrideFlag(
f.PrideFlag.LegacyId,
f.PrideFlag.Id,
f.PrideFlag.Hash!,
f.PrideFlag.Name,
f.PrideFlag.Description
))
.ToArray(),
Members: members.Select(m => RenderPartialMember(m, user.CustomPreferences)).ToArray(),
utcOffset,
CustomPreferences: RenderCustomPreferences(user.CustomPreferences),
user.Id.Time,
user.Timezone,
user.Role is UserRole.Admin,
user.ListHidden,
user.LastSidReroll,
discord?.RemoteId,
discord?.RemoteUsername,
google?.RemoteId,
google?.RemoteUsername,
tumblr?.RemoteId,
tumblr?.RemoteUsername,
fediverse?.RemoteId,
fediverse?.RemoteUsername,
fediverse?.FediverseApplication?.Domain
);
}
private static Dictionary<Guid, CustomPreference> RenderCustomPreferences(
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
customPreferences
.Select(x =>
(
x.Value.LegacyId,
new CustomPreference(
x.Value.Icon,
x.Value.Tooltip,
x.Value.Size,
x.Value.Muted,
x.Value.Favourite
)
)
)
.ToDictionary();
private static PartialMember RenderPartialMember(
Member m,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
new(
m.LegacyId,
m.Id,
m.Sid,
m.Name,
m.DisplayName,
m.Bio,
m.Avatar,
m.Links,
Names: FieldEntry.FromEntries(m.Names, customPreferences),
Pronouns: PronounEntry.FromPronouns(m.Pronouns, customPreferences)
);
public static PartialUser RenderPartialUser(User user) =>
new(
user.LegacyId,
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
CustomPreferences: RenderCustomPreferences(user.CustomPreferences)
);
}

View file

@ -0,0 +1,34 @@
// 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;
namespace Foxnouns.Backend.Services.V1;
public static class V1Utils
{
public static string TranslateStatus(
string status,
Dictionary<Snowflake, User.CustomPreference> customPreferences
)
{
if (!Snowflake.TryParse(status, out Snowflake? sf))
return status;
return customPreferences.TryGetValue(sf.Value, out User.CustomPreference? cf)
? cf.LegacyId.ToString()
: "unknown";
}
}

View file

@ -196,6 +196,13 @@ public static partial class ValidationUtils
}; };
} }
public const int MaximumReportContextLength = 512;
public static ValidationError? ValidateReportContext(string? context) =>
context?.Length > MaximumReportContextLength
? ValidationError.GenericValidationError("Avatar is too large", null)
: null;
public const int MinimumPasswordLength = 12; public const int MinimumPasswordLength = 12;
public const int MaximumPasswordLength = 1024; public const int MaximumPasswordLength = 1024;

View file

@ -1,7 +1,7 @@
; The host the server will listen on ; The host the server will listen on
Host = localhost Host = localhost
; The port the server will listen on ; The port the server will listen on
Port = 5000 Port = 6000
; The base *external* URL ; The base *external* URL
BaseUrl = https://pronouns.localhost BaseUrl = https://pronouns.localhost
; The base URL for media, without a trailing slash. This must be publicly accessible. ; The base URL for media, without a trailing slash. This must be publicly accessible.

View file

@ -293,6 +293,12 @@
"System.Runtime": "4.3.1" "System.Runtime": "4.3.1"
} }
}, },
"Yort.Xid.Net": {
"type": "Direct",
"requested": "[2.0.1, )",
"resolved": "2.0.1",
"contentHash": "+3sNX7/RKSKheVuMz9jtWLazD+R4PXpx8va2d9SdDgvKOhETbEb0VYis8K/fD1qm/qOQT57LadToSpzReGMZlw=="
},
"BouncyCastle.Cryptography": { "BouncyCastle.Cryptography": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.5.0", "resolved": "2.5.0",

View file

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

View file

@ -6,6 +6,7 @@ using Foxnouns.Backend.Extensions;
using Foxnouns.DataMigrator.Models; using Foxnouns.DataMigrator.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Npgsql; using Npgsql;
using Serilog; using Serilog;
using Serilog.Sinks.SystemConsole.Themes; using Serilog.Sinks.SystemConsole.Themes;
@ -22,6 +23,12 @@ internal class Program
.WriteTo.Console(theme: AnsiConsoleTheme.Sixteen) .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen)
.CreateLogger(); .CreateLogger();
var minUserId = new Snowflake(0);
if (args.Length > 0)
minUserId = ulong.Parse(args[0]);
Log.Information("Starting migration from user ID {MinUserId}", minUserId);
Config config = Config config =
new ConfigurationBuilder() new ConfigurationBuilder()
.AddConfiguration() .AddConfiguration()
@ -35,11 +42,30 @@ internal class Program
await context.Database.MigrateAsync(); await context.Database.MigrateAsync();
Log.Information("Migrating applications"); Dictionary<int, Snowflake> appIds;
Dictionary<int, Snowflake> appIds = await MigrateAppsAsync(conn, context); if (minUserId == new Snowflake(0))
{
Log.Information("Migrating applications");
appIds = await MigrateAppsAsync(conn, context);
string appJson = JsonConvert.SerializeObject(appIds);
await File.WriteAllTextAsync("apps.json", appJson);
}
else
{
Log.Information(
"Not the first migration, reading application IDs from {Filename}",
"apps.json"
);
string appJson = await File.ReadAllTextAsync("apps.json");
appIds =
JsonConvert.DeserializeObject<Dictionary<int, Snowflake>>(appJson)
?? throw new Exception("invalid apps.json file");
}
Log.Information("Migrating users"); Log.Information("Migrating users");
List<GoUser> users = await Queries.GetUsersAsync(conn); List<GoUser> users = await Queries.GetUsersAsync(conn, minUserId);
List<GoUserField> userFields = await Queries.GetUserFieldsAsync(conn); List<GoUserField> userFields = await Queries.GetUserFieldsAsync(conn);
List<GoMemberField> memberFields = await Queries.GetMemberFieldsAsync(conn); List<GoMemberField> memberFields = await Queries.GetMemberFieldsAsync(conn);
List<GoPrideFlag> prideFlags = await Queries.GetUserFlagsAsync(conn); List<GoPrideFlag> prideFlags = await Queries.GetUserFlagsAsync(conn);
@ -70,6 +96,12 @@ internal class Program
await context.SaveChangesAsync(); await context.SaveChangesAsync();
Log.Information("Migration complete!"); Log.Information("Migration complete!");
Log.Information(
"Migrated {Count} users, last user was {UserId}. Complete? {Complete}",
users.Count,
users.Last().SnowflakeId,
users.Count != 1000
);
} }
private static async Task<Dictionary<int, Snowflake>> MigrateAppsAsync( private static async Task<Dictionary<int, Snowflake>> MigrateAppsAsync(
@ -92,6 +124,7 @@ internal class Program
ClientId = app.ClientId, ClientId = app.ClientId,
ClientSecret = app.ClientSecret, ClientSecret = app.ClientSecret,
InstanceType = app.TypeToEnum(), InstanceType = app.TypeToEnum(),
ForceRefresh = true,
} }
); );
} }

View file

@ -13,8 +13,13 @@ public static class Queries
public static async Task<List<GoFediverseApp>> GetFediverseAppsAsync(NpgsqlConnection conn) => public static async Task<List<GoFediverseApp>> GetFediverseAppsAsync(NpgsqlConnection conn) =>
(await conn.QueryAsync<GoFediverseApp>("select * from fediverse_apps")).ToList(); (await conn.QueryAsync<GoFediverseApp>("select * from fediverse_apps")).ToList();
public static async Task<List<GoUser>> GetUsersAsync(NpgsqlConnection conn) => public static async Task<List<GoUser>> GetUsersAsync(NpgsqlConnection conn, Snowflake minId) =>
(await conn.QueryAsync<GoUser>("select * from users order by id")).ToList(); (
await conn.QueryAsync<GoUser>(
"select * from users where snowflake_id > @Id order by snowflake_id limit 1000",
new { Id = minId.Value }
)
).ToList();
public static async Task<List<GoUserField>> GetUserFieldsAsync(NpgsqlConnection conn) => public static async Task<List<GoUserField>> GetUserFieldsAsync(NpgsqlConnection conn) =>
(await conn.QueryAsync<GoUserField>("select * from user_fields order by id")).ToList(); (await conn.QueryAsync<GoUserField>("select * from user_fields order by id")).ToList();

View file

@ -39,6 +39,7 @@ public class UserMigrator(
_user = new User _user = new User
{ {
Id = goUser.SnowflakeId, Id = goUser.SnowflakeId,
LegacyId = goUser.Id,
Username = goUser.Username, Username = goUser.Username,
DisplayName = goUser.DisplayName, DisplayName = goUser.DisplayName,
Bio = goUser.Bio, Bio = goUser.Bio,
@ -139,6 +140,7 @@ public class UserMigrator(
new PrideFlag new PrideFlag
{ {
Id = flag.SnowflakeId, Id = flag.SnowflakeId,
LegacyId = flag.Id,
UserId = _user!.Id, UserId = _user!.Id,
Hash = flag.Hash, Hash = flag.Hash,
Name = flag.Name, Name = flag.Name,
@ -190,6 +192,7 @@ public class UserMigrator(
UserId = _user!.Id, UserId = _user!.Id,
Name = goMember.Name, Name = goMember.Name,
Sid = goMember.Sid, Sid = goMember.Sid,
LegacyId = goMember.Id,
DisplayName = goMember.DisplayName, DisplayName = goMember.DisplayName,
Bio = goMember.Bio, Bio = goMember.Bio,
Avatar = goMember.Avatar, Avatar = goMember.Avatar,
@ -235,6 +238,7 @@ public class UserMigrator(
"small" => PreferenceSize.Small, "small" => PreferenceSize.Small,
_ => PreferenceSize.Normal, _ => PreferenceSize.Normal,
}, },
LegacyId = new Guid(id),
}; };
} }

View file

@ -1,7 +1,7 @@
# Example .env file--DO NOT EDIT # Example .env file--DO NOT EDIT, copy to .env or .env.local then edit
PUBLIC_LANGUAGE=en PUBLIC_LANGUAGE=en
PUBLIC_BASE_URL=https://pronouns.cc PUBLIC_BASE_URL=https://pronouns.cc
PUBLIC_SHORT_URL=https://prns.cc PUBLIC_SHORT_URL=https://prns.cc
PUBLIC_API_BASE=https://pronouns.cc/api PUBLIC_API_BASE=https://pronouns.cc/api
PRIVATE_API_HOST=http://localhost:5003/api PRIVATE_API_HOST=http://localhost:5003/api
PRIVATE_INTERNAL_API_HOST=http://localhost:5000/api PRIVATE_INTERNAL_API_HOST=http://localhost:6000/api

View file

@ -13,8 +13,8 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.10", "@sveltejs/adapter-node": "^5.2.10",
"@sveltejs/kit": "^2.11.1", "@sveltejs/kit": "^2.12.1",
"@sveltejs/vite-plugin-svelte": "^4.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.2",
"@sveltestrap/sveltestrap": "^6.2.7", "@sveltestrap/sveltestrap": "^6.2.7",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
@ -28,13 +28,13 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2", "prettier-plugin-svelte": "^3.3.2",
"sass": "^1.83.0", "sass": "^1.83.0",
"svelte": "^5.13.0", "svelte": "^5.14.3",
"svelte-bootstrap-icons": "^3.1.1", "svelte-bootstrap-icons": "^3.1.1",
"svelte-check": "^4.1.1", "svelte-check": "^4.1.1",
"sveltekit-i18n": "^2.4.2", "sveltekit-i18n": "^2.4.2",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.18.0", "typescript-eslint": "^8.18.1",
"vite": "^5.4.11" "vite": "^6.0.3"
}, },
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c", "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
"dependencies": { "dependencies": {

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,16 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
import type { ErrorCode } from "$api/error";
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
interface Error {
message: string;
status: number;
code: ErrorCode;
id: string;
}
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}

View file

@ -64,3 +64,11 @@
max-width: 200px; max-width: 200px;
border-radius: 3px; border-radius: 3px;
} }
.big-footer {
@media (prefers-color-scheme: dark) {
background-color: bootstrap.shade-color(bootstrap.$dark, 20%);
}
background-color: bootstrap.shade-color(bootstrap.$light, 5%);
}

View file

@ -1,6 +1,8 @@
import ApiError, { ErrorCode } from "$api/error";
import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private"; import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private";
import { PUBLIC_API_BASE } from "$env/static/public"; import { PUBLIC_API_BASE } from "$env/static/public";
import type { HandleFetch } from "@sveltejs/kit"; import log from "$lib/log";
import type { HandleFetch, HandleServerError } from "@sveltejs/kit";
export const handleFetch: HandleFetch = async ({ request, fetch }) => { export const handleFetch: HandleFetch = async ({ request, fetch }) => {
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) { if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
@ -11,3 +13,24 @@ export const handleFetch: HandleFetch = async ({ request, fetch }) => {
return await fetch(request); return await fetch(request);
}; };
export const handleError: HandleServerError = async ({ error, status, message }) => {
const id = crypto.randomUUID();
if (error instanceof ApiError) {
return {
id,
status: error.raw?.status || status,
message: error.raw?.message || "Unknown error",
code: error.code,
};
}
if (status >= 400 && status <= 499) {
return { id, status, message, code: ErrorCode.GenericApiError };
}
log.error("[%s] error in handler:", id, error);
return { id, status, message, code: ErrorCode.InternalServerError };
};

View file

@ -9,7 +9,7 @@ export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/** /**
* Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`. * Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`.
*/ */
export type RequestArgs = { export type RequestArgs<T> = {
/** /**
* The token for this request. Where possible, `cookies` should be passed instead. * The token for this request. Where possible, `cookies` should be passed instead.
* Will override `cookies` if both are passed. * Will override `cookies` if both are passed.
@ -23,7 +23,7 @@ export type RequestArgs = {
/** /**
* The body for this request, which will be serialized to JSON. Should be a plain JS object. * The body for this request, which will be serialized to JSON. Should be a plain JS object.
*/ */
body?: unknown; body?: T;
/** /**
* The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests. * The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests.
*/ */
@ -41,10 +41,10 @@ export type RequestArgs = {
* @param args Optional arguments to the request function. * @param args Optional arguments to the request function.
* @returns A Response object. * @returns A Response object.
*/ */
export async function baseRequest( export async function baseRequest<T = unknown>(
method: Method, method: Method,
path: string, path: string,
args: RequestArgs = {}, args: RequestArgs<T> = {},
): Promise<Response> { ): Promise<Response> {
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME); const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
@ -72,11 +72,11 @@ export async function baseRequest(
* @param args Optional arguments to the request function. * @param args Optional arguments to the request function.
* @returns The response deserialized as `T`. * @returns The response deserialized as `T`.
*/ */
export async function apiRequest<T>( export async function apiRequest<TResponse, TRequest = unknown>(
method: Method, method: Method,
path: string, path: string,
args: RequestArgs = {}, args: RequestArgs<TRequest> = {},
): Promise<T> { ): Promise<TResponse> {
const resp = await baseRequest(method, path, args); const resp = await baseRequest(method, path, args);
if (resp.status < 200 || resp.status > 299) { if (resp.status < 200 || resp.status > 299) {
@ -84,7 +84,7 @@ export async function apiRequest<T>(
if ("code" in err) throw new ApiError(err); if ("code" in err) throw new ApiError(err);
else throw new ApiError(); else throw new ApiError();
} }
return (await resp.json()) as T; return (await resp.json()) as TResponse;
} }
/** /**
@ -94,10 +94,10 @@ export async function apiRequest<T>(
* @param args Optional arguments to the request function. * @param args Optional arguments to the request function.
* @param enforce204 Whether to throw an error on a non-204 status code. * @param enforce204 Whether to throw an error on a non-204 status code.
*/ */
export async function fastRequest( export async function fastRequest<T = unknown>(
method: Method, method: Method,
path: string, path: string,
args: RequestArgs = {}, args: RequestArgs<T> = {},
enforce204: boolean = false, enforce204: boolean = false,
): Promise<void> { ): Promise<void> {
const resp = await baseRequest(method, path, args); const resp = await baseRequest(method, path, args);

View file

@ -0,0 +1,61 @@
import type { Member } from "./member";
import type { PartialMember, PartialUser, User } from "./user";
export type CreateReportRequest = {
reason: ReportReason;
context: string | null;
};
export enum ReportReason {
Totalitarianism = "TOTALITARIANISM",
HateSpeech = "HATE_SPEECH",
Racism = "RACISM",
Homophobia = "HOMOPHOBIA",
Transphobia = "TRANSPHOBIA",
Queerphobia = "QUEERPHOBIA",
Exclusionism = "EXCLUSIONISM",
Sexism = "SEXISM",
Ableism = "ABLEISM",
ChildPornography = "CHILD_PORNOGRAPHY",
PedophiliaAdvocacy = "PEDOPHILIA_ADVOCACY",
Harassment = "HARASSMENT",
Impersonation = "IMPERSONATION",
Doxxing = "DOXXING",
EncouragingSelfHarm = "ENCOURAGING_SELF_HARM",
Spam = "SPAM",
Trolling = "TROLLING",
Advertisement = "ADVERTISEMENT",
CopyrightViolation = "COPYRIGHT_VIOLATION",
}
export type Report = {
id: string;
reporter: PartialUser;
target_user: PartialUser;
target_member?: PartialMember;
status: "OPEN" | "CLOSED";
reason: ReportReason;
context: string | null;
target_type: "USER" | "MEMBER";
snapshot: User | Member | null;
};
export type AuditLogEntry = {
id: string;
moderator: AuditLogEntity;
target_user?: AuditLogEntity;
target_member?: AuditLogEntity;
report_id?: string;
type: AuditLogEntryType;
reason: string | null;
cleared_fields?: string[];
};
export type AuditLogEntity = { id: string; username: string };
export enum AuditLogEntryType {
IgnoreReport = "IGNORE_REPORT",
WarnUser = "WARN_USER",
WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
SuspendUser = "SUSPEND_USER",
}

View file

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

View file

@ -8,7 +8,7 @@
NavLink, NavLink,
NavItem, NavItem,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { page } from "$app/stores"; import { page } from "$app/state";
import type { Meta, MeUser } from "$api/models/index"; import type { Meta, MeUser } from "$api/models/index";
import Logo from "$components/Logo.svelte"; import Logo from "$components/Logo.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
@ -25,12 +25,14 @@
{#if user.suspended} {#if user.suspended}
<strong>{$t("nav.suspended-account-hint")}</strong> <strong>{$t("nav.suspended-account-hint")}</strong>
<br /> <br />
<a href="/contact">{$t("nav.appeal-suspension-link")}</a> <a href="/settings">{$t("nav.delete-permanently-link")}</a>
<a href="/contact">{$t("nav.appeal-suspension-link")}</a>
<a href="/settings/export">{$t("nav.export-link")}</a>
{:else} {:else}
<strong>{$t("nav.deleted-account-hint")}</strong> <strong>{$t("nav.deleted-account-hint")}</strong>
<br /> <br />
<a href="/settings/reactivate">{$t("nav.reactivate-account-link")}</a> <a href="/settings">{$t("nav.reactivate-or-delete-link")}</a>
<a href="/contact">{$t("nav.delete-permanently-link")}</a> <a href="/settings/export">{$t("nav.export-link")}</a>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -51,19 +53,26 @@
<NavItem> <NavItem>
<NavLink <NavLink
href="/@{user.username}" href="/@{user.username}"
active={$page.url.pathname.startsWith(`/@${user.username}`)} active={page.url.pathname.startsWith(`/@${user.username}`)}
> >
@{user.username} @{user.username}
</NavLink> </NavLink>
</NavItem> </NavItem>
{#if user.role === "ADMIN" || user.role === "MODERATOR"}
<NavItem>
<NavLink href="/admin" active={page.url.pathname.startsWith(`/admin`)}>
Administration
</NavLink>
</NavItem>
{/if}
<NavItem> <NavItem>
<NavLink href="/settings" active={$page.url.pathname.startsWith("/settings")}> <NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
{$t("nav.settings")} {$t("nav.settings")}
</NavLink> </NavLink>
</NavItem> </NavItem>
{:else} {:else}
<NavItem> <NavItem>
<NavLink href="/auth/log-in" active={$page.url.pathname === "/auth/log-in"}> <NavLink href="/auth/log-in" active={page.url.pathname === "/auth/log-in"}>
{$t("nav.log-in")} {$t("nav.log-in")}
</NavLink> </NavLink>
</NavItem> </NavItem>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { t } from "$lib/i18n";
type Props = { required?: boolean };
let { required }: Props = $props();
</script>
{#if required}
<small class="text-danger"><abbr title={$t("form.required")}>*</abbr></small>
{:else}
<small class="text-body-secondary">{$t("form.optional")}</small>
{/if}

View file

@ -0,0 +1,8 @@
<script lang="ts">
import type { AuditLogEntity } from "$api/models/moderation";
type Props = { entity: AuditLogEntity };
let { entity }: Props = $props();
</script>
<strong>{entity.username}</strong> <span class="text-secondary">({entity.id})</span>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { AuditLogEntry } from "$api/models/moderation";
import { idTimestamp } from "$lib";
import { renderMarkdown } from "$lib/markdown";
import { DateTime } from "luxon";
import AuditLogEntity from "./AuditLogEntity.svelte";
type Props = { entry: AuditLogEntry };
let { entry }: Props = $props();
let reason = $derived(renderMarkdown(entry.reason));
let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
</script>
<svelte:head>
<title>Audit log</title>
</svelte:head>
<div class="card my-1 p-2">
<h6 class="d-flex">
<span class="flex-grow-1">
<AuditLogEntity entity={entry.moderator} />
{#if entry.type === "IGNORE_REPORT"}
ignored a report
{:else if entry.type === "WARN_USER" || entry.type === "WARN_USER_AND_CLEAR_PROFILE"}
warned
{:else if entry.type === "SUSPEND_USER"}
suspended
{:else}
(unknown action <code>{entry.type}</code>)
{/if}
{#if entry.target_user}
<AuditLogEntity entity={entry.target_user} />
{/if}
{#if entry.target_member}
for member <AuditLogEntity entity={entry.target_member} />
{/if}
</span>
<small class="text-secondary">{date}</small>
</h6>
{#if reason}
<details>
<summary>Reason</summary>
{@html reason}
</details>
{:else}
<em>(no reason given)</em>
{/if}
</div>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Props = { title: string; onlyNumber?: boolean; children: Snippet };
let { title, onlyNumber = true, children }: Props = $props();
</script>
<div class="col-md">
<div class="card">
<div class="card-body">
<h5 class="card-title">{title}</h5>
<p class="card-text text-center" class:fs-1={onlyNumber}>
{@render children()}
</p>
</div>
</div>
</div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { type User, type Member, type CustomPreference } from "$api/models";
import StatusIcon from "$components/StatusIcon.svelte";
type Props = { profile: User | Member; allPreferences: Record<string, CustomPreference> };
let { profile, allPreferences }: Props = $props();
let preferences = $derived.by(() => {
let preferenceKeys = Object.keys(allPreferences).filter(
(pref) =>
profile.names.some((entry) => entry.status === pref) ||
profile.pronouns.some((entry) => entry.status === pref) ||
profile.fields.some((field) => field.entries.some((entry) => entry.status === pref)),
);
return preferenceKeys.map((pref) => allPreferences[pref]);
});
</script>
<div class="text-center text-body-secondary">
<ul class="list-inline">
{#each preferences as preference}
<li class="list-inline-item mx-2">
<StatusIcon {preference} />
{preference.tooltip}
</li>
{/each}
</ul>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import type { MeUser } from "$api/models";
import { PUBLIC_BASE_URL, PUBLIC_SHORT_URL } from "$env/static/public";
import { t } from "$lib/i18n";
type Props = {
user: string;
member?: string;
sid: string;
reportUrl: string;
meUser: MeUser | null;
};
let { user, member, sid, reportUrl, meUser }: Props = $props();
let profileUrl = $derived(
member ? `${PUBLIC_BASE_URL}/@${user}/${member}` : `${PUBLIC_BASE_URL}/@${user}`,
);
let shortUrl = $derived(`${PUBLIC_SHORT_URL}/${sid}`);
const copyUrl = async (url: string) => {
await navigator.clipboard.writeText(url);
};
</script>
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary" onclick={() => copyUrl(profileUrl)}>
{$t("profile.copy-link-button")}
</button>
<button type="button" class="btn btn-outline-secondary" onclick={() => copyUrl(shortUrl)}>
{$t("profile.copy-short-link-button")}
</button>
{#if meUser && meUser.username !== user}
<a class="btn btn-outline-danger" href={reportUrl}>{$t("profile.report-button")}</a>
{/if}
</div>

View file

@ -5,9 +5,11 @@
"settings": "Settings", "settings": "Settings",
"suspended-account-hint": "Your account has been suspended. Your profile has been hidden and you will not be able to change any settings.", "suspended-account-hint": "Your account has been suspended. Your profile has been hidden and you will not be able to change any settings.",
"appeal-suspension-link": "I want to appeal", "appeal-suspension-link": "I want to appeal",
"deleted-account-hint": "You have requested deletion of your account. If you want to reactivate it, click the link below.", "deleted-account-hint": "You have requested deletion of your account.",
"reactivate-account-link": "Reactivate account", "reactivate-account-link": "Reactivate account",
"delete-permanently-link": "I want my account deleted permanently" "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"
}, },
"avatar-tooltip": "Avatar for {{name}}", "avatar-tooltip": "Avatar for {{name}}",
"profile": { "profile": {
@ -18,7 +20,10 @@
"pronouns-header": "Pronouns", "pronouns-header": "Pronouns",
"default-members-header": "Members", "default-members-header": "Members",
"create-member-button": "Create member", "create-member-button": "Create member",
"back-to-user": "Back to {{name}}" "back-to-user": "Back to {{name}}",
"copy-link-button": "Copy link",
"copy-short-link-button": "Copy short link",
"report-button": "Report profile"
}, },
"title": { "title": {
"log-in": "Log in", "log-in": "Log in",
@ -152,7 +157,44 @@
"flag-description-placeholder": "Description", "flag-description-placeholder": "Description",
"flag-name-placeholder": "Name", "flag-name-placeholder": "Name",
"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved.", "flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved.",
"custom-preferences-title": "Custom preferences" "custom-preferences-title": "Custom preferences",
"change-username-header": "Change your username",
"force-delete-button": "Delete my account permanently",
"force-delete-warning": "This is irreversible. Consider exporting a copy of your data before doing this.",
"force-delete-explanation": "Your account is currently pending deletion. If you want your data deleted permanently, use the button below.",
"reactivate-explanation": "Your account is currently pending deletion. If you want to cancel this and keep using your account, use the link below.",
"reactivate-header": "Reactivate your account",
"force-delete-header": "Permanently delete your account",
"reactivate-button": "Reactivate my account",
"reactivated-header": "Account reactivated",
"reactivated-explanation": "Your account has been reactivated!",
"force-delete-input-label": "To delete your account, type your username (@{{username}}), including the @, in the box below:",
"force-delete-export-hint": "If you haven't done so yet, we recommend you download an export of your data before continuing:",
"force-delete-export-link": "export your data",
"force-delete-irreversible": "This process is irreversible.",
"force-delete-username-available": "Your username will immediately be available for other users to take.",
"force-delete-immediate-delete": "This will immediately delete all of your profiles, including avatars.",
"force-delete-page-explanation": "Your account is currently pending deletion. If you want all your data deleted immediately, you can do so here.",
"force-delete-page-header": "Permanently delete your account",
"force-delete-checkbox-label": "Yes, I understand that my data will be permanently deleted and cannot be recovered.",
"force-delete-page-button": "Delete my account",
"account-is-deleted-header": "Your account has been deleted",
"account-is-deleted-permanently-description": "Your account has been deleted. Note that it may take a few minutes for all of your data to be removed.",
"account-is-deleted-close-page": "You may now close this page.",
"soft-delete-button": "Deactivate your account",
"soft-delete-hint": "If you want to delete your account, use the button below.",
"soft-delete-header": "Deactivate your account",
"force-delete-page-cancel": "I changed my mind, cancel",
"soft-delete-page-header": "Deactivate your account",
"soft-delete-page-explanation": "If you want to delete your account, you can do so here.",
"soft-delete-90-days": "Your account will be permanently deleted after 90 days.",
"soft-delete-can-reactivate": "If you change your mind, you can log in and go to the settings page at any time to reactivate your account.",
"soft-delete-keep-username": "You will keep your current username until your account is permanently deleted.",
"soft-delete-can-delete-permanently": "If you want to delete all your data early, you can do so by logging in and going to the settings page.",
"soft-delete-page-button": "Deactivate my account",
"soft-delete-input-label": "To deactivate your account, type your username (@{{username}}), including the @, in the box below:",
"account-is-deactivated-header": "Your account has been deactivated",
"account-is-deactivated-description": "Your account has been deactivated, and will be deleted in 90 days. If you change your mind, just log in again, and you will have the option to reactivate your account. If you want to delete your data immediately, you should also log in again, and you will be able to request immediate deletion."
}, },
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
@ -237,5 +279,35 @@
"custom-preference-muted": "Show as muted text", "custom-preference-muted": "Show as muted text",
"custom-preference-favourite": "Treat like favourite" "custom-preference-favourite": "Treat like favourite"
}, },
"cancel": "Cancel" "cancel": "Cancel",
"report": {
"title": "Reporting {{name}}",
"totalitarianism": "Support of totalitarian regimes",
"hate-speech": "Hate speech",
"racism": "Racism or xenophobia",
"homophobia": "Homophobia",
"transphobia": "Transphobia",
"queerphobia": "Queerphobia (other)",
"exclusionism": "Queer or plural exclusionism",
"sexism": "Sexism or misogyny",
"ableism": "Ableism",
"child-pornography": "Child pornography",
"pedophilia-advocacy": "Pedophilia advocacy",
"harassment": "Harassment",
"impersonation": "Impersonation",
"doxxing": "Doxxing",
"encouraging-self-harm": "Encouraging self-harm or suicide",
"spam": "Spam",
"trolling": "Trolling",
"advertisement": "Advertising",
"copyright-violation": "Copyright or trademark violation",
"success": "Successfully submitted report!",
"reason-label": "Why are you reporting this profile?",
"context-label": "Is there any context you'd like to give us?",
"submit-button": "Submit report"
},
"form": {
"optional": "(optional)",
"required": "Required"
}
} }

View file

@ -7,11 +7,7 @@ const md = new MarkdownIt({
linkify: true, linkify: true,
}).disable(["heading", "lheading", "link", "table", "blockquote"]); }).disable(["heading", "lheading", "link", "table", "blockquote"]);
const unsafeMd = new MarkdownIt({ const unsafeMd = new MarkdownIt();
html: false,
breaks: true,
linkify: true,
});
export const renderMarkdown = (src: string | null) => (src ? sanitize(md.render(src)) : null); export const renderMarkdown = (src: string | null) => (src ? sanitize(md.render(src)) : null);

View file

@ -0,0 +1,10 @@
import { page } from "$app/state";
export const isActive = (path: string | string[], prefix: boolean = false) =>
typeof path === "string"
? prefix
? page.url.pathname.startsWith(path)
: page.url.pathname === path
: prefix
? path.some((p) => page.url.pathname.startsWith(p))
: path.some((p) => page.url.pathname === p);

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import Error from "$components/Error.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
@ -14,22 +15,8 @@
</svelte:head> </svelte:head>
<div class="container"> <div class="container">
<h3>{$t("title.an-error-occurred")}</h3> <Error {error} headerElem="h3" />
<p> <div class="btn-group mt-2">
<strong>{$page.status}</strong>: {error.message}
</p>
<p>
{#if $page.status === 400}
{$t("error.400-description")}
{:else if $page.status === 404}
{$t("error.404-description")}
{:else if $page.status === 500}
{$t("error.500-description")}
{:else}
{$t("error.unknown-status-description")}
{/if}
</p>
<div class="btn-group">
{#if data.meUser} {#if data.meUser}
<a class="btn btn-primary" href="/@{data.meUser.username}"> <a class="btn btn-primary" href="/@{data.meUser.username}">
{$t("error.back-to-profile-button")} {$t("error.back-to-profile-button")}

View file

@ -3,11 +3,16 @@
import "../app.scss"; import "../app.scss";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import Navbar from "$components/Navbar.svelte"; import Navbar from "$components/Navbar.svelte";
import Footer from "$components/Footer.svelte";
type Props = { children: Snippet; data: LayoutData }; type Props = { children: Snippet; data: LayoutData };
let { children, data }: Props = $props(); let { children, data }: Props = $props();
</script> </script>
<Navbar user={data.meUser} meta={data.meta} /> <div class="d-flex flex-column min-vh-100">
<div class="flex-grow-1">
{@render children?.()} <Navbar user={data.meUser} meta={data.meta} />
{@render children?.()}
</div>
<Footer meta={data.meta} />
</div>

View file

@ -1,25 +1,14 @@
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error.js";
import type { UserWithMembers } from "$api/models"; import type { UserWithMembers } from "$api/models";
import log from "$lib/log.js";
import paginate from "$lib/paginate"; import paginate from "$lib/paginate";
import { error } from "@sveltejs/kit";
const MEMBERS_PER_PAGE = 20; const MEMBERS_PER_PAGE = 20;
export const load = async ({ params, fetch, cookies, url }) => { export const load = async ({ params, fetch, cookies, url }) => {
let user: UserWithMembers; const user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, {
fetch,
try { cookies,
user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, { });
fetch,
cookies,
});
} catch (e) {
if (e instanceof ApiError && e.code === ErrorCode.UserNotFound) error(404, "User not found");
log.error("Error fetching user %s:", params.username, e);
throw e;
}
const { data, currentPage, pageCount } = paginate( const { data, currentPage, pageCount } = paginate(
user.members, user.members,

View file

@ -8,6 +8,8 @@
import { Icon } from "@sveltestrap/sveltestrap"; import { Icon } from "@sveltestrap/sveltestrap";
import Paginator from "$components/Paginator.svelte"; import Paginator from "$components/Paginator.svelte";
import MemberCard from "$components/profile/user/MemberCard.svelte"; import MemberCard from "$components/profile/user/MemberCard.svelte";
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
import PreferenceCheatsheet from "$components/profile/PreferenceCheatsheet.svelte";
type Props = { data: PageData }; type Props = { data: PageData };
let { data }: Props = $props(); let { data }: Props = $props();
@ -27,6 +29,13 @@
<ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} /> <ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} />
<ProfileFields profile={data.user} {allPreferences} /> <ProfileFields profile={data.user} {allPreferences} />
<PreferenceCheatsheet profile={data.user} {allPreferences} />
<ProfileButtons
meUser={data.meUser}
user={data.user.username}
sid={data.user.sid}
reportUrl="/report/{data.user.id}"
/>
{#if data.members.length > 0} {#if data.members.length > 0}
<hr /> <hr />

View file

@ -1,28 +1,15 @@
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error.js";
import type { Member } from "$api/models/member"; import type { Member } from "$api/models/member";
import log from "$lib/log.js";
import { error } from "@sveltejs/kit";
export const load = async ({ params, fetch, cookies }) => { export const load = async ({ params, fetch, cookies }) => {
try { const member = await apiRequest<Member>(
const member = await apiRequest<Member>( "GET",
"GET", `/users/${params.username}/members/${params.memberName}`,
`/users/${params.username}/members/${params.memberName}`, {
{ fetch,
fetch, cookies,
cookies, },
}, );
);
return { member }; return { member };
} catch (e) {
if (e instanceof ApiError) {
if (e.code === ErrorCode.UserNotFound) error(404, "User not found");
if (e.code === ErrorCode.MemberNotFound) error(404, "Member not found");
}
log.error("Error fetching user %s/member %s:", params.username, params.memberName, e);
throw e;
}
}; };

View file

@ -6,6 +6,8 @@
import ProfileFields from "$components/profile/ProfileFields.svelte"; import ProfileFields from "$components/profile/ProfileFields.svelte";
import { Icon } from "@sveltestrap/sveltestrap"; import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
import PreferenceCheatsheet from "$components/profile/PreferenceCheatsheet.svelte";
type Props = { data: PageData }; type Props = { data: PageData };
let { data }: Props = $props(); let { data }: Props = $props();
@ -37,4 +39,12 @@
<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} /> <ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} />
<ProfileFields profile={data.member} {allPreferences} /> <ProfileFields profile={data.member} {allPreferences} />
<PreferenceCheatsheet profile={data.member} {allPreferences} />
<ProfileButtons
meUser={data.meUser}
user={data.member.user.username}
member={data.member.name}
sid={data.member.sid}
reportUrl="/report/{data.member.user.id}?member={data.member.id}"
/>
</div> </div>

View file

@ -0,0 +1,30 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import type { Report } from "$api/models/moderation";
import { idTimestamp } from "$lib";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent, fetch, cookies }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/");
if (meUser.role !== "ADMIN" && meUser.role !== "MODERATOR") {
throw new ApiError({
status: 403,
code: ErrorCode.Forbidden,
message: "Only admins and moderators can use this page.",
});
}
const reports = await apiRequest<Report[]>("GET", "/moderation/reports", { fetch, cookies });
const staleReportCount = reports.filter(
(r) => idTimestamp(r.id).diffNow(["days"]).days >= 7,
).length;
return {
user: meUser,
isAdmin: meUser.role === "ADMIN",
reportCount: reports.length,
staleReportCount,
};
};

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { LayoutData } from "./$types";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props();
</script>
<div class="container">
<div class="row">
<div class="col-md-3 mt-1 mb-3">
<div class="list-group">
<a
href="/admin"
class="list-group-item list-group-item-action"
class:active={isActive("/admin")}
>
Dashboard
</a>
<a
href="/admin/reports"
class="list-group-item list-group-item-action"
class:active={isActive("/admin/reports", true)}
>
Reports
{#if data.reportCount}
<span
class="badge"
class:text-bg-danger={data.reportCount >= 10}
class:text-bg-secondary={data.reportCount < 10}
>
{data.reportCount >= 100 ? "99+" : data.reportCount.toString()}
</span>
{/if}
</a>
<a
href="/admin/audit-log"
class="list-group-item list-group-item-action"
class:active={isActive("/admin/audit-log", true)}
>
Audit log
</a>
</div>
</div>
<div class="col-md-9">
{@render children?.()}
</div>
</div>
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import DashboardCard from "$components/admin/DashboardCard.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
</script>
<h1>Dashboard</h1>
<div class="row gx-3 gy-3">
<DashboardCard title="Users" onlyNumber={false}>
<span class="fs-1">{data.meta.users.total.toLocaleString("en")}</span>
<br />
<small>({data.meta.users.active_month.toLocaleString("en")} active in the last month)</small>
</DashboardCard>
<DashboardCard title="Members">{data.meta.members.toLocaleString("en")}</DashboardCard>
<DashboardCard title="Open reports" onlyNumber={false}>
<span class="fs-1">{data.reportCount.toLocaleString("en")}</span>
<br />
<small>({data.staleReportCount} older than 1 week)</small>
</DashboardCard>
</div>

View file

@ -0,0 +1,38 @@
import { apiRequest } from "$api";
import { type AuditLogEntity, type AuditLogEntry } from "$api/models/moderation.js";
export const load = async ({ url, fetch, cookies }) => {
const type = url.searchParams.get("type");
const before = url.searchParams.get("before");
const after = url.searchParams.get("after");
const byModerator = url.searchParams.get("by-moderator");
let limit: number = 100;
if (url.searchParams.has("limit")) limit = parseInt(url.searchParams.get("limit")!);
const params = new URLSearchParams();
params.set("limit", limit.toString());
if (type) params.set("type", type);
if (before) params.set("before", before);
if (after) params.set("after", after);
if (byModerator) params.set("by-moderator", byModerator);
const entries = await apiRequest<AuditLogEntry[]>(
"GET",
`/moderation/audit-log?${params.toString()}`,
{
fetch,
cookies,
},
);
const moderators = await apiRequest<AuditLogEntity[]>("GET", "/moderation/audit-log/moderators", {
fetch,
cookies,
});
let modFilter: AuditLogEntity | null = null;
if (byModerator)
modFilter = entries.find((e) => e.moderator.id === byModerator)?.moderator || null;
return { entries, type, before, after, modFilter, url: url.toString(), moderators };
};

View file

@ -0,0 +1,105 @@
<script lang="ts">
import type { AuditLogEntity } from "$api/models/moderation";
import AuditLogEntryCard from "$components/admin/AuditLogEntryCard.svelte";
import {
ButtonDropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
const addTypeFilter = (type: string | null) => {
const url = new URL(data.url);
if (type) url.searchParams.set("type", type);
else url.searchParams.delete("type");
return url.toString();
};
const addModerator = (mod: AuditLogEntity | null) => {
const url = new URL(data.url);
if (mod) url.searchParams.set("by-moderator", mod.id);
else url.searchParams.delete("by-moderator");
return url.toString();
};
const addBefore = (id: string) => {
const url = new URL(data.url);
url.searchParams.delete("after");
url.searchParams.set("before", id);
return url.toString();
};
const addAfter = (id: string) => {
const url = new URL(data.url);
url.searchParams.delete("before");
url.searchParams.set("after", id);
return url.toString();
};
</script>
<h1>Audit log</h1>
<div class="btn-group">
<ButtonDropdown>
<DropdownToggle color="secondary" outline caret active={!!data.type}>
Filter by type
</DropdownToggle>
<DropdownMenu>
<DropdownItem href={addTypeFilter("IgnoreReport")} active={data.type === "IgnoreReport"}>
Ignore report
</DropdownItem>
<DropdownItem href={addTypeFilter("WarnUser")} active={data.type === "WarnUser"}>
Warn user
</DropdownItem>
<DropdownItem
href={addTypeFilter("WarnUserAndClearProfile")}
active={data.type === "WarnUserAndClearProfile"}
>
Warn user and clear profile
</DropdownItem>
<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
Suspend user
</DropdownItem>
{#if data.type}
<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
{/if}
</DropdownMenu>
</ButtonDropdown>
<ButtonDropdown>
<DropdownToggle color="secondary" outline caret active={!!data.modFilter}>
Filter by moderator
</DropdownToggle>
<DropdownMenu>
{#each data.moderators as mod (mod.id)}
<DropdownItem href={addModerator(mod)} active={data.modFilter?.id === mod.id}>
{mod.username}
</DropdownItem>
{/each}
{#if data.modFilter}
<DropdownItem href={addModerator(null)}>Remove filter</DropdownItem>
{/if}
</DropdownMenu>
</ButtonDropdown>
</div>
{#if data.before}
<a href={addAfter(data.before)}>Show newer entries</a>
{/if}
{#each data.entries as entry (entry.id)}
<AuditLogEntryCard {entry} />
{:else}
<p class="text-secondary m-3">There are no entries matching your filter</p>
{/each}
{#if data.entries.length === 100}
<a href={addBefore(data.entries[data.entries.length - 1].id)}>Show older entries</a>
{/if}

View file

@ -0,0 +1,14 @@
import { baseRequest } from "$api";
import ApiError from "$api/error";
export const load = async ({ fetch, params }) => {
const resp = await baseRequest("GET", `/meta/page/${params.page}`, { fetch });
if (resp.status < 200 || resp.status > 299) {
const err = await resp.json();
if ("code" in err) throw new ApiError(err);
else throw new ApiError();
}
const pageText = await resp.text();
return { page: params.page, text: pageText };
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { renderUnsafeMarkdown } from "$lib/markdown";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
let md = $derived(renderUnsafeMarkdown(data.text));
let title = $derived.by(() => {
let title = data.text.split("\n")[0];
if (title.startsWith("# ")) title = title.substring("# ".length);
return title;
});
</script>
<svelte:head>
<title>{title} • pronouns.cc</title>
</svelte:head>
<div class="container">
{@html md}
</div>

View file

@ -0,0 +1,60 @@
import { apiRequest, fastRequest } from "$api";
import ApiError from "$api/error.js";
import type { Member } from "$api/models/member.js";
import { type CreateReportRequest, ReportReason } from "$api/models/moderation.js";
import type { PartialUser, User } from "$api/models/user.js";
import log from "$lib/log.js";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent, params, fetch, cookies, url }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/");
let user: PartialUser;
let member: Member | null = null;
if (url.searchParams.has("member")) {
const resp = await apiRequest<Member>(
"GET",
`/users/${params.id}/members/${url.searchParams.get("member")}`,
{ fetch, cookies },
);
user = resp.user;
member = resp;
} else {
user = await apiRequest<User>("GET", `/users/${params.id}`, { fetch, cookies });
}
if (meUser.id === user.id) redirect(303, "/");
return { user, member };
};
export const actions = {
default: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const targetIsMember = body.get("target-type") === "member";
const target = body.get("target-id") as string;
const reason = body.get("reason") as ReportReason;
const context = body.get("context") as string | null;
const url = targetIsMember
? `/moderation/report-member/${target}`
: `/moderation/report-user/${target}`;
try {
await fastRequest<CreateReportRequest>("POST", url, {
body: { reason, context },
fetch,
cookies,
});
return { ok: true, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj };
log.error("error reporting user or member %s:", target, e);
throw e;
}
},
};

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { ReportReason } from "$api/models/moderation";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import RequiredFieldMarker from "$components/RequiredFieldMarker.svelte";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let name = $derived(
data.member ? `${data.member.name} (@${data.user.username})` : "@" + data.user.username,
);
let link = $derived(
data.member ? `/@${data.user.username}/${data.member.name}` : `/@${data.user.username}`,
);
let reasons = $derived.by(() => {
const reasons = [];
for (const value of Object.values(ReportReason)) {
const key = "report." + value.toLowerCase().replaceAll("_", "-");
reasons.push({ key, value });
}
return reasons;
});
</script>
<svelte:head>
<title>{$t("report.title", { name })} • pronouns.cc</title>
</svelte:head>
<div class="container">
<form method="POST" class="w-lg-75 mx-auto">
<h3>{$t("report.title", { name })}</h3>
<FormStatusMarker {form} successMessage={$t("report.success")} />
<input type="hidden" name="target-type" value={data.member ? "member" : "user"} />
<input type="hidden" name="target-id" value={data.member ? data.member.id : data.user.id} />
<h4 class="mt-3">{$t("report.reason-label")} <RequiredFieldMarker required /></h4>
<div class="row row-cols-1 row-cols-lg-2">
{#each reasons as reason}
<div class="col">
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="reason"
value={reason.value}
id="reason-{reason.value}"
required
/>
<label class="form-check-label" for="reason-{reason.value}">{$t(reason.key)}</label>
</div>
</div>
{/each}
</div>
<h4 class="mt-3">
{$t("report.context-label")}
<RequiredFieldMarker />
</h4>
<textarea class="form-control" name="context" style="height: 100px;" maxlength={512}></textarea>
<div class="mt-3">
<button type="submit" class="btn btn-danger">{$t("report.submit-button")}</button>
<a href={link} class="btn btn-secondary">{$t("cancel")}</a>
</div>
</form>
</div>

View file

@ -1,20 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { page } from "$app/stores";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { Nav, NavLink } from "@sveltestrap/sveltestrap"; import { Nav, NavLink } from "@sveltestrap/sveltestrap";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { children: Snippet }; type Props = { children: Snippet };
let { children }: Props = $props(); let { children }: Props = $props();
const isActive = (path: string | string[], prefix: boolean = false) =>
typeof path === "string"
? prefix
? $page.url.pathname.startsWith(path)
: $page.url.pathname === path
: prefix
? path.some((p) => $page.url.pathname.startsWith(p))
: path.some((p) => $page.url.pathname === p);
</script> </script>
<svelte:head> <svelte:head>

View file

@ -18,9 +18,37 @@
<h3>{$t("settings.general-information-tab")}</h3> <h3>{$t("settings.general-information-tab")}</h3>
{#if data.user.deleted}
<div class="row mb-3">
{#if !data.user.suspended}
<div class="col-md">
<h4>{$t("settings.reactivate-header")}</h4>
<p>
{$t("settings.reactivate-explanation")}
</p>
<a href="/settings/reactivate" class="btn btn-success">
{$t("settings.reactivate-button")}
</a>
</div>
{/if}
<div class="col-md">
<h4>{$t("settings.force-delete-header")}</h4>
<p>
{$t("settings.force-delete-explanation")}
<strong>
{$t("settings.force-delete-warning")}
</strong>
</p>
<a href="/settings/force-delete" class="btn btn-danger">
{$t("settings.force-delete-button")}
</a>
</div>
</div>
{/if}
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-9"> <div class="col-md-9">
<h5>Change your username</h5> <h5>{$t("settings.change-username-header")}</h5>
<form method="POST" action="?/changeUsername" use:enhance> <form method="POST" action="?/changeUsername" use:enhance>
<FormGroup class="mb-3"> <FormGroup class="mb-3">
<InputGroup class="m-1 mt-3 w-md-75"> <InputGroup class="m-1 mt-3 w-md-75">
@ -80,6 +108,14 @@
<a class="btn btn-danger" href="/settings/force-log-out">{$t("settings.force-log-out-button")}</a> <a class="btn btn-danger" href="/settings/force-log-out">{$t("settings.force-log-out-button")}</a>
</div> </div>
{#if !data.user.deleted}
<div class="mb-3">
<h4>{$t("settings.soft-delete-header")}</h4>
<p>{$t("settings.soft-delete-hint")}</p>
<a href="/settings/delete" class="btn btn-danger">{$t("settings.soft-delete-button")}</a>
</div>
{/if}
<div> <div>
<h4>{$t("settings.table-title")}</h4> <h4>{$t("settings.table-title")}</h4>

View file

@ -0,0 +1,41 @@
import { fastRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import { clearToken } from "$lib";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/");
if (meUser.deleted)
throw new ApiError({
message: "You cannot use this page.",
status: 403,
code: ErrorCode.Forbidden,
});
return { user: meUser! };
};
export const actions = {
default: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const username = body.get("username") as string;
const currentUsername = body.get("current-username") as string;
if (!username || username !== currentUsername) {
return {
ok: false,
error: {
message: "Username doesn't match your username.",
status: 400,
code: ErrorCode.BadRequest,
} as RawApiError,
};
}
await fastRequest("POST", "/self-delete/delete", { fetch, cookies, isInternal: true });
clearToken(cookies);
redirect(303, "/settings/delete/success");
},
};

View file

@ -0,0 +1,55 @@
<script lang="ts">
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("settings.soft-delete-page-header")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="w-lg-75 mx-auto">
<h3>{$t("settings.soft-delete-page-header")}</h3>
<p>
{$t("settings.soft-delete-page-explanation")}
</p>
<ul>
<li>{$t("settings.soft-delete-90-days")}</li>
<li>
{$t("settings.soft-delete-can-reactivate")}
</li>
<li>{$t("settings.soft-delete-keep-username")}</li>
<li>
{$t("settings.soft-delete-can-delete-permanently")}
</li>
</ul>
<form method="POST">
<FormStatusMarker {form} />
<p>
{$t("settings.soft-delete-input-label", { username: data.user.username })}
<input
class="form-control mt-2"
type="text"
name="username"
required
placeholder="@{data.user.username}"
autocomplete="off"
/>
<input type="hidden" value="@{data.user.username}" readonly name="current-username" />
</p>
<div class="btn-group mb-2">
<button type="submit" class="btn btn-danger">
{$t("settings.soft-delete-page-button")}
</button>
<a href="/settings" class="btn btn-secondary">{$t("settings.force-delete-page-cancel")}</a>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { t } from "$lib/i18n";
</script>
<svelte:head>
<title>{$t("settings.soft-delete-page-header")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="w-lg-75 mx-auto">
<h3>{$t("settings.account-is-deactivated-header")}</h3>
<p>
{$t("settings.account-is-deactivated-description")}
</p>
<p>{$t("settings.account-is-deleted-close-page")}</p>
<p>
<a href="/" class="btn btn-secondary">{$t("error.back-to-main-page-button")}</a>
</p>
</div>
</div>

View file

@ -48,7 +48,6 @@
const idx = flags.findIndex((f) => f.id === id); const idx = flags.findIndex((f) => f.id === id);
if (idx === -1) return; if (idx === -1) return;
console.log("yippee");
flags[idx] = { ...flags[idx], name, description }; flags[idx] = { ...flags[idx], name, description };
} catch (e) { } catch (e) {
log.error("Could not update flag %s:", id, e); log.error("Could not update flag %s:", id, e);

View file

@ -0,0 +1,53 @@
import { fastRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import { clearToken } from "$lib";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/");
if (!meUser.deleted)
throw new ApiError({
message: "You cannot use this page.",
status: 403,
code: ErrorCode.Forbidden,
});
return { user: meUser! };
};
export const actions = {
default: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const username = body.get("username") as string;
const currentUsername = body.get("current-username") as string;
const confirmed = !!body.get("confirm");
if (!username || username !== currentUsername) {
return {
ok: false,
error: {
message: "Username doesn't match your username.",
status: 400,
code: ErrorCode.BadRequest,
} as RawApiError,
};
}
if (!confirmed) {
return {
ok: false,
error: {
message: "You must check the box to continue.",
status: 400,
code: ErrorCode.BadRequest,
} as RawApiError,
};
}
await fastRequest("POST", "/self-delete/force", { fetch, cookies, isInternal: true });
clearToken(cookies);
redirect(303, "/settings/force-delete/success");
},
};

View file

@ -0,0 +1,68 @@
<script lang="ts">
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("settings.force-delete-page-header")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="w-lg-75 mx-auto">
<h3>{$t("settings.force-delete-page-header")}</h3>
<p>
{$t("settings.force-delete-page-explanation")}
</p>
<ul>
<li>{$t("settings.force-delete-immediate-delete")}</li>
<li>{$t("settings.force-delete-username-available")}</li>
<li><strong>{$t("settings.force-delete-irreversible")}</strong></li>
</ul>
<p>
{$t("settings.force-delete-export-hint")}
<a href="/settings/export">{$t("settings.force-delete-export-link")}</a>
</p>
<form method="POST">
<FormStatusMarker {form} />
<p>
{$t("settings.force-delete-input-label", { username: data.user.username })}
<input
class="form-control mt-2"
type="text"
name="username"
required
placeholder="@{data.user.username}"
autocomplete="off"
/>
<input type="hidden" value="@{data.user.username}" readonly name="current-username" />
</p>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="yes"
name="confirm"
id="confirm"
required
/>
<label class="form-check-label" for="confirm">
{$t("settings.force-delete-checkbox-label")}
</label>
</div>
<div class="btn-group mt-3 mb-2">
<button type="submit" class="btn btn-danger">
{$t("settings.force-delete-page-button")}
</button>
<a href="/settings" class="btn btn-secondary">{$t("settings.force-delete-page-cancel")}</a>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { t } from "$lib/i18n";
</script>
<svelte:head>
<title>{$t("settings.force-delete-page-header")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="w-lg-75 mx-auto">
<h3>{$t("settings.account-is-deleted-header")}</h3>
<p>
{$t("settings.account-is-deleted-permanently-description")}
</p>
<p>{$t("settings.account-is-deleted-close-page")}</p>
<p>
<a href="/" class="btn btn-secondary">{$t("error.back-to-main-page-button")}</a>
</p>
</div>
</div>

View file

@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { page } from "$app/stores";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { data: LayoutData; children: Snippet }; type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props(); let { data, children }: Props = $props();
const isActive = (path: string) => $page.url.pathname === path;
let name = $derived( let name = $derived(
data.member.display_name === data.member.name data.member.display_name === data.member.name
? data.member.name ? data.member.name

View file

@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { page } from "$app/stores";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { data: LayoutData; children: Snippet }; type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props(); let { data, children }: Props = $props();
const isActive = (path: string) => $page.url.pathname === path;
</script> </script>
<svelte:head> <svelte:head>

View file

@ -0,0 +1,23 @@
import { fastRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent, fetch, cookies }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/");
if (meUser.suspended || !meUser.deleted)
throw new ApiError({
message: "You cannot use this page.",
status: 403,
code: ErrorCode.Forbidden,
});
await fastRequest("POST", "/self-delete/undelete", {
fetch,
cookies,
isInternal: true,
});
return { user: meUser! };
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { t } from "$lib/i18n";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
</script>
<div class="container">
<div class="w-lg-75 mx-auto">
<h3>{$t("settings.reactivated-header")}</h3>
<p>{$t("settings.reactivated-explanation")}</p>
<div class="btn-group">
<a href="/settings" class="btn btn-primary">{$t("edit-profile.back-to-settings-tab")}</a>
<a href="/@{data.user.username}" class="btn btn-secondary">
{$t("error.back-to-profile-button")}
</a>
</div>
</div>
</div>

View file

@ -16,6 +16,7 @@ services:
- "5007:5001" - "5007:5001"
volumes: volumes:
- ./docker/config.ini:/app/config.ini - ./docker/config.ini:/app/config.ini
- ./docker/static-pages:/app/static-pages
frontend: frontend:
image: frontend image: frontend

2
docker/static-pages/.gitignore vendored Normal file
View file

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

View file

@ -38,7 +38,7 @@ func (hn *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
// all public api endpoints are prefixed with this // all public api endpoints are prefixed with this
if !strings.HasPrefix(r.URL.Path, "/api/v2") { if !strings.HasPrefix(r.URL.Path, "/api/v2") && !strings.HasPrefix(r.URL.Path, "/api/v1") {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }

View file

@ -1,6 +1,6 @@
{ {
"port": 5003, "port": 5003,
"proxy_target": "http://localhost:5000", "proxy_target": "http://localhost:6000",
"debug": true, "debug": true,
"powered_by": "5 gay rats" "powered_by": "5 gay rats"
} }