Compare commits

...

6 commits

17 changed files with 399 additions and 13 deletions

View file

@ -65,7 +65,7 @@ public class MembersController(
return Ok(memberRenderer.RenderMember(member, CurrentToken));
}
public const int MaxMemberCount = 500;
public const int MaxMemberCount = 1000;
[HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]

View file

@ -43,7 +43,9 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
_ => limit,
};
IQueryable<AuditLogEntry> query = db.AuditLog.OrderByDescending(e => e.Id);
IQueryable<AuditLogEntry> query = db
.AuditLog.Include(e => e.Report)
.OrderByDescending(e => e.Id);
if (before != null)
query = query.Where(e => e.Id < before.Value);

View file

@ -0,0 +1,96 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation/lookup")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class LookupController(
DatabaseContext db,
UserRendererService userRenderer,
ModerationService moderationService,
ModerationRendererService moderationRenderer
) : ApiControllerBase
{
[HttpPost]
public async Task<IActionResult> QueryUsersAsync(
[FromBody] QueryUsersRequest req,
CancellationToken ct = default
)
{
var query = db.Users.Select(u => new { u.Id, u.Username });
query = req.Fuzzy
? query.Where(u => u.Username.Contains(req.Query))
: query.Where(u => u.Username == req.Query);
var users = await query.OrderBy(u => u.Id).Take(100).ToListAsync(ct);
return Ok(users);
}
[HttpGet("{id}")]
public async Task<IActionResult> QueryUserAsync(Snowflake id, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(id, ct);
bool showSensitiveData = await moderationService.ShowSensitiveDataAsync(
CurrentUser!,
user,
ct
);
List<AuthMethod> authMethods = showSensitiveData
? await db
.AuthMethods.Where(a => a.UserId == user.Id)
.Include(a => a.FediverseApplication)
.ToListAsync(ct)
: [];
return Ok(
new QueryUserResponse(
User: await userRenderer.RenderUserAsync(
user,
renderMembers: false,
renderAuthMethods: false,
ct: ct
),
MemberListHidden: user.ListHidden,
LastActive: user.LastActive,
LastSidReroll: user.LastSidReroll,
Suspended: user is { Deleted: true, DeletedBy: not null },
Deleted: user.Deleted,
ShowSensitiveData: showSensitiveData,
AuthMethods: showSensitiveData
? authMethods.Select(UserRendererService.RenderAuthMethod)
: null
)
);
}
[HttpPost("{id}/sensitive")]
public async Task<IActionResult> QuerySensitiveUserDataAsync(
Snowflake id,
[FromBody] QuerySensitiveUserDataRequest req
)
{
User user = await db.ResolveUserAsync(id);
// Don't let mods accidentally spam the audit log
bool alreadyAuthorized = await moderationService.ShowSensitiveDataAsync(CurrentUser!, user);
if (alreadyAuthorized)
return NoContent();
AuditLogEntry entry = await moderationService.QuerySensitiveDataAsync(
CurrentUser!,
user,
req.Reason
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
}

View file

@ -41,4 +41,5 @@ public enum AuditLogEntryType
WarnUser,
WarnUserAndClearProfile,
SuspendUser,
QuerySensitiveUserData,
}

View file

@ -18,6 +18,7 @@ using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NodaTime;
namespace Foxnouns.Backend.Dto;
@ -41,12 +42,23 @@ public record AuditLogResponse(
AuditLogEntity? TargetUser,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
AuditLogEntity? TargetMember,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ReportId,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] PartialReport? Report,
AuditLogEntryType Type,
string? Reason,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
);
public record PartialReport(
Snowflake Id,
Snowflake ReporterId,
Snowflake TargetUserId,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
Snowflake? TargetMemberId,
ReportReason Reason,
string? Context,
ReportTargetType TargetType
);
public record NotificationResponse(
Snowflake Id,
NotificationType Type,
@ -83,3 +95,19 @@ public enum FieldsToClear
Flags,
CustomPreferences,
}
public record QueryUsersRequest(string Query, bool Fuzzy);
public record QueryUserResponse(
UserResponse User,
bool MemberListHidden,
Instant LastActive,
Instant LastSidReroll,
bool Suspended,
bool Deleted,
bool ShowSensitiveData,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<AuthMethodResponse>? AuthMethods
);
public record QuerySensitiveUserDataRequest(string Reason);

View file

