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

@ -21,3 +21,4 @@
**/values.dev.yaml
LICENSE
README.md
static-pages/*

4
.gitignore vendored
View file

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

View file

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

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")]
[Authorize("identify")]
[Limit(UsableByDeletedUsers = true)]
[ApiExplorerSettings(IgnoreApi = true)]
public class ExportsController(
ILogger logger,
@ -57,7 +58,7 @@ public class ExportsController(
}
private string ExportUrl(Snowflake userId, string filename) =>
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip";
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}/data-export.zip";
[HttpPost]
public async Task<IActionResult> QueueDataExportAsync()

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Text.RegularExpressions;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
@ -19,7 +20,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/meta")]
public class MetaController : ApiControllerBase
public partial class MetaController : ApiControllerBase
{
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")]
public IActionResult BrewCoffee() =>
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(
[FromQuery] AuditLogEntryType? type = null,
[FromQuery] int? limit = null,
[FromQuery] Snowflake? before = null
[FromQuery] Snowflake? before = null,
[FromQuery] Snowflake? after = null,
[FromQuery(Name = "by-moderator")] Snowflake? byModerator = null
)
{
limit = limit switch
@ -45,11 +47,30 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
if (before != null)
query = query.Where(e => e.Id < before.Value);
else if (after != null)
query = query.Where(e => e.Id > after.Value);
if (type != null)
query = query.Where(e => e.Type == type);
if (byModerator != null)
query = query.Where(e => e.ModeratorId == byModerator.Value);
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
}
[HttpGet("moderators")]
public async Task<IActionResult> GetModeratorsAsync(CancellationToken ct = default)
{
var moderators = await db
.Users.Where(u =>
!u.Deleted && (u.Role == UserRole.Admin || u.Role == UserRole.Moderator)
)
.Select(u => new { u.Id, u.Username })
.OrderBy(u => u.Id)
.ToListAsync(ct);
return Ok(moderators);
}
}

View file

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

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.Models;
using Foxnouns.Backend.Middleware;
@ -17,7 +31,7 @@ public class NotificationsController(
{
[HttpGet]
[Authorize("user.moderation")]
[Limit(UsableBySuspendedUsers = true)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
{
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
@ -31,7 +45,7 @@ public class NotificationsController(
[HttpPut("{id}/ack")]
[Authorize("user.moderation")]
[Limit(UsableBySuspendedUsers = true)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
{
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>

View file

@ -42,7 +42,7 @@ public class UsersController(
[HttpGet("{userRef}")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
@ -222,7 +222,7 @@ public class UsersController(
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
.ToDictionary();
foreach (CustomPreferenceUpdateRequest? r in req)
foreach (CustomPreferenceUpdateRequest r in req)
{
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
{
@ -233,6 +233,7 @@ public class UsersController(
Muted = r.Muted,
Size = r.Size,
Tooltip = r.Tooltip,
LegacyId = preferences[r.Id.Value].LegacyId,
};
}
else
@ -244,6 +245,7 @@ public class UsersController(
Muted = r.Muted,
Size = r.Size,
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")
.IsUnique();
modelBuilder
.Entity<AuditLogEntry>()
.HasOne(e => e.Report)
.WithOne(e => e.AuditLogEntry)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
@ -133,6 +139,26 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
.HasName("find_free_member_sid");
// Indexes for legacy IDs for APIv1
modelBuilder.Entity<User>().HasIndex(u => u.LegacyId).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.LegacyId).IsUnique();
modelBuilder.Entity<PrideFlag>().HasIndex(f => f.LegacyId).IsUnique();
// a UUID is not an xid, but this should always be set by the application anyway.
// we're just setting it here to shut EFCore up because squashing migrations is for nerds
modelBuilder
.Entity<User>()
.Property(u => u.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<Member>()
.Property(m => m.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<PrideFlag>()
.Property(f => f.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
}
/// <summary>

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,7 +36,7 @@ public record UserResponse(
IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns,
IEnumerable<Field> Fields,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
Dictionary<Snowflake, CustomPreferenceResponse> CustomPreferences,
IEnumerable<PrideFlagResponse> Flags,
int? UtcOffset,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
@ -52,6 +52,14 @@ public record UserResponse(
[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(
Snowflake Id,
[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.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Services.V1;
using Microsoft.EntityFrameworkCore;
using Minio;
using NodaTime;
@ -127,7 +128,10 @@ public static class WebApplicationExtensions
.AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>()
.AddTransient<CreateFlagInvocable>()
.AddTransient<CreateDataExportInvocable>();
.AddTransient<CreateDataExportInvocable>()
// Legacy services
.AddScoped<UsersV1Service>()
.AddScoped<MembersV1Service>();
if (!config.Logging.EnableMetrics)
services.AddHostedService<BackgroundMetricsCollectionService>();

View file

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

View file

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

View file

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

View file

@ -58,7 +58,7 @@ public partial class FediverseAuthService
)
{
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,

View file

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

View file

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

View file

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

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 MaximumPasswordLength = 1024;

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ using Foxnouns.Backend.Extensions;
using Foxnouns.DataMigrator.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Npgsql;
using Serilog;
using Serilog.Sinks.SystemConsole.Themes;
@ -22,6 +23,12 @@ internal class Program
.WriteTo.Console(theme: AnsiConsoleTheme.Sixteen)
.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 =
new ConfigurationBuilder()
.AddConfiguration()
@ -35,11 +42,30 @@ internal class Program
await context.Database.MigrateAsync();
Dictionary<int, Snowflake> appIds;
if (minUserId == new Snowflake(0))
{
Log.Information("Migrating applications");
Dictionary<int, Snowflake> appIds = await MigrateAppsAsync(conn, context);
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");
List<GoUser> users = await Queries.GetUsersAsync(conn);
List<GoUser> users = await Queries.GetUsersAsync(conn, minUserId);
List<GoUserField> userFields = await Queries.GetUserFieldsAsync(conn);
List<GoMemberField> memberFields = await Queries.GetMemberFieldsAsync(conn);
List<GoPrideFlag> prideFlags = await Queries.GetUserFlagsAsync(conn);
@ -70,6 +96,12 @@ internal class Program
await context.SaveChangesAsync();
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(
@ -92,6 +124,7 @@ internal class Program
ClientId = app.ClientId,
ClientSecret = app.ClientSecret,
InstanceType = app.TypeToEnum(),
ForceRefresh = true,
}
);
}

View file

@ -13,8 +13,13 @@ public static class Queries
public static async Task<List<GoFediverseApp>> GetFediverseAppsAsync(NpgsqlConnection conn) =>
(await conn.QueryAsync<GoFediverseApp>("select * from fediverse_apps")).ToList();
public static async Task<List<GoUser>> GetUsersAsync(NpgsqlConnection conn) =>
(await conn.QueryAsync<GoUser>("select * from users order by id")).ToList();
public static async Task<List<GoUser>> GetUsersAsync(NpgsqlConnection conn, Snowflake minId) =>
(
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) =>
(await conn.QueryAsync<GoUserField>("select * from user_fields order by id")).ToList();

View file

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

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_BASE_URL=https://pronouns.cc
PUBLIC_SHORT_URL=https://prns.cc
PUBLIC_API_BASE=https://pronouns.cc/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": {
"@sveltejs/adapter-node": "^5.2.10",
"@sveltejs/kit": "^2.11.1",
"@sveltejs/vite-plugin-svelte": "^4.0.3",
"@sveltejs/kit": "^2.12.1",
"@sveltejs/vite-plugin-svelte": "^5.0.2",
"@sveltestrap/sveltestrap": "^6.2.7",
"@types/eslint": "^9.6.1",
"@types/luxon": "^3.4.2",
@ -28,13 +28,13 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"sass": "^1.83.0",
"svelte": "^5.13.0",
"svelte": "^5.14.3",
"svelte-bootstrap-icons": "^3.1.1",
"svelte-check": "^4.1.1",
"sveltekit-i18n": "^2.4.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0",
"vite": "^5.4.11"
"typescript-eslint": "^8.18.1",
"vite": "^6.0.3"
},
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
"dependencies": {

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
import type { ErrorCode } from "$api/error";
// for information about these interfaces
declare global {
namespace App {
interface Error {
message: string;
status: number;
code: ErrorCode;
id: string;
}
// interface Error {}
// interface Locals {}
// interface PageData {}

View file

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

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

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,
NavItem,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/stores";
import { page } from "$app/state";
import type { Meta, MeUser } from "$api/models/index";
import Logo from "$components/Logo.svelte";
import { t } from "$lib/i18n";
@ -25,12 +25,14 @@
{#if user.suspended}
<strong>{$t("nav.suspended-account-hint")}</strong>
<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}
<strong>{$t("nav.deleted-account-hint")}</strong>
<br />
<a href="/settings/reactivate">{$t("nav.reactivate-account-link")}</a>
<a href="/contact">{$t("nav.delete-permanently-link")}</a>
<a href="/settings">{$t("nav.reactivate-or-delete-link")}</a>
<a href="/settings/export">{$t("nav.export-link")}</a>
{/if}
</div>
{/if}
@ -51,19 +53,26 @@
<NavItem>
<NavLink
href="/@{user.username}"
active={$page.url.pathname.startsWith(`/@${user.username}`)}
active={page.url.pathname.startsWith(`/@${user.username}`)}
>
@{user.username}
</NavLink>
</NavItem>
{#if user.role === "ADMIN" || user.role === "MODERATOR"}
<NavItem>
<NavLink href="/settings" active={$page.url.pathname.startsWith("/settings")}>
<NavLink href="/admin" active={page.url.pathname.startsWith(`/admin`)}>
Administration
</NavLink>
</NavItem>
{/if}
<NavItem>
<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
{$t("nav.settings")}
</NavLink>
</NavItem>
{:else}
<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")}
</NavLink>
</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",
"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",
"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",
"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}}",
"profile": {
@ -18,7 +20,10 @@
"pronouns-header": "Pronouns",
"default-members-header": "Members",
"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": {
"log-in": "Log in",
@ -152,7 +157,44 @@
"flag-description-placeholder": "Description",
"flag-name-placeholder": "Name",
"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",
"no": "No",
@ -237,5 +279,35 @@
"custom-preference-muted": "Show as muted text",
"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,
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
const unsafeMd = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
});
const unsafeMd = new MarkdownIt();
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">
import { page } from "$app/stores";
import Error from "$components/Error.svelte";
import { t } from "$lib/i18n";
import type { LayoutData } from "./$types";
@ -14,22 +15,8 @@
</svelte:head>
<div class="container">
<h3>{$t("title.an-error-occurred")}</h3>
<p>
<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">
<Error {error} headerElem="h3" />
<div class="btn-group mt-2">
{#if data.meUser}
<a class="btn btn-primary" href="/@{data.meUser.username}">
{$t("error.back-to-profile-button")}

View file

@ -3,11 +3,16 @@
import "../app.scss";
import type { LayoutData } from "./$types";
import Navbar from "$components/Navbar.svelte";
import Footer from "$components/Footer.svelte";
type Props = { children: Snippet; data: LayoutData };
let { children, data }: Props = $props();
</script>
<Navbar user={data.meUser} meta={data.meta} />
{@render children?.()}
<div class="d-flex flex-column min-vh-100">
<div class="flex-grow-1">
<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 ApiError, { ErrorCode } from "$api/error.js";
import type { UserWithMembers } from "$api/models";
import log from "$lib/log.js";
import paginate from "$lib/paginate";
import { error } from "@sveltejs/kit";
const MEMBERS_PER_PAGE = 20;
export const load = async ({ params, fetch, cookies, url }) => {
let user: UserWithMembers;
try {
user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, {
const 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(
user.members,

View file

@ -8,6 +8,8 @@
import { Icon } from "@sveltestrap/sveltestrap";
import Paginator from "$components/Paginator.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 };
let { data }: Props = $props();
@ -27,6 +29,13 @@
<ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} />
<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}
<hr />

View file

@ -1,11 +1,7 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error.js";
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 }) => {
try {
const member = await apiRequest<Member>(
"GET",
`/users/${params.username}/members/${params.memberName}`,
@ -16,13 +12,4 @@ export const load = async ({ params, fetch, cookies }) => {
);
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 { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n";
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
import PreferenceCheatsheet from "$components/profile/PreferenceCheatsheet.svelte";
type Props = { data: PageData };
let { data }: Props = $props();
@ -37,4 +39,12 @@
<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} />
<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>

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">
import type { Snippet } from "svelte";
import { page } from "$app/stores";
import { t } from "$lib/i18n";
import { Nav, NavLink } from "@sveltestrap/sveltestrap";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { children: Snippet };
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>
<svelte:head>

View file

@ -18,9 +18,37 @@
<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="col-md-9">
<h5>Change your username</h5>
<h5>{$t("settings.change-username-header")}</h5>
<form method="POST" action="?/changeUsername" use:enhance>
<FormGroup class="mb-3">
<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>
</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>
<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);
if (idx === -1) return;
console.log("yippee");
flags[idx] = { ...flags[idx], name, description };
} catch (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">
import type { Snippet } from "svelte";
import { page } from "$app/stores";
import { t } from "$lib/i18n";
import type { LayoutData } from "./$types";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props();
const isActive = (path: string) => $page.url.pathname === path;
let name = $derived(
data.member.display_name === data.member.name
? data.member.name

View file

@ -1,13 +1,11 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { page } from "$app/stores";
import { t } from "$lib/i18n";
import type { LayoutData } from "./$types";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props();
const isActive = (path: string) => $page.url.pathname === path;
</script>
<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"
volumes:
- ./docker/config.ini:/app/config.ini
- ./docker/static-pages:/app/static-pages
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
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)
return
}

View file

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