diff --git a/Foxnouns.Backend/Database/Snowflake.cs b/Foxnouns.Backend/Database/Snowflake.cs index fc2188d..a0f127a 100644 --- a/Foxnouns.Backend/Database/Snowflake.cs +++ b/Foxnouns.Backend/Database/Snowflake.cs @@ -113,24 +113,30 @@ public readonly struct Snowflake(ulong value) : IEquatable ) => writer.WriteStringValue(value.Value.ToString()); } - private class JsonConverter : JsonConverter + private class JsonConverter : JsonConverter { 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 diff --git a/Foxnouns.Backend/Dto/Moderation.cs b/Foxnouns.Backend/Dto/Moderation.cs index e45c166..3792e31 100644 --- a/Foxnouns.Backend/Dto/Moderation.cs +++ b/Foxnouns.Backend/Dto/Moderation.cs @@ -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; @@ -80,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, diff --git a/Foxnouns.Backend/Services/ModerationService.cs b/Foxnouns.Backend/Services/ModerationService.cs index ff86a05..4e2afe6 100644 --- a/Foxnouns.Backend/Services/ModerationService.cs +++ b/Foxnouns.Backend/Services/ModerationService.cs @@ -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; diff --git a/Foxnouns.Frontend/src/lib/actions/modaction.ts b/Foxnouns.Frontend/src/lib/actions/modaction.ts new file mode 100644 index 0000000..15fd16f --- /dev/null +++ b/Foxnouns.Frontend/src/lib/actions/modaction.ts @@ -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; + +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("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), + }; +} diff --git a/Foxnouns.Frontend/src/lib/api/index.ts b/Foxnouns.Frontend/src/lib/api/index.ts index a23b68c..e52918a 100644 --- a/Foxnouns.Frontend/src/lib/api/index.ts +++ b/Foxnouns.Frontend/src/lib/api/index.ts @@ -81,6 +81,7 @@ export async function apiRequest( 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(); } diff --git a/Foxnouns.Frontend/src/lib/api/models/moderation.ts b/Foxnouns.Frontend/src/lib/api/models/moderation.ts index 852358e..edd0865 100644 --- a/Foxnouns.Frontend/src/lib/api/models/moderation.ts +++ b/Foxnouns.Frontend/src/lib/api/models/moderation.ts @@ -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", +} diff --git a/Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte b/Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte new file mode 100644 index 0000000..3c7f741 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte @@ -0,0 +1,73 @@ + + +
+ + {#if memberId} + + {/if} + {#if reportId} + + {/if} + + + + {#if reportId} + + + + {/if} + +
+ {#each fields as field} +
+ + +
+ {/each} +
+
+ +
+
+ +
+ + +
+ +
+
+ diff --git a/Foxnouns.Frontend/src/lib/components/admin/PartialProfileCard.svelte b/Foxnouns.Frontend/src/lib/components/admin/PartialProfileCard.svelte new file mode 100644 index 0000000..885ca82 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/admin/PartialProfileCard.svelte @@ -0,0 +1,29 @@ + + +
+ + + +

+ + @{user.username} + +

+

Created {createdAt}

+
diff --git a/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte b/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte index ec15087..8ffbdb6 100644 --- a/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte +++ b/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte @@ -1,10 +1,14 @@ + + diff --git a/Foxnouns.Frontend/src/routes/admin/+layout.server.ts b/Foxnouns.Frontend/src/routes/admin/+layout.server.ts index 7da4d36..38df461 100644 --- a/Foxnouns.Frontend/src/routes/admin/+layout.server.ts +++ b/Foxnouns.Frontend/src/routes/admin/+layout.server.ts @@ -18,7 +18,7 @@ export const load = async ({ parent, fetch, cookies }) => { const reports = await apiRequest("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 { diff --git a/Foxnouns.Frontend/src/routes/admin/reports/+page.server.ts b/Foxnouns.Frontend/src/routes/admin/reports/+page.server.ts new file mode 100644 index 0000000..a88121e --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/reports/+page.server.ts @@ -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("GET", `/moderation/reports?${params.toString()}`, { + fetch, + cookies, + }); + return { reports, url: url.toString(), byReporter, byTarget, before, after }; +}; diff --git a/Foxnouns.Frontend/src/routes/admin/reports/+page.svelte b/Foxnouns.Frontend/src/routes/admin/reports/+page.svelte new file mode 100644 index 0000000..3b76ce7 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/reports/+page.svelte @@ -0,0 +1,125 @@ + + + + Reports • pronouns.cc + + +

Reports

+ +
    + {#if data.byTarget} +
  • Filtering by target (clear)
  • + {/if} + {#if data.byReporter} +
  • Filtering by reporter (clear)
  • + {/if} +
+ +{#if data.before} + Show newer reports +{/if} + + + + + + + + + + + + + + + {#each data.reports as report (report.id)} + + + + + + + + + + {/each} + +
UserMemberReporterReasonContext?Created at
+ + + Open report + + + @{report.target_user.username} + ( + {#if data.byTarget === report.target_user.id}{:else}{/if} + ) + + {#if report.target_member} + + {report.target_member.name} + + {:else} + (none) + {/if} + + {report.reporter.username} + ( + {#if data.byReporter === report.reporter.id}{:else}{/if} + ) + {report.reason} + {#if report.context} + {$t("yes")} + {:else} + {$t("no")} + {/if} + + {idTimestamp(report.id).toLocaleString(DateTime.DATETIME_SHORT)} +
+ +{#if data.reports.length === 100} + Show older reports +{/if} diff --git a/Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.server.ts b/Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.server.ts new file mode 100644 index 0000000..a9a2d44 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.server.ts @@ -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("GET", `/moderation/reports/${params.id}`, { + fetch, + cookies, + }); + return { report: resp.report, user: resp.user, member: resp.member }; +}; + +export const actions = createModactions(); diff --git a/Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.svelte b/Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.svelte new file mode 100644 index 0000000..17bff1d --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.svelte @@ -0,0 +1,78 @@ + + + + Report on @{user.username} • pronouns.cc + + +
+
+

Target user

+ +
+ {#if report.target_member} +
+

Target member

+ +
+ {/if} +
+

Reporter

+ +
+
+ +
+
+

Reason

+

{report.reason}

+
+
+

Context

+

+ {#if report.context} + {@html renderMarkdown(report.context)} + {:else} + (no context given) + {/if} +

+
+
+ +
+

Take action

+ +
+ +{#if report.snapshot} +

Profile at time of report

+
+ {#if report.target_type === "USER"} + {@const snapshot = report.snapshot as User} + + {:else} + {@const snapshot = report.snapshot as Member} + + {/if} +{/if}