@ -46,12 +46,26 @@ public class ModerationRendererService(
public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
{
PartialReport? report = null;
if (entry.Report != null)
{
report = new PartialReport(
entry.Report.Id,
entry.Report.ReporterId,
entry.Report.TargetUserId,
entry.Report.TargetMemberId,
entry.Report.Reason,
entry.Report.Context,
entry.Report.TargetType
);
}
return new AuditLogResponse(
Id: entry.Id,
Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
ReportId: entry.ReportId,
Report: report,
Type: entry.Type,
Reason: entry.Reason,
ClearedFields: entry.ClearedFields

View file

@ -18,6 +18,7 @@ using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Jobs;
using Humanizer;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Services;
@ -63,6 +64,54 @@ public class ModerationService(
return entry;
}
public async Task<AuditLogEntry> QuerySensitiveDataAsync(
User moderator,
User target,
string reason
)
{
_logger.Information(
"Moderator {ModeratorId} is querying sensitive data for {TargetId}",
moderator.Id,
target.Id
);
var entry = new AuditLogEntry
{
Id = snowflakeGenerator.GenerateSnowflake(),
ModeratorId = moderator.Id,
ModeratorUsername = moderator.Username,
TargetUserId = target.Id,
TargetUsername = target.Username,
Type = AuditLogEntryType.QuerySensitiveUserData,
Reason = reason,
};
db.AuditLog.Add(entry);
await db.SaveChangesAsync();
return entry;
}
public async Task<bool> ShowSensitiveDataAsync(
User moderator,
User target,
CancellationToken ct = default
)
{
Snowflake cutoff = snowflakeGenerator.GenerateSnowflake(
clock.GetCurrentInstant() - Duration.FromDays(1)
);
return await db.AuditLog.AnyAsync(
e =>
e.ModeratorId == moderator.Id
&& e.TargetUserId == target.Id
&& e.Type == AuditLogEntryType.QuerySensitiveUserData
&& e.Id > cutoff,
ct
);
}
public async Task<AuditLogEntry> ExecuteSuspensionAsync(
User moderator,
User target,

View file

@ -1,5 +1,5 @@
import type { Member } from "./member";
import type { PartialMember, PartialUser, User } from "./user";
import type { AuthMethod, PartialMember, PartialUser, User } from "./user";
export type CreateReportRequest = {
reason: ReportReason;
@ -45,7 +45,7 @@ export type AuditLogEntry = {
moderator: AuditLogEntity;
target_user?: AuditLogEntity;
target_member?: AuditLogEntity;
report_id?: string;
report?: PartialReport;
type: AuditLogEntryType;
reason: string | null;
cleared_fields?: string[];
@ -58,4 +58,25 @@ export enum AuditLogEntryType {
WarnUser = "WARN_USER",
WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
SuspendUser = "SUSPEND_USER",
QuerySensitiveUserData = "QUERY_SENSITIVE_USER_DATA",
}
export type PartialReport = {
id: string;
reporter_id: string;
target_user_id: string;
target_member_id?: string;
reason: ReportReason;
context: string | null;
target_type: "USER" | "MEMBER";
};
export type QueriedUser = {
user: User;
member_list_hidden: boolean;
last_active: string;
last_sid_reroll: string;
suspended: boolean;
deleted: boolean;
auth_methods?: AuthMethod[];
};

View file

@ -12,10 +12,6 @@
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">
@ -26,6 +22,8 @@
warned
{:else if entry.type === "SUSPEND_USER"}
suspended
{:else if entry.type === "QUERY_SENSITIVE_USER_DATA"}
looked up sensitive data of
{:else}
(unknown action <code>{entry.type}</code>)
{/if}
@ -39,12 +37,31 @@
<small class="text-secondary">{date}</small>
</h6>
{#if entry.type === "IGNORE_REPORT"}
{#if entry.report}
<details>
<summary>Report</summary>
<ul>
<li><strong>From:</strong> {entry.report.reporter_id}</li>
<li><strong>Target:</strong> {entry.report.target_user_id}</li>
<li><strong>Reason:</strong> {entry.report.reason}</li>
{#if entry.report.context}
<li><strong>Context:</strong> {entry.report.context}</li>
{/if}
</ul>
</details>
{:else}
<p><em>(the ignored report has been deleted)</em></p>
{/if}
{/if}
{#if reason}
<details>
<summary>Reason</summary>
{@html reason}
</details>
{:else}
<em>(no reason given)</em>
<p><em>(no reason given)</em></p>
{/if}
</div>

View file

@ -2,8 +2,8 @@
import { t } from "$lib/i18n";
import type { AuthMethod } from "$api/models";
type Props = { method: AuthMethod; canRemove: boolean };
let { method, canRemove }: Props = $props();
type Props = { method: AuthMethod; canRemove: boolean; showType?: boolean };
let { method, canRemove, showType }: Props = $props();
let name = $derived(
method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
@ -14,6 +14,9 @@
<div class="list-group-item">
<div class="row">
<div class="col">
{#if showType}
<code>{method.type}</code>:
{/if}
{name}
{#if showId}({method.remote_id}){/if}
</div>

View file

@ -41,6 +41,13 @@
>
Audit log
</a>
<a
href="/admin/lookup"
class="list-group-item list-group-item-action"
class:active={isActive("/admin/lookup", true)}
>
Lookup
</a>
</div>
</div>
<div class="col-md-9">

View file

@ -6,6 +6,10 @@
let { data }: Props = $props();
</script>
<svelte:head>
<title>Admin dashboard • pronouns.cc</title>
</svelte:head>
<h1>Dashboard</h1>
<div class="row gx-3 gy-3">

View file

@ -45,6 +45,10 @@
};
</script>
<svelte:head>
<title>Audit log • pronouns.cc</title>
</svelte:head>
<h1>Audit log</h1>
<div class="btn-group">
@ -68,6 +72,12 @@
<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
Suspend user
</DropdownItem>
<DropdownItem
href={addTypeFilter("QuerySensitiveUserData")}
active={data.type === "QuerySensitiveUserData"}
>
Query sensitive user data
</DropdownItem>
{#if data.type}
<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
{/if}

View file

@ -0,0 +1,27 @@
import { apiRequest } from "$api";
import { redirect } from "@sveltejs/kit";
export const actions = {
default: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const query = body.get("query") as string;
const fuzzy = body.get("fuzzy") === "yes";
const users = await apiRequest<Array<{ id: string; username: string }>>(
"POST",
"/moderation/lookup",
{
fetch,
cookies,
body: {
query,
fuzzy,
},
},
);
if (!fuzzy && users.length > 0) redirect(303, `/admin/lookup/${users[0].id}`);
return { users };
},
};

View file

@ -0,0 +1,33 @@
<script lang="ts">
import type { ActionData } from "./$types";
type Props = { form: ActionData };
let { form }: Props = $props();
</script>
<svelte:head>
<title>Look up a user • pronouns.cc</title>
</svelte:head>
<h1>Look up a user</h1>
<form method="POST">
<div class="input-group w-lg-50 mb-2">
<input type="text" class="form-control" name="query" placeholder="Query" required />
<button class="btn btn-primary" type="submit">Search</button>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="yes" name="fuzzy" id="fuzzy" />
<label class="form-check-label" for="fuzzy">Fuzzy?</label>
</div>
</form>
<div class="list-group">
{#each form?.users || [] as user (user.id)}
<a href="/admin/lookup/{user.id}" class="list-group-item list-group-item-action">
{user.username} <span class="text-secondary">({user.id})</span>
</a>
{:else}
<div class="list-group-item">No results</div>
{/each}
</div>

View file

@ -0,0 +1,11 @@
import { apiRequest } from "$api";
import type { QueriedUser } from "$api/models/moderation";
export const load = async ({ params, fetch, cookies }) => {
const user = await apiRequest<QueriedUser>("GET", `/moderation/lookup/${params.id}`, {
fetch,
cookies,
});
return { user };
};

View file

@ -0,0 +1,63 @@
<script lang="ts">
import { idTimestamp } from "$lib";
import { DateTime } from "luxon";
import type { PageData } from "./$types";
import ProfileHeader from "$components/profile/ProfileHeader.svelte";
import ProfileFields from "$components/profile/ProfileFields.svelte";
import { mergePreferences } from "$api/models";
import AuthMethodRow from "$components/settings/AuthMethodRow.svelte";
type Props = { data: PageData };
let { data }: Props = $props();
let createdAt = $derived(idTimestamp(data.user.user.id));
let lastActive = $derived(DateTime.fromISO(data.user.last_active));
let lastSidReroll = $derived(DateTime.fromISO(data.user.last_sid_reroll));
let authMethods = $derived.by(() => {
if (!data.user.auth_methods) return undefined;
return data.user.auth_methods.sort((a, b) => a.type.localeCompare(b.type));
});
</script>
<svelte:head>
<title>Looking up @{data.user.user.username} • pronouns.cc</title>
</svelte:head>
<h2>Basic profile</h2>
<ProfileHeader name="@{data.user.user.username}" profile={data.user.user} />
<ProfileFields
profile={data.user.user}
allPreferences={mergePreferences(data.user.user.custom_preferences)}
/>
<h2>Extra information</h2>
<table class="table table-striped table-hover table-bordered">
<tbody>
<tr>
<th scope="row">Created at</th>
<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td>
</tr>
<tr>
<th scope="row">Last active</th>
<td>{lastActive.toLocaleString(DateTime.DATETIME_MED)}</td>
</tr>
<tr>
<th scope="row">Last SID reroll</th>
<td>{lastSidReroll.toLocaleString(DateTime.DATETIME_MED)}</td>
</tr>
</tbody>
</table>
{#if authMethods}
<h2>Authentication methods</h2>
<div class="list-group">
{#each authMethods as method (method.id)}
<AuthMethodRow {method} canRemove={false} showType />
{/each}
</div>
{/if}