From 0d5afb32a1f007ff2cf5a0ef65a00f46d7880ada Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Wed, 12 Aug 2026 08:31:23 +0100 Subject: [PATCH 01/10] fix: moderation preview scope, comment vote races, and email link origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes, consolidated into one PR by request. 1. Move the moderation preview off the public reader routes (#1340 follow-up) #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 a misclick writes a vote and author points onto content the moderator is declining. It also stopped admins seeing the site the way readers do, and made the rejected-post banner's "not visible to anyone else" untrue. Preview now lives at /admin/moderation/preview/{id}: read-only, inside the admin gate. The public routes and their visibility filter revert. It also fixes the link path. #1340 sent Preview straight off-site, so the member's own title/excerpt/body — where a spammer would put the payload — was never shown; the preview renders both halves. And 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 page under review received the admin surface as its referrer. 2. Drop the frozen sort snapshot, serialise votes per comment (#1341 follow-up) Freezing sort scores was more than the fix needed and wrong on its own terms: counts still updated on refetch while the order did not, so a thread could show a 42-point comment below a 3-point one; the documented "re-pick the sort" escape hatch never fired, because selecting the already-selected option is a no-op; and the added tiebreak made Top identical to New on the common all-zero thread. Not refetching after a successful vote is the whole fix. Ordering is derived from the data on screen again, so it cannot contradict the counts beside it. Votes are serialised per comment (newest click wins), since #1341 dropped the in-flight guard without replacing it and overlapping writes could land in either order. The resync remount is per comment too. 3. Email links pointed at the deployment, not the site `getAppOrigin()` fell back to VERCEL_URL, which is the unique per-deployment hostname — and Vercel sets it in production too. With DOMAIN_NAME unset, every link it built (the admin's "post awaiting review" deep link, report emails, the verification link) went out as *.vercel.app. Production now resolves to the project's production domain, falling back to the canonical origin; preview deploys still get their own URL. The duplicate copy of this logic in utils/emailToken.ts is gone. --- 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 +--------- components/Discussion/DiscussionArea.tsx | 130 +++++++-------- server/api/router/admin.ts | 8 +- server/lib/postVisibility.test.ts | 62 -------- server/lib/postVisibility.ts | 38 ----- server/lib/url.test.ts | 62 +++++++- server/lib/url.ts | 36 ++++- utils/emailToken.ts | 10 +- 13 files changed, 447 insertions(+), 338 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 b6a2592ab..285bcc28e 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 = () => { )}
- + + {/* Stable hook for tests: the submit label ("Reply") is also the + label of every comment's expand-reply button, so picking this one + out by text alone is ambiguous once a thread has nesting. */}