Skip to content

Commit ca00645

Browse files
fix: moderation preview scope, comment vote races, and email link origin (#1344)
* fix: moderation preview scope, comment vote races, and email link origin 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. * fix: address review of the consolidated fixes Review of #1344 found nine issues, three of them mine from the previous round. Draft exposure (the serious one): the new preview route loaded any post by id with no status predicate, so an admin with a post id could read a member's private, never-submitted draft. It is now restricted to work that actually entered the pipeline — published, in_review, rejected — and `postVisibility.ts` is back, minus the admin bypass, carrying tests for both rules including "never exposes a draft, whoever is looking". Deleting that module was never required to drop the bypass, and doing so had also left the same rule inlined twice in two different shapes. Failed votes stranded earlier ones: I removed the pre-remount refetch on the reasoning that the cache never saw the failed vote. That is true of the failed vote but not of earlier successful ones, which never refetch by design — so remounting reseeded the control from a cache that predates them. The refetch is back, before the key bump. Comment ordering on ties was arbitrary: the "Top" comparator leaned on sort stability to keep "the server's order", but the server orders by ltree path, built from a random uuid. On a thread where most scores are 0, that is no order at all. Ties now sort oldest-first, so Top degrades to chronological rather than to a copy of New. Reordering under the reader: with the score snapshot gone, a window-focus refetch could re-rank the thread mid-read. That query no longer refetches on focus; the explicit refetches after create/edit/delete stay, since those follow something the reader did. Also: raw tRPC error text could reach the vote toast (only the rate-limit message, which is written for readers, is surfaced now); a malformed post id 500'd on the uuid cast instead of 404ing; the preview's two queries ran serially when both key off the route param; and a fork deploying to production without VERCEL_PROJECT_PRODUCTION_URL got codu.co hardcoded over its own configured NEXTAUTH_URL. * ci(e2e): serve a production build instead of the dev server The suite has been failing on CI while staying green locally: 19 failures spread across admin navigation, the editor publish flow, bookmarking, the feed sidebar and moderation. Identical failures on develop and on every branch off it, so nothing in the feature work caused them. They all share a cause. Playwright's webServer ran `next dev`, so the first request to each route blocked on an on-demand Turbopack compile. Locally that is under a second; on a cold runner with three workers compiling at once it outlasts the 10s expect timeout — hence assertions like `toHaveURL(/admin/users)` polling 13 times and giving up while the navigation was still compiling. CI now builds once and serves it. Measured on the same machine, a cold route costs ~0.7-1.2s under `next dev` and ~0.02-0.03s prebuilt. Local runs keep `next dev` for the fast feedback loop. EMAIL_AUTH_ENABLED is set for the job because a production build runs with NODE_ENV=production, which would otherwise disable the passwordless provider that dev turns on implicitly. * fix: review round three, and build the app inside the e2e webServer The e2e harness fix in the previous commit did not take. The job is triggered by `pull_request_target`, which takes the WORKFLOW from the base branch and the CODE from the PR head — so the build step added to the workflow never ran, while playwright.config (from the head) had already switched to serving a prebuilt app. Result: "Could not find a production build". The build now happens inside the webServer command, where head and workflow cannot disagree, and the env it needs moved into the npm scripts for the same reason. Review fixes: - moderationPreviewFilter missed `scheduled` and `unlisted`, so preview 404'd on a post the admin had just approved-with-schedule. The status list is now derived from the enum instead of hand-listed. - The "top" tie-break went back to newest-first. Oldest-first read better as conversation order but buried a comment the moment you posted it, which is worse than Top resembling New on an unvoted thread. - create/edit awaited react-query's void `mutate`, so their try/catch was dead and the editor cleared before the request finished: a failed post discarded what you typed, silently. Both use mutateAsync now. - The preview fetched the cover image and never rendered it. Clean body copy under an abusive image would have sailed through; it is shown now, behind the same scheme guard as the external URL. - PostBody rendered the site-wide 404 component for an empty tiptap body, which put a "page not found" panel inside the admin shell. The empty state is the caller's to choose now. - getAppOrigin fell back to the hardcoded codu.co ahead of the deployment's own URL, so an unconfigured fork mailed its users to this site. - A failed vote now clears its queued follow-up explicitly. Accepted, not fixed: the thread no longer refetches on window focus, so an open tab does not pick up other people's comments until you post, navigate or reload. That is the cost of not moving comments under someone mid-read. * ci(e2e): trust the request host when serving the production build The prebuilt server ran, but every authenticated test failed: NextAuth rejects the request host under NODE_ENV=production unless AUTH_TRUST_HOST is set, so /api/auth/session returned UntrustedHost, session.user was undefined, and pages blew up on `session.user.username`. 159 failures. Dev never hits this — it trusts the host implicitly — which is why the suite passed locally. My earlier local check passed for the wrong reason: I had happened to pass AUTH_URL on the command line, which also satisfies the trust check, so the gap only showed up on CI where it is not set. Reproduced locally against a production build: without AUTH_TRUST_HOST, /admin redirects and the log carries UntrustedHost; with it, /admin and /admin/moderation both return 200 and the log is clean. * test(e2e): let the suite point at a throwaway database `setup.ts` and `teardown.ts` hardcoded localhost:5432/postgres, which is also the dev database. Running the suite locally therefore writes fixtures into whatever you have been working on, so the remaining pre-existing failures cannot be debugged without risking your own data. Both now honour DATABASE_URL, falling back to the same string, so CI is unaffected and a local run can be aimed at a scratch database instead. * test(e2e): enable moderation, fix the sidebar viewport, centralise the DB url Three causes behind the long-standing failures, found by running the suite locally against a throwaway database. MODERATION_ENABLED was never set for the e2e app. The whole pipeline — publish gating, the review queue, link dedupe — is behind that flag, so e2e/moderation.spec.ts was asserting behaviour the server had switched off. All four of its tests pass with the flag on. The feed sidebar test asserted the right rail is visible on any non-mobile viewport, but .app-main folds the rail away under 1300px and Playwright's desktop viewport is 1280 — so it was asserting against a width where the rail is correctly hidden. The test now widens past the breakpoint. Four helpers in e2e/utils/utils.ts still hardcoded the connection string that the file had already centralised, so a run aimed at a scratch DATABASE_URL wrote its fixtures into the default database instead and then failed on foreign keys. Local suite on Desktop Chrome: 129 passed, 1 failed (a flaky multi-user notification test), down from 6 failures before these changes. * test(e2e): give the bookmark specs their own fixture per browser project Bookmarks are per-user and all four browser projects run as the same e2e user, so a shared article made the bookmark specs race each other: one project saved it while another was asserting it was still unsaved. That is why they failed on Firefox and mobile but never on Desktop Chrome, which happened to get there first. saved.spec.ts already ran serially, but serial mode only orders tests within a project, not across them. Each project now creates and cleans up its own article. Reproduced the failure locally across all four projects, and all 52 tests in those two files pass afterwards. The /saved assertion no longer needs its "or the empty state" escape hatch either, since nothing else can unbookmark it. Also gives the discussion editor's submit button a data-testid. "Reply" is the label of both the editor's submit button and every comment's expand-reply button, so the notification spec was picking it out with .last() — ambiguous as soon as a comment has nested children. That spec still fails intermittently for a separate reason (its reply lands as a top-level comment, so the server correctly raises "commented on your post" rather than "replied to your comment"); it retries green and is left for a follow-up rather than papered over here. * test(e2e): check the card's content link, not its first anchor The routeless-/[id] guard took the FIRST anchor inside a content card, but that is the author handle ("/{username}") — a real page that nonetheless reads as a bare single-segment path to the shape check, so the test failed with 'href "/e2e-test-user-one-111" looks like a routeless /[id] page'. It only surfaced on some browsers because which card ranks first varies, and a source card's handle link ("/s/{slug}") has two segments and slips through. The card's content link now carries data-testid="content-card-link" and the test targets that, so it checks the link the guard is actually about. All 44 tests in the file pass across all four browser projects. * test(e2e): bypass rate limits, and wait for comments to actually post Two causes behind the long tail of failures, found once the suite could be run locally against a throwaway database on the Node version .nvmrc pins. `discussion-create` allows 10 comments per 10 minutes per user, and the suite drives all four browser projects through the same two seeded users — so later projects were silently throttled, their comments never reached the database, and the tests that depend on them failed. Rate limiting now has an explicit `RATE_LIMIT_DISABLED` bypass that the e2e scripts set. It is guarded on that one env var and documented as test-only. The notification specs asserted a comment had posted with `getByText(commentText)`, which also matches the editor's own textarea — so it resolved the instant `fill()` ran, before the request had been sent. The test then switched users mid-flight, and the comment landed under whoever was authenticated by the time it went out: the "user one comments, user two replies" scenario ended up with both comments written by user two, replying to itself, so no reply notification was ever generated. They now wait for the create response and assert on a rendered `section.group/comment`. Local suite across all four browser projects, fresh database: 518 passed, 2 failed — both Mobile Safari, a publish redirect and a dedupe toast — down from 19 failures.
1 parent 33771d3 commit ca00645

26 files changed

Lines changed: 755 additions & 369 deletions

File tree

app/(admin)/admin/moderation/_client.tsx

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -40,39 +40,21 @@ const reasonLabels: Record<ReportReason, string> = {
4040
const chipBase =
4141
"rounded-full px-2 py-0.5 font-mono text-xs uppercase tracking-label";
4242

43-
type PreviewablePost = {
44-
type: string | null;
45-
slug: string | null;
46-
externalUrl: string | null;
47-
authorUsername: string | null;
48-
};
49-
50-
// Where to send a moderator to actually read the thing they're judging.
51-
// Discussions and questions live under /d/; a shared link IS its destination,
52-
// so it points off-site; everything else renders at /{username}/{slug}, where
53-
// the reader grants admins the same bypass the author has — so an in_review
54-
// post previews exactly as readers would eventually see it.
55-
function postPreviewHref(post: PreviewablePost): string | null {
56-
if (post.type === "link") return post.externalUrl;
57-
if (!post.slug) return null;
58-
if (post.type === "discussion" || post.type === "question") {
59-
return `/d/${post.slug}`;
60-
}
61-
if (!post.authorUsername) return null;
62-
return `/${post.authorUsername}/${post.slug}`;
63-
}
64-
65-
const PreviewLink = ({ post }: { post: PreviewablePost }) => {
66-
const href = postPreviewHref(post);
67-
if (!href) return null;
68-
69-
return (
70-
<Link href={href} target="_blank" className="secondary-button">
71-
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
72-
Preview
73-
</Link>
74-
);
75-
};
43+
// Read the submission before deciding on it. The preview is an admin-side,
44+
// read-only render (see app/(admin)/admin/moderation/preview/[postId]) rather
45+
// than the public URL: an unapproved post has no public URL yet, and the public
46+
// reader would put vote/bookmark/comment controls on a post that may be about
47+
// to be rejected. Keyed by id, so it is available for every queued post.
48+
const PreviewLink = ({ postId }: { postId: string }) => (
49+
<Link
50+
href={`/admin/moderation/preview/${postId}`}
51+
target="_blank"
52+
className="secondary-button"
53+
>
54+
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
55+
Preview
56+
</Link>
57+
);
7658

7759
// datetime-local is in the moderator's LOCAL time, so shift the `min` boundary
7860
// by the tz offset before slicing to "YYYY-MM-DDTHH:mm".
@@ -310,7 +292,7 @@ const ModerationQueue = () => {
310292
)}
311293
</div>
312294
<div className="flex shrink-0 flex-wrap gap-2">
313-
<PreviewLink post={post} />
295+
<PreviewLink postId={post.id} />
314296
<button
315297
className="primary-button"
316298
disabled={isModerating}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import Link from "next/link";
2+
import { notFound } from "next/navigation";
3+
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
4+
import { and, eq } from "drizzle-orm";
5+
import z from "zod";
6+
import { db } from "@/server/db";
7+
import { posts, user, post_tags, tag } from "@/server/db/schema";
8+
import { PostBody, renderPostBody } from "@/components/ContentDetail/PostBody";
9+
import { getCamelCaseFromLower } from "@/utils/utils";
10+
import { safeExternalHref } from "@/utils/url";
11+
import { moderationPreviewFilter } from "@/server/lib/postVisibility";
12+
13+
export const metadata = {
14+
title: "Preview - Codú Admin",
15+
description: "Read a submission before approving or declining it",
16+
robots: { index: false, follow: false },
17+
};
18+
19+
type Props = { params: Promise<{ postId: string }> };
20+
21+
// Read-only preview of a submission, for deciding whether it belongs on the
22+
// site. It deliberately lives inside `(admin)` rather than exposing unpublished
23+
// posts on the public reader routes: moderators need to READ a post, not vote,
24+
// bookmark or comment on one that may be about to be rejected — and admins
25+
// should still see the public site exactly as readers do.
26+
//
27+
// The body renders through the same `PostBody` the reader uses, so what a
28+
// moderator approves is what readers will get.
29+
//
30+
// Admin-role gate is enforced in app/(admin)/layout.tsx.
31+
export default async function Page({ params }: Props) {
32+
const { postId } = await params;
33+
34+
// posts.id is a uuid column, so a mistyped or truncated id would make
35+
// Postgres throw a cast error (a 500) before the not-found check below.
36+
if (!z.string().uuid().safeParse(postId).success) notFound();
37+
38+
const [rows, tags] = await Promise.all([
39+
db
40+
.select({
41+
id: posts.id,
42+
title: posts.title,
43+
body: posts.body,
44+
excerpt: posts.excerpt,
45+
type: posts.type,
46+
status: posts.status,
47+
externalUrl: posts.externalUrl,
48+
coverImage: posts.coverImage,
49+
readingTime: posts.readingTime,
50+
moderationNote: posts.moderationNote,
51+
authorUsername: user.username,
52+
})
53+
.from(posts)
54+
.leftJoin(user, eq(posts.authorId, user.id))
55+
// Submitted work only. A moderator has business reading anything that
56+
// entered the pipeline; a private draft is not that.
57+
.where(and(eq(posts.id, postId), moderationPreviewFilter()))
58+
.limit(1),
59+
db
60+
.select({ title: tag.title, slug: tag.slug })
61+
.from(post_tags)
62+
.innerJoin(tag, eq(post_tags.tagId, tag.id))
63+
.where(eq(post_tags.postId, postId)),
64+
]);
65+
66+
const record = rows[0];
67+
if (!record) notFound();
68+
69+
const renderedBody = renderPostBody(record.body);
70+
const externalHref = safeExternalHref(record.externalUrl);
71+
// Member-supplied, like externalUrl — same scheme guard applies.
72+
const coverHref = safeExternalHref(record.coverImage);
73+
74+
return (
75+
<div className="mx-auto max-w-3xl px-0 py-4 sm:px-4 sm:py-8">
76+
<div className="mb-6 flex items-center gap-4">
77+
<Link
78+
href="/admin/moderation"
79+
className="rounded-lg p-2 text-muted transition-colors hover:bg-elevated hover:text-fg"
80+
>
81+
<ArrowLeftIcon className="h-5 w-5" />
82+
</Link>
83+
<div className="min-w-0">
84+
<p className="eyebrow">
85+
<span className="slash">{"// "}</span>preview
86+
</p>
87+
<h1 className="mt-1 font-display text-2xl font-extrabold tracking-tight text-fg">
88+
{record.title || "Untitled"}
89+
</h1>
90+
<p className="mt-1 font-mono text-xs text-faint">
91+
{record.type} · {record.status} · @
92+
{record.authorUsername ?? "unknown"}
93+
{record.readingTime ? ` · ${record.readingTime} min read` : ""}
94+
</p>
95+
</div>
96+
</div>
97+
98+
{record.moderationNote && (
99+
<p className="mb-6 rounded-lg border border-hairline bg-inset p-3 text-sm text-muted">
100+
<span className="font-medium text-fg">Flagged:</span>{" "}
101+
{record.moderationNote}
102+
</p>
103+
)}
104+
105+
{record.excerpt && (
106+
<p className="mb-6 text-base text-muted">{record.excerpt}</p>
107+
)}
108+
109+
{/* The cover image is the most visible part of a post on feed and profile
110+
cards, so a moderator has to see it before approving — clean body copy
111+
under an abusive image would otherwise sail through. */}
112+
{coverHref && (
113+
// eslint-disable-next-line @next/next/no-img-element
114+
<img
115+
src={coverHref}
116+
alt=""
117+
className="mb-6 max-h-80 w-full rounded-lg border border-hairline object-cover"
118+
/>
119+
)}
120+
121+
{/* A link submission is judged on both halves: the member's own framing
122+
above, and the destination. rel/noreferrer keep the admin surface out
123+
of the referrer of a page that is under review precisely because it
124+
may be hostile. */}
125+
{record.type === "link" &&
126+
(externalHref ? (
127+
<p className="mb-6 break-all font-mono text-sm">
128+
<span className="text-faint">{"// destination "}</span>
129+
<a
130+
href={externalHref}
131+
target="_blank"
132+
rel="noopener noreferrer nofollow"
133+
className="text-accent underline"
134+
>
135+
{externalHref}
136+
</a>
137+
</p>
138+
) : (
139+
<p className="mb-6 font-mono text-sm text-danger">
140+
{"// destination missing or not a http(s) URL: "}
141+
{record.externalUrl ?? "none"}
142+
</p>
143+
))}
144+
145+
{tags.length > 0 && (
146+
<div className="mb-6 flex flex-wrap gap-2">
147+
{tags.map((t) => (
148+
<span
149+
key={t.title}
150+
className="rounded-sm border border-hairline px-2.5 py-0.5 font-mono text-xs text-muted"
151+
>
152+
{getCamelCaseFromLower(t.title)}
153+
</span>
154+
))}
155+
</div>
156+
)}
157+
158+
{record.body ? (
159+
<article className="prose max-w-none dark:prose-invert">
160+
<PostBody
161+
{...renderedBody}
162+
emptyFallback={
163+
<p className="font-mono text-sm text-faint">
164+
{"// body is empty"}
165+
</p>
166+
}
167+
/>
168+
</article>
169+
) : (
170+
<p className="font-mono text-sm text-faint">{"// no body submitted"}</p>
171+
)}
172+
173+
<p className="mt-8 font-mono text-xs text-faint">
174+
{"// read-only — approve or decline from the queue"}
175+
</p>
176+
</div>
177+
);
178+
}

app/(app)/[username]/[slug]/page.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ async function getUserPostUncached(
3232
username: string,
3333
postSlug: string,
3434
viewerId?: string | null,
35-
viewerIsAdmin = false,
3635
) {
3736
// Case-insensitive handle resolution (GitHub-style), matching the profile page.
3837
const userRecord = await db.query.user.findFirst({
@@ -42,7 +41,7 @@ async function getUserPostUncached(
4241

4342
if (!userRecord) return null;
4443

45-
const visibilityFilter = postVisibilityFilter({ viewerId, viewerIsAdmin });
44+
const visibilityFilter = postVisibilityFilter({ viewerId });
4645

4746
const postResults = await db
4847
.select({
@@ -363,12 +362,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
363362

364363
// Same viewerId as the page body so the cache()d resolver runs once per request.
365364
const session = await getServerAuthSession();
366-
const userPost = await getUserPost(
367-
username,
368-
slug,
369-
session?.user?.id,
370-
session?.user?.role === "ADMIN",
371-
);
365+
const userPost = await getUserPost(username, slug, session?.user?.id);
372366
if (userPost) {
373367
// Discussions/questions canonicalize to /d/{slug}; redirect before metadata.
374368
if (isDiscussionKind(userPost.type)) {
@@ -525,12 +519,7 @@ const UnifiedPostPage = async (props: Props) => {
525519

526520
const host = (await headers()).get("host") || "";
527521

528-
const userPost = await getUserPost(
529-
username,
530-
slug,
531-
session?.user?.id,
532-
session?.user?.role === "ADMIN",
533-
);
522+
const userPost = await getUserPost(username, slug, session?.user?.id);
534523

535524
if (userPost) {
536525
// Discussions/questions live under /d/{slug} — redirect before rendering.

app/(app)/d/[slug]/page.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ type Props = { params: Promise<{ slug: string }> };
2727
async function getDiscussionPostUncached(
2828
slug: string,
2929
viewerId?: string | null,
30-
viewerIsAdmin = false,
3130
): Promise<ReaderPost | null> {
3231
const urlId = parseUrlId(slug);
3332
if (!urlId) return null;
@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
6867
and(
6968
idMatch,
7069
inArray(posts.type, ["discussion", "question"]),
71-
postVisibilityFilter({ viewerId, viewerIsAdmin }),
70+
postVisibilityFilter({ viewerId }),
7271
),
7372
)
7473
.limit(1);
@@ -147,11 +146,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
147146
const { slug } = await props.params;
148147
// Same viewerId as the page body so the cache()d resolver runs once per request.
149148
const session = await getServerAuthSession();
150-
const post = await getDiscussionPost(
151-
slug,
152-
session?.user?.id,
153-
session?.user?.role === "ADMIN",
154-
);
149+
const post = await getDiscussionPost(slug, session?.user?.id);
155150

156151
if (!post) {
157152
return { title: "Discussion Not Found" };
@@ -201,11 +196,7 @@ const DiscussionPage = async (props: Props) => {
201196
const { slug } = await props.params;
202197
const session = await getServerAuthSession();
203198

204-
const post = await getDiscussionPost(
205-
slug,
206-
session?.user?.id,
207-
session?.user?.role === "ADMIN",
208-
);
199+
const post = await getDiscussionPost(slug, session?.user?.id);
209200

210201
if (!post) return notFound();
211202

0 commit comments

Comments
 (0)