Compare commits
26 commits
feat/repor
...
main
Author | SHA1 | Date | |
---|---|---|---|
53006ea313 | |||
49e9eabea0 | |||
5077bd6a0b | |||
3f0edc4374 | |||
7468aa20ab | |||
fe1cf7ce8a | |||
478ba2a406 | |||
78afb8b9c4 | |||
e908e67ca6 | |||
d182b07482 | |||
2281b3e478 | |||
140419a1ca | |||
7791c91960 | |||
5e7df2e074 | |||
e24c4f9b00 | |||
3f8f6d0f23 | |||
661c3eab0f | |||
96725cc304 | |||
8a2ffd7d69 | |||
546e900204 | |||
bd21eeebcf | |||
05913a3b2f | |||
1fb1d8dd14 | |||
ddd96e415a | |||
397ffc2d5e | |||
80385893c7 |
100 changed files with 2867 additions and 412 deletions
|
@ -21,3 +21,4 @@
|
|||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
static-pages/*
|
||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
89
Foxnouns.Backend/Controllers/DeleteUserController.cs
Normal file
89
Foxnouns.Backend/Controllers/DeleteUserController.cs
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
120
Foxnouns.Backend/Controllers/V1/V1ReadController.cs
Normal file
120
Foxnouns.Backend/Controllers/V1/V1ReadController.cs
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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))]
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
59
Foxnouns.Backend/Dto/V1/Member.cs
Normal file
59
Foxnouns.Backend/Dto/V1/Member.cs
Normal 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
|
||||
);
|
130
Foxnouns.Backend/Dto/V1/User.cs
Normal file
130
Foxnouns.Backend/Dto/V1/User.cs
Normal 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);
|
|
@ -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>();
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ public class ModerationRendererService(
|
|||
: null,
|
||||
report.Status,
|
||||
report.Reason,
|
||||
report.Context,
|
||||
report.TargetType,
|
||||
report.TargetSnapshot != null
|
||||
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)
|
||||
|
|
|
@ -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,
|
||||
|
|
125
Foxnouns.Backend/Services/V1/MembersV1Service.cs
Normal file
125
Foxnouns.Backend/Services/V1/MembersV1Service.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
247
Foxnouns.Backend/Services/V1/UsersV1Service.cs
Normal file
247
Foxnouns.Backend/Services/V1/UsersV1Service.cs
Normal 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)
|
||||
);
|
||||
}
|
34
Foxnouns.Backend/Services/V1/V1Utils.cs
Normal file
34
Foxnouns.Backend/Services/V1/V1Utils.cs
Normal 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";
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
2
Foxnouns.Backend/static-pages/.gitignore
vendored
Normal file
2
Foxnouns.Backend/static-pages/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
9
Foxnouns.Frontend/src/app.d.ts
vendored
9
Foxnouns.Frontend/src/app.d.ts
vendored
|
@ -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 {}
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
61
Foxnouns.Frontend/src/lib/api/models/moderation.ts
Normal file
61
Foxnouns.Frontend/src/lib/api/models/moderation.ts
Normal 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",
|
||||
}
|
83
Foxnouns.Frontend/src/lib/components/Footer.svelte
Normal file
83
Foxnouns.Frontend/src/lib/components/Footer.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { AuditLogEntity } from "$api/models/moderation";
|
||||
|
||||
type Props = { entity: AuditLogEntity };
|
||||
let { entity }: Props = $props();
|
||||
</script>
|
||||
|
||||
<strong>{entity.username}</strong> <span class="text-secondary">({entity.id})</span>
|
|
@ -0,0 +1,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>
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = { title: string; onlyNumber?: boolean; children: Snippet };
|
||||
let { title, onlyNumber = true, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="col-md">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{title}</h5>
|
||||
<p class="card-text text-center" class:fs-1={onlyNumber}>
|
||||
{@render children()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { type 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>
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
10
Foxnouns.Frontend/src/lib/pageUtils.svelte.ts
Normal file
10
Foxnouns.Frontend/src/lib/pageUtils.svelte.ts
Normal 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);
|
|
@ -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")}
|
||||
|
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
30
Foxnouns.Frontend/src/routes/admin/+layout.server.ts
Normal file
30
Foxnouns.Frontend/src/routes/admin/+layout.server.ts
Normal 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,
|
||||
};
|
||||
};
|
50
Foxnouns.Frontend/src/routes/admin/+layout.svelte
Normal file
50
Foxnouns.Frontend/src/routes/admin/+layout.svelte
Normal 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>
|
23
Foxnouns.Frontend/src/routes/admin/+page.svelte
Normal file
23
Foxnouns.Frontend/src/routes/admin/+page.svelte
Normal 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>
|
38
Foxnouns.Frontend/src/routes/admin/audit-log/+page.server.ts
Normal file
38
Foxnouns.Frontend/src/routes/admin/audit-log/+page.server.ts
Normal 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 };
|
||||
};
|
105
Foxnouns.Frontend/src/routes/admin/audit-log/+page.svelte
Normal file
105
Foxnouns.Frontend/src/routes/admin/audit-log/+page.svelte
Normal 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}
|
14
Foxnouns.Frontend/src/routes/page/[page]/+page.server.ts
Normal file
14
Foxnouns.Frontend/src/routes/page/[page]/+page.server.ts
Normal 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 };
|
||||
};
|
22
Foxnouns.Frontend/src/routes/page/[page]/+page.svelte
Normal file
22
Foxnouns.Frontend/src/routes/page/[page]/+page.svelte
Normal 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>
|
60
Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts
Normal file
60
Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
70
Foxnouns.Frontend/src/routes/report/[id]/+page.svelte
Normal file
70
Foxnouns.Frontend/src/routes/report/[id]/+page.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
41
Foxnouns.Frontend/src/routes/settings/delete/+page.server.ts
Normal file
41
Foxnouns.Frontend/src/routes/settings/delete/+page.server.ts
Normal 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");
|
||||
},
|
||||
};
|
55
Foxnouns.Frontend/src/routes/settings/delete/+page@.svelte
Normal file
55
Foxnouns.Frontend/src/routes/settings/delete/+page@.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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);
|
||||
|
|
|
@ -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");
|
||||
},
|
||||
};
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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! };
|
||||
};
|
|
@ -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>
|
|
@ -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
2
docker/static-pages/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"port": 5003,
|
||||
"proxy_target": "http://localhost:5000",
|
||||
"proxy_target": "http://localhost:6000",
|
||||
"debug": true,
|
||||
"powered_by": "5 gay rats"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue