From 4d3435a275d9455dec6fa4804572c0a836e07867 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Wed, 12 Aug 2026 08:24:38 +0100 Subject: [PATCH] fix(admin): move the moderation preview off the public reader routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1340. Review of that change found the approach itself was the problem, not just details of it. #1340 let admins resolve in_review/rejected posts at their public URLs. That put the public reader — vote, bookmark and comment controls — on a post that may be about to be rejected, and `post.vote` has no status guard, so one misclick writes a vote and author reputation points onto content the moderator is in the middle of declining. It also stopped admins seeing the public site the way readers do, so checking whether a takedown took effect gave a 200 where everyone else gets a 404. And the rejected-post banner still promised authors "This post is not visible to anyone else", which that change had quietly made untrue. Preview now lives at /admin/moderation/preview/{id}: read-only, no engagement controls, inside the admin gate. The public reader routes and `postVisibilityFilter` go back to what they were, so the only people who can reach an unpublished post there are its author, as before. Two things this also fixes: - A link submission is judged on the member's own title, excerpt and body as much as on its destination. #1340 sent Preview straight off-site, so the Codú-side copy — where a spammer would put the payload — was never shown. The preview page renders both. - That off-site href skipped `safeExternalHref` and rel, so an externalUrl that never passed `httpUrl()` validation would run as a `javascript:` URL inside the authenticated admin session, and the destination received the admin surface as its referrer. Both are now handled the way every other external-href site in the repo does it. Body rendering moves to a shared `PostBody`, so the preview and the public reader cannot drift apart. --- app/(admin)/admin/moderation/_client.tsx | 50 ++---- .../moderation/preview/[postId]/page.tsx | 150 ++++++++++++++++++ app/(app)/[username]/[slug]/page.tsx | 35 ++-- app/(app)/d/[slug]/page.tsx | 33 ++-- components/ContentDetail/PostBody.tsx | 90 +++++++++++ components/ContentDetail/PostReader.tsx | 81 +--------- server/api/router/admin.ts | 8 +- server/lib/postVisibility.test.ts | 62 -------- server/lib/postVisibility.ts | 38 ----- 9 files changed, 302 insertions(+), 245 deletions(-) create mode 100644 app/(admin)/admin/moderation/preview/[postId]/page.tsx create mode 100644 components/ContentDetail/PostBody.tsx delete mode 100644 server/lib/postVisibility.test.ts delete mode 100644 server/lib/postVisibility.ts diff --git a/app/(admin)/admin/moderation/_client.tsx b/app/(admin)/admin/moderation/_client.tsx index b6a2592a..285bcc28 100644 --- a/app/(admin)/admin/moderation/_client.tsx +++ b/app/(admin)/admin/moderation/_client.tsx @@ -40,39 +40,21 @@ const reasonLabels: Record = { const chipBase = "rounded-full px-2 py-0.5 font-mono text-xs uppercase tracking-label"; -type PreviewablePost = { - type: string | null; - slug: string | null; - externalUrl: string | null; - authorUsername: string | null; -}; - -// Where to send a moderator to actually read the thing they're judging. -// Discussions and questions live under /d/; a shared link IS its destination, -// so it points off-site; everything else renders at /{username}/{slug}, where -// the reader grants admins the same bypass the author has — so an in_review -// post previews exactly as readers would eventually see it. -function postPreviewHref(post: PreviewablePost): string | null { - if (post.type === "link") return post.externalUrl; - if (!post.slug) return null; - if (post.type === "discussion" || post.type === "question") { - return `/d/${post.slug}`; - } - if (!post.authorUsername) return null; - return `/${post.authorUsername}/${post.slug}`; -} - -const PreviewLink = ({ post }: { post: PreviewablePost }) => { - const href = postPreviewHref(post); - if (!href) return null; - - return ( - - - Preview - - ); -}; +// Read the submission before deciding on it. The preview is an admin-side, +// read-only render (see app/(admin)/admin/moderation/preview/[postId]) rather +// than the public URL: an unapproved post has no public URL yet, and the public +// reader would put vote/bookmark/comment controls on a post that may be about +// to be rejected. Keyed by id, so it is available for every queued post. +const PreviewLink = ({ postId }: { postId: string }) => ( + + + Preview + +); // datetime-local is in the moderator's LOCAL time, so shift the `min` boundary // by the tz offset before slicing to "YYYY-MM-DDTHH:mm". @@ -310,7 +292,7 @@ const ModerationQueue = () => { )}
- +