Compare commits

...

4 commits

24 changed files with 2094 additions and 42 deletions

View file

@ -3,18 +3,18 @@
"isRoot": true,
"tools": {
"husky": {
"version": "0.7.1",
"version": "0.7.2",
"commands": [
"husky"
],
"rollForward": false
},
"csharpier": {
"version": "0.29.2",
"version": "0.30.6",
"commands": [
"dotnet-csharpier"
],
"rollForward": false
}
}
}
}

View file

@ -220,7 +220,36 @@ public class ReportsController(
return Ok(reports.Select(moderationRenderer.RenderReport));
}
[HttpGet("reports/{id}")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public async Task<IActionResult> GetReportAsync(Snowflake id, CancellationToken ct = default)
{
Report? report = await db
.Reports.Include(r => r.Reporter)
.Include(r => r.TargetUser)
.Include(r => r.TargetMember)
.FirstOrDefaultAsync(r => r.Id == id, ct);
if (report == null)
throw new ApiError.NotFound("No report with that ID found.");
return Ok(
new ReportDetailResponse(
Report: moderationRenderer.RenderReport(report),
User: await userRenderer.RenderUserAsync(
report.TargetUser,
renderMembers: false,
ct: ct
),
Member: report.TargetMember != null
? memberRenderer.RenderMember(report.TargetMember)
: null
)
);
}
[HttpPost("reports/{id}/ignore")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public async Task<IActionResult> IgnoreReportAsync(
Snowflake id,

View file

@ -113,24 +113,30 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
) => writer.WriteStringValue(value.Value.ToString());
}
private class JsonConverter : JsonConverter<Snowflake>
private class JsonConverter : JsonConverter<Snowflake?>
{
public override void WriteJson(
JsonWriter writer,
Snowflake value,
Snowflake? value,
JsonSerializer serializer
)
{
writer.WriteValue(value.Value.ToString());
if (value != null)
writer.WriteValue(value.Value.ToString());
else
writer.WriteNull();
}
public override Snowflake ReadJson(
public override Snowflake? ReadJson(
JsonReader reader,
Type objectType,
Snowflake existingValue,
Snowflake? existingValue,
bool hasExistingValue,
JsonSerializer serializer
) => ulong.Parse((string)reader.Value!);
) =>
reader.TokenType is not (JsonToken.None or JsonToken.Null)
? ulong.Parse((string)reader.Value!)
: null;
}
private class TypeConverter : System.ComponentModel.TypeConverter

View file

@ -16,6 +16,7 @@
// ReSharper disable NotAccessedPositionalProperty.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NodaTime;
@ -35,6 +36,12 @@ public record ReportResponse(
JObject? Snapshot
);
public record ReportDetailResponse(
ReportResponse Report,
UserResponse User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] MemberResponse? Member
);
public record AuditLogResponse(
Snowflake Id,
AuditLogEntity Moderator,
@ -74,15 +81,17 @@ public record CreateReportRequest(ReportReason Reason, string? Context = null);
public record IgnoreReportRequest(string? Reason = null);
public record WarnUserRequest(
string Reason,
FieldsToClear[]? ClearFields = null,
Snowflake? MemberId = null,
Snowflake? ReportId = null
);
public class WarnUserRequest
{
public required string Reason { get; init; }
public FieldsToClear[]? ClearFields { get; init; }
public Snowflake? MemberId { get; init; }
public Snowflake? ReportId { get; init; }
}
public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null);
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum FieldsToClear
{
DisplayName,

View file

@ -154,6 +154,12 @@ public class ModerationService(
target.DeletedAt = clock.GetCurrentInstant();
target.DeletedBy = moderator.Id;
if (report != null)
{
report.Status = ReportStatus.Closed;
db.Update(report);
}
if (!clearProfile)
{
db.Update(target);
@ -334,6 +340,12 @@ public class ModerationService(
db.Update(targetUser);
}
if (report != null)
{
report.Status = ReportStatus.Closed;
db.Update(report);
}
await db.SaveChangesAsync();
return entry;

View file

@ -1,7 +1,18 @@
# Example .env file--DO NOT EDIT, copy to .env or .env.local then edit
# The language the frontend will use. Valid languages are listed in src/lib/i18n/index.ts.
PUBLIC_LANGUAGE=en
# The public base URL, i.e. the one users will see. Used for building links.
PUBLIC_BASE_URL=https://pronouns.cc
# The base URL for the URL shortener service. Used for building short links.
PUBLIC_SHORT_URL=https://prns.cc
# The base public URL for the API. This is (almost) always the public base URL + /api.
PUBLIC_API_BASE=https://pronouns.cc/api
# The base *private* URL for the API's rate limiter proxy. The frontend will rewrite API URLs to use this.
# In development, you can set this to the same value as $PRIVATE_INTERNAL_API_HOST, but be aware that this will disable rate limiting.
PRIVATE_API_HOST=http://localhost:5003/api
# The base private URL for the API, which bypasses the rate limiter. Used for /api/internal paths and unauthenticated GET requests.
PRIVATE_INTERNAL_API_HOST=http://localhost:6000/api
# The Sentry URL to use. Optional.
PRIVATE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0

View file

@ -39,6 +39,7 @@
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
"dependencies": {
"@fontsource/firago": "^5.1.0",
"@sentry/sveltekit": "^8.52.0",
"base64-arraybuffer": "^1.0.2",
"bootstrap-icons": "^1.11.3",
"luxon": "^3.5.0",

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,8 @@ declare global {
message: string;
status: number;
code: ErrorCode;
id: string;
errors?: Array<{ key: string; errors: ValidationError[] }>;
error_id?: string;
}
// interface Error {}
// interface Locals {}

View file

@ -1,8 +1,10 @@
import ApiError, { ErrorCode } from "$api/error";
import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private";
import { env } from "$env/dynamic/private";
import { PUBLIC_API_BASE } from "$env/static/public";
import log from "$lib/log";
import type { HandleFetch, HandleServerError } from "@sveltejs/kit";
import * as Sentry from "@sentry/sveltekit";
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
@ -14,12 +16,17 @@ export const handleFetch: HandleFetch = async ({ request, fetch }) => {
return await fetch(request);
};
Sentry.init({
dsn: env.PRIVATE_SENTRY_DSN,
});
export const handleError: HandleServerError = async ({ error, status, message }) => {
const id = crypto.randomUUID();
// as far as i know, sentry IDs are just UUIDs with the dashes removed. use those here as well
let id = crypto.randomUUID().replaceAll("-", "");
if (error instanceof ApiError) {
return {
id,
error_id: id,
status: error.raw?.status || status,
message: error.raw?.message || "Unknown error",
code: error.code,
@ -27,10 +34,18 @@ export const handleError: HandleServerError = async ({ error, status, message })
}
if (status >= 400 && status <= 499) {
return { id, status, message, code: ErrorCode.GenericApiError };
return { error_id: id, status, message, code: ErrorCode.GenericApiError };
}
// client errors and backend API errors just clog up sentry, so we don't send those.
id = Sentry.captureException(error, {
mechanism: {
type: "sveltekit",
handled: false,
},
});
log.error("[%s] error in handler:", id, error);
return { id, status, message, code: ErrorCode.InternalServerError };
return { error_id: id, status, message, code: ErrorCode.InternalServerError };
};

View file

@ -0,0 +1,90 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuditLogEntry, ClearableField } from "$api/models/moderation";
import log from "$lib/log";
import { type RequestEvent } from "@sveltejs/kit";
type ModactionResponse = { ok: boolean; resp: AuditLogEntry | null; error: RawApiError | null };
type ModactionFunction = (evt: RequestEvent) => Promise<ModactionResponse>;
export default function createModactionAction(
type: "ignore" | "warn" | "suspend",
requireReason: boolean,
): ModactionFunction {
return async function ({ request, fetch, cookies }) {
const body = await request.formData();
const userId = body.get("user") as string;
const memberId = body.get("member") as string | null;
const reportId = body.get("report") as string | null;
const reason = body.get("reason") as string | null;
if (!reportId && type === "ignore") {
return {
ok: false,
resp: null,
error: {
status: 400,
message: "Bad request",
code: ErrorCode.BadRequest,
errors: [
{ key: "report", errors: [{ message: "Ignoring a report requires a report ID" }] },
],
} satisfies RawApiError,
};
}
if (!reason && requireReason) {
return {
ok: false,
resp: null,
error: {
status: 400,
message: "Bad request",
code: ErrorCode.BadRequest,
errors: [{ key: "reason", errors: [{ message: "You must give a reason" }] }],
} satisfies RawApiError,
};
}
let clearFields: ClearableField[] | undefined = undefined;
if (type === "warn") {
clearFields = body.getAll("clear-fields") as ClearableField[];
}
let path: string;
if (type === "warn") path = `/moderation/warnings/${userId}`;
else if (type === "suspend") path = `/moderation/suspensions/${userId}`;
else path = `/moderation/reports/${reportId}/ignore`;
try {
const resp = await apiRequest<AuditLogEntry>("POST", path, {
fetch,
cookies,
body: {
reason: reason,
// These are ignored by POST /reports/{id}/ignore
member_id: memberId,
report_id: reportId,
// This is ignored by everything but POST /warnings/{id}
clear_fields: clearFields,
// This is ignored by everything but POST /suspensions/{id}
clear_profile: !!body.get("clear-profile"),
},
});
return { ok: true, resp, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj, resp: null };
log.error("could not take action on %s:", path, e);
throw e;
}
};
}
export function createModactions() {
return {
ignore: createModactionAction("ignore", false),
warn: createModactionAction("warn", true),
suspend: createModactionAction("suspend", true),
};
}

View file

@ -14,6 +14,7 @@ export default class ApiError {
toObject(): RawApiError {
return {
error_id: this.raw?.error_id,
status: this.raw?.status || 500,
code: this.code,
message: this.raw?.message || "Internal server error",
@ -23,6 +24,7 @@ export default class ApiError {
}
export type RawApiError = {
error_id?: string;
status: number;
message: string;
code: ErrorCode;

View file

@ -81,6 +81,7 @@ export async function apiRequest<TResponse, TRequest = unknown>(
if (resp.status < 200 || resp.status > 299) {
const err = await resp.json();
log.error("Received error for request to %s %s:", method, path, err);
if ("code" in err) throw new ApiError(err);
else throw new ApiError();
}

View file

@ -71,6 +71,12 @@ export type PartialReport = {
target_type: "USER" | "MEMBER";
};
export type ReportDetails = {
report: Report;
user: User;
member?: Member;
};
export type QueriedUser = {
user: User;
member_list_hidden: boolean;
@ -80,3 +86,28 @@ export type QueriedUser = {
deleted: boolean;
auth_methods?: AuthMethod[];
};
export type WarnUserRequest = {
reason: string;
clear_fields?: ClearableField[];
member_id?: string;
report_id?: string;
};
export type SuspendUserRequest = {
reason: string;
clear_profile: boolean;
report_id?: string;
};
export enum ClearableField {
DisplayName = "DISPLAY_NAME",
Avatar = "AVATAR",
Bio = "BIO",
Links = "LINKS",
Names = "NAMES",
Pronouns = "PRONOUNS",
Fields = "FIELDS",
Flags = "FLAGS",
CustomPreferences = "CUSTOM_PREFERENCES",
}

View file

@ -18,6 +18,14 @@
</svelte:element>
{/if}
<p>{errorDescription($t, error.code)}</p>
{#if error.error_id}
<p>
{$t("error.error-id")}
<code>
{error.error_id}
</code>
</p>
{/if}
{#if error.errors}
<details>
<summary>{$t("error.extra-info-header")}</summary>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { ClearableField } from "$api/models/moderation";
import FormStatusMarker, { type FormError } from "$components/editor/FormStatusMarker.svelte";
import { TabContent, TabPane } from "@sveltestrap/sveltestrap";
let {
userId,
reportId,
memberId,
form,
}: { userId: string; reportId?: string; memberId?: string; form: FormError } = $props();
let fields = $derived.by(() => {
const fields = [];
for (const value of Object.values(ClearableField)) {
fields.push({ value });
}
return fields;
});
</script>
<form method="POST">
<input type="hidden" name="user" value={userId} />
{#if memberId}
<input type="hidden" name="member" value={memberId} />
{/if}
{#if reportId}
<input type="hidden" name="report" value={reportId} />
{/if}
<FormStatusMarker {form} />
<textarea name="reason" class="form-control" style="height: 200px;"></textarea>
<TabContent>
{#if reportId}
<TabPane tabId="ignore" tab="Ignore">
<button type="submit" formaction="?/ignore" class="btn btn-secondary">Ignore report</button>
</TabPane>
{/if}
<TabPane tabId="warn" tab="Warn" active>
<div class="row row-cols-1 row-cols-lg-2">
{#each fields as field}
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="clear-fields"
value={field.value}
id="reason-{field.value}"
/>
<label class="form-check-label" for="reason-{field.value}">
<code>{field.value}</code>
</label>
</div>
{/each}
</div>
<div>
<button type="submit" formaction="?/warn" class="btn btn-danger">Warn user</button>
</div>
</TabPane>
<TabPane tabId="suspend" tab="Suspend">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="yes"
name="clear-profile"
id="clear-profile"
/>
<label class="form-check-label" for="clear-profile">Clear the user's profile?</label>
</div>
<button type="submit" formaction="?/suspend" class="btn btn-danger">Suspend user</button>
</TabPane>
</TabContent>
</form>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import type { PartialUser } from "$api/models";
import Avatar from "$components/Avatar.svelte";
import { idTimestamp } from "$lib";
import { t } from "$lib/i18n";
import { DateTime } from "luxon";
type Props = { user: PartialUser };
let { user }: Props = $props();
let createdAt = $derived(idTimestamp(user.id).toLocaleString(DateTime.DATETIME_SHORT));
</script>
<div class="text-center">
<a href="/@{user.username}">
<Avatar
name={user.username}
url={user.avatar_url}
lazyLoad
alt={$t("avatar-tooltip", { name: "@" + user.username })}
/>
</a>
<p class="m-2">
<a class="text-reset fs-5 text-break" href="/@{user.username}">
@{user.username}
</a>
</p>
<p>Created {createdAt}</p>
</div>

View file

@ -1,10 +1,14 @@
<script module lang="ts">
export type FormError = { error: RawApiError | null; ok: boolean } | null;
</script>
<script lang="ts">
import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n";
import type { RawApiError } from "$api/error";
import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string };
type Props = { form: FormError | null; successMessage?: string };
let { form, successMessage }: Props = $props();
</script>

View file

@ -110,7 +110,8 @@
"back-to-prev-page-button": "Go back to the previous page",
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
"500-description": "Something went wrong on the server. Please try again later.",
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again.",
"error-id": "If you report this error to the developers, please give them this ID:"
},
"settings": {
"general-information-tab": "General information",

View file

@ -18,7 +18,7 @@ export const load = async ({ parent, fetch, cookies }) => {
const reports = await apiRequest<Report[]>("GET", "/moderation/reports", { fetch, cookies });
const staleReportCount = reports.filter(
(r) => idTimestamp(r.id).diffNow(["days"]).days >= 7,
(r) => idTimestamp(r.id).diffNow(["days"]).days <= -7,
).length;
return {

View file

@ -0,0 +1,23 @@
import { apiRequest } from "$api";
import type { Report } from "$api/models/moderation";
export const load = async ({ url, fetch, cookies }) => {
const before = url.searchParams.get("before");
const after = url.searchParams.get("after");
const byReporter = url.searchParams.get("by-reporter");
const byTarget = url.searchParams.get("by-target");
const includeClosed = url.searchParams.get("include-closed") === "true";
const params = new URLSearchParams();
if (before) params.set("before", before);
if (after) params.set("after", after);
if (byReporter) params.set("by-reporter", byReporter);
if (byTarget) params.set("by-target", byTarget);
if (includeClosed) params.set("include-closed", "true");
const reports = await apiRequest<Report[]>("GET", `/moderation/reports?${params.toString()}`, {
fetch,
cookies,
});
return { reports, url: url.toString(), byReporter, byTarget, before, after };
};

View file

@ -0,0 +1,125 @@
<script lang="ts">
import Link45deg from "svelte-bootstrap-icons/lib/Link45deg.svelte";
import Funnel from "svelte-bootstrap-icons/lib/Funnel.svelte";
import FunnelFill from "svelte-bootstrap-icons/lib/FunnelFill.svelte";
import type { PageData } from "./$types";
import { t } from "$lib/i18n";
import { DateTime } from "luxon";
import { idTimestamp } from "$lib";
type Props = { data: PageData };
let { data }: Props = $props();
const addReporter = (id: string | null) => {
const url = new URL(data.url);
if (id) url.searchParams.set("by-reporter", id);
else url.searchParams.delete("by-reporter");
return url.toString();
};
const addTarget = (id: string | null) => {
const url = new URL(data.url);
if (id) url.searchParams.set("by-target", id);
else url.searchParams.delete("by-target");
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>
<svelte:head>
<title>Reports • pronouns.cc</title>
</svelte:head>
<h2>Reports</h2>
<ul>
{#if data.byTarget}
<li>Filtering by target (<a href={addTarget(null)}>clear</a>)</li>
{/if}
{#if data.byReporter}
<li>Filtering by reporter (<a href={addReporter(null)}>clear</a>)</li>
{/if}
</ul>
{#if data.before}
<a href={addAfter(data.before)}>Show newer reports</a>
{/if}
<table class="table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">User</th>
<th scope="col">Member</th>
<th scope="col">Reporter</th>
<th scope="col">Reason</th>
<th scope="col">Context?</th>
<th scope="col">Created at</th>
</tr>
</thead>
<tbody>
{#each data.reports as report (report.id)}
<tr>
<td>
<a href="/admin/reports/{report.id}">
<Link45deg />
<span class="visually-hidden">Open report</span>
</a>
</td>
<td>
<a href="/@{report.target_user.username}">@{report.target_user.username}</a>
(<a href={addTarget(report.target_user.id)}>
{#if data.byTarget === report.target_user.id}<FunnelFill />{:else}<Funnel />{/if}
</a>)
</td>
<td>
{#if report.target_member}
<a href="@/{report.target_user.username}/{report.target_member.name}">
{report.target_member.name}
</a>
{:else}
<em>(none)</em>
{/if}
</td>
<td>
<a href="/@{report.reporter.username}">{report.reporter.username}</a>
(<a href={addReporter(report.reporter.id)}>
{#if data.byReporter === report.reporter.id}<FunnelFill />{:else}<Funnel />{/if}
</a>)
</td>
<td><code>{report.reason}</code></td>
<td>
{#if report.context}
{$t("yes")}
{:else}
{$t("no")}
{/if}
</td>
<td>
{idTimestamp(report.id).toLocaleString(DateTime.DATETIME_SHORT)}
</td>
</tr>
{/each}
</tbody>
</table>
{#if data.reports.length === 100}
<a href={addBefore(data.reports[data.reports.length - 1].id)}>Show older reports</a>
{/if}

View file

@ -0,0 +1,13 @@
import { apiRequest } from "$api";
import type { ReportDetails } from "$api/models/moderation";
import { createModactions } from "$lib/actions/modaction";
export const load = async ({ params, fetch, cookies }) => {
const resp = await apiRequest<ReportDetails>("GET", `/moderation/reports/${params.id}`, {
fetch,
cookies,
});
return { report: resp.report, user: resp.user, member: resp.member };
};
export const actions = createModactions();

View file

@ -0,0 +1,78 @@
<script lang="ts">
import type { Member } from "$api/models/member";
import type { User } from "$api/models/user";
import ActionForm from "$components/admin/ActionForm.svelte";
import PartialProfileCard from "$components/admin/PartialProfileCard.svelte";
import ProfileHeader from "$components/profile/ProfileHeader.svelte";
import MemberCard from "$components/profile/user/MemberCard.svelte";
import { renderMarkdown } from "$lib/markdown";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let { report, user, member } = $derived(data);
</script>
<svelte:head>
<title>Report on @{user.username} • pronouns.cc</title>
</svelte:head>
<div class="row">
<div class="col-md">
<h3>Target user</h3>
<PartialProfileCard user={report.target_user} />
</div>
{#if report.target_member}
<div class="col-md">
<h3>Target member</h3>
<MemberCard
username={report.target_user.username}
member={report.target_member}
allPreferences={{}}
/>
</div>
{/if}
<div class="col-md">
<h3>Reporter</h3>
<PartialProfileCard user={report.reporter} />
</div>
</div>
<div class="row">
<div class="col-md">
<h3>Reason</h3>
<p><code>{report.reason}</code></p>
</div>
<div class="col-md">
<h3>Context</h3>
<p>
{#if report.context}
{@html renderMarkdown(report.context)}
{:else}
<em>(no context given)</em>
{/if}
</p>
</div>
</div>
<div class="row">
<h3>Take action</h3>
<ActionForm
userId={report.target_user.id}
reportId={report.id}
memberId={report.target_member?.id}
{form}
/>
</div>
{#if report.snapshot}
<h3>Profile at time of report</h3>
<hr />
{#if report.target_type === "USER"}
{@const snapshot = report.snapshot as User}
<ProfileHeader profile={snapshot} name="@{snapshot.username}" />
{:else}
{@const snapshot = report.snapshot as Member}
<ProfileHeader profile={snapshot} name="{snapshot.name} (@{snapshot.user.username})" />
{/if}
{/if}