diff --git a/.dockerignore b/.dockerignore index f90ce74..d755b6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,4 +20,5 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md \ No newline at end of file +README.md +static-pages/* diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 8552164..e22fbc1 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -12,6 +12,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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 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(); } diff --git a/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs b/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs index 8b556de..b2d0581 100644 --- a/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs +++ b/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs @@ -30,7 +30,9 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo public async Task 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 entries = await query.Take(limit!.Value).ToListAsync(); return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry)); } + + [HttpGet("moderators")] + public async Task 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); + } } diff --git a/Foxnouns.Backend/Dto/Moderation.cs b/Foxnouns.Backend/Dto/Moderation.cs index f9e6ab7..c9489ed 100644 --- a/Foxnouns.Backend/Dto/Moderation.cs +++ b/Foxnouns.Backend/Dto/Moderation.cs @@ -29,6 +29,7 @@ public record ReportResponse( PartialMember? TargetMember, ReportStatus Status, ReportReason Reason, + string? Context, ReportTargetType TargetType, JObject? Snapshot ); diff --git a/Foxnouns.Backend/Services/ModerationRendererService.cs b/Foxnouns.Backend/Services/ModerationRendererService.cs index deed9c5..04ef46b 100644 --- a/Foxnouns.Backend/Services/ModerationRendererService.cs +++ b/Foxnouns.Backend/Services/ModerationRendererService.cs @@ -36,6 +36,7 @@ public class ModerationRendererService( : null, report.Status, report.Reason, + report.Context, report.TargetType, report.TargetSnapshot != null ? JsonConvert.DeserializeObject(report.TargetSnapshot) diff --git a/Foxnouns.Backend/static-pages/.gitignore b/Foxnouns.Backend/static-pages/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/Foxnouns.Backend/static-pages/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/Foxnouns.Frontend/src/lib/api/models/moderation.ts b/Foxnouns.Frontend/src/lib/api/models/moderation.ts index b95da5c..f0e112b 100644 --- a/Foxnouns.Frontend/src/lib/api/models/moderation.ts +++ b/Foxnouns.Frontend/src/lib/api/models/moderation.ts @@ -1,3 +1,6 @@ +import type { Member } from "./member"; +import type { PartialMember, PartialUser, User } from "./user"; + export type CreateReportRequest = { reason: ReportReason; context: string | null; @@ -24,3 +27,35 @@ export enum ReportReason { 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", +} diff --git a/Foxnouns.Frontend/src/lib/components/Navbar.svelte b/Foxnouns.Frontend/src/lib/components/Navbar.svelte index 967d688..edfbd1a 100644 --- a/Foxnouns.Frontend/src/lib/components/Navbar.svelte +++ b/Foxnouns.Frontend/src/lib/components/Navbar.svelte @@ -58,6 +58,13 @@ @{user.username} + {#if user.role === "ADMIN" || user.role === "MODERATOR"} + + + Administration + + + {/if} {$t("nav.settings")} diff --git a/Foxnouns.Frontend/src/lib/components/admin/AuditLogEntity.svelte b/Foxnouns.Frontend/src/lib/components/admin/AuditLogEntity.svelte new file mode 100644 index 0000000..1f3645f --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/admin/AuditLogEntity.svelte @@ -0,0 +1,8 @@ + + +{entity.username} ({entity.id}) diff --git a/Foxnouns.Frontend/src/lib/components/admin/AuditLogEntryCard.svelte b/Foxnouns.Frontend/src/lib/components/admin/AuditLogEntryCard.svelte new file mode 100644 index 0000000..2391b57 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/admin/AuditLogEntryCard.svelte @@ -0,0 +1,50 @@ + + + + Audit log + + +
+
+ + + {#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 {entry.type}) + {/if} + {#if entry.target_user} + + {/if} + {#if entry.target_member} + for member + {/if} + + + {date} +
+ {#if reason} +
+ Reason + {@html reason} +
+ {:else} + (no reason given) + {/if} +
diff --git a/Foxnouns.Frontend/src/lib/components/admin/DashboardCard.svelte b/Foxnouns.Frontend/src/lib/components/admin/DashboardCard.svelte new file mode 100644 index 0000000..a6d04e4 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/admin/DashboardCard.svelte @@ -0,0 +1,17 @@ + + +
+
+
+
{title}
+

+ {@render children()} +

+
+
+
diff --git a/Foxnouns.Frontend/src/lib/markdown.ts b/Foxnouns.Frontend/src/lib/markdown.ts index 94a1a05..9c4ff35 100644 --- a/Foxnouns.Frontend/src/lib/markdown.ts +++ b/Foxnouns.Frontend/src/lib/markdown.ts @@ -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); diff --git a/Foxnouns.Frontend/src/lib/pageUtils.svelte.ts b/Foxnouns.Frontend/src/lib/pageUtils.svelte.ts new file mode 100644 index 0000000..5f45815 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/pageUtils.svelte.ts @@ -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); diff --git a/Foxnouns.Frontend/src/routes/admin/+layout.server.ts b/Foxnouns.Frontend/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..7da4d36 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/+layout.server.ts @@ -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("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, + }; +}; diff --git a/Foxnouns.Frontend/src/routes/admin/+layout.svelte b/Foxnouns.Frontend/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..0c0b247 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/+layout.svelte @@ -0,0 +1,50 @@ + + + diff --git a/Foxnouns.Frontend/src/routes/admin/+page.svelte b/Foxnouns.Frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..79df014 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/+page.svelte @@ -0,0 +1,23 @@ + + +

Dashboard

+ +
+ + {data.meta.users.total.toLocaleString("en")} +
+ ({data.meta.users.active_month.toLocaleString("en")} active in the last month) +
+ {data.meta.members.toLocaleString("en")} + + {data.reportCount.toLocaleString("en")} +
+ ({data.staleReportCount} older than 1 week) +
+
diff --git a/Foxnouns.Frontend/src/routes/admin/audit-log/+page.server.ts b/Foxnouns.Frontend/src/routes/admin/audit-log/+page.server.ts new file mode 100644 index 0000000..f48e334 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/audit-log/+page.server.ts @@ -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( + "GET", + `/moderation/audit-log?${params.toString()}`, + { + fetch, + cookies, + }, + ); + + const moderators = await apiRequest("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 }; +}; diff --git a/Foxnouns.Frontend/src/routes/admin/audit-log/+page.svelte b/Foxnouns.Frontend/src/routes/admin/audit-log/+page.svelte new file mode 100644 index 0000000..a0e182d --- /dev/null +++ b/Foxnouns.Frontend/src/routes/admin/audit-log/+page.svelte @@ -0,0 +1,105 @@ + + +

Audit log

+ +
+ + + Filter by type + + + + Ignore report + + + Warn user + + + Warn user and clear profile + + + Suspend user + + {#if data.type} + Remove filter + {/if} + + + + + Filter by moderator + + + {#each data.moderators as mod (mod.id)} + + {mod.username} + + {/each} + {#if data.modFilter} + Remove filter + {/if} + + +
+ +{#if data.before} + Show newer entries +{/if} + +{#each data.entries as entry (entry.id)} + +{:else} +

There are no entries matching your filter

+{/each} + +{#if data.entries.length === 100} + Show older entries +{/if} diff --git a/Foxnouns.Frontend/src/routes/page/[page]/+page.server.ts b/Foxnouns.Frontend/src/routes/page/[page]/+page.server.ts new file mode 100644 index 0000000..1d9e8fc --- /dev/null +++ b/Foxnouns.Frontend/src/routes/page/[page]/+page.server.ts @@ -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 }; +}; diff --git a/Foxnouns.Frontend/src/routes/page/[page]/+page.svelte b/Foxnouns.Frontend/src/routes/page/[page]/+page.svelte new file mode 100644 index 0000000..a156d0a --- /dev/null +++ b/Foxnouns.Frontend/src/routes/page/[page]/+page.svelte @@ -0,0 +1,22 @@ + + + + {title} • pronouns.cc + + +
+ {@html md} +
diff --git a/Foxnouns.Frontend/src/routes/settings/+layout.svelte b/Foxnouns.Frontend/src/routes/settings/+layout.svelte index 11801cf..8f18c8e 100644 --- a/Foxnouns.Frontend/src/routes/settings/+layout.svelte +++ b/Foxnouns.Frontend/src/routes/settings/+layout.svelte @@ -1,20 +1,11 @@ diff --git a/Foxnouns.Frontend/src/routes/settings/members/[id]/+layout@.svelte b/Foxnouns.Frontend/src/routes/settings/members/[id]/+layout@.svelte index faae426..f3f4301 100644 --- a/Foxnouns.Frontend/src/routes/settings/members/[id]/+layout@.svelte +++ b/Foxnouns.Frontend/src/routes/settings/members/[id]/+layout@.svelte @@ -1,14 +1,12 @@ diff --git a/docker-compose.yml b/docker-compose.yml index 4fc94bb..751d919 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: - "5007:5001" volumes: - ./docker/config.ini:/app/config.ini + - ./docker/static-pages:/app/static-pages frontend: image: frontend diff --git a/docker/static-pages/.gitignore b/docker/static-pages/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/docker/static-pages/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore