Skip to content

Commit 0d5afb3

Browse files
committed
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.
1 parent 33771d3 commit 0d5afb3

13 files changed

Lines changed: 447 additions & 338 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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import Link from "next/link";
2+
import { notFound } from "next/navigation";
3+
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
4+
import { eq } from "drizzle-orm";
5+
import { db } from "@/server/db";
6+
import { posts, user, post_tags, tag } from "@/server/db/schema";
7+
import { PostBody, renderPostBody } from "@/components/ContentDetail/PostBody";
8+
import { getCamelCaseFromLower } from "@/utils/utils";
9+
import { safeExternalHref } from "@/utils/url";
10+
11+
export const metadata = {
12+
title: "Preview - Codú Admin",
13+
description: "Read a submission before approving or declining it",
14+
robots: { index: false, follow: false },
15+
};
16+
17+
type Props = { params: Promise<{ postId: string }> };
18+
19+
// Read-only preview of a submission, for deciding whether it belongs on the
20+
// site. It deliberately lives inside `(admin)` rather than exposing unpublished
21+
// posts on the public reader routes: moderators need to READ a post, not vote,
22+
// bookmark or comment on one that may be about to be rejected — and admins
23+
// should still see the public site exactly as readers do.
24+
//
25+
// The body renders through the same `PostBody` the reader uses, so what a
26+
// moderator approves is what readers will get.
27+
//
28+
// Admin-role gate is enforced in app/(admin)/layout.tsx.
29+
export default async function Page({ params }: Props) {
30+
const { postId } = await params;
31+
32+
const [record] = await db
33+
.select({
34+
id: posts.id,
35+
title: posts.title,
36+
body: posts.body,
37+
excerpt: posts.excerpt,
38+
type: posts.type,
39+
status: posts.status,
40+
slug: posts.slug,
41+
externalUrl: posts.externalUrl,
42+
coverImage: posts.coverImage,
43+
readingTime: posts.readingTime,
44+
createdAt: posts.createdAt,
45+
moderationNote: posts.moderationNote,
46+
authorName: user.name,
47+
authorUsername: user.username,
48+
})
49+
.from(posts)
50+
.leftJoin(user, eq(posts.authorId, user.id))
51+
.where(eq(posts.id, postId))
52+
.limit(1);
53+
54+
if (!record) notFound();
55+
56+
const renderedBody = renderPostBody(record.body);
57+
const externalHref = safeExternalHref(record.externalUrl);
58+
59+
const tags = await 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, record.id));
64+
65+
return (
66+
<div className="mx-auto max-w-3xl px-0 py-4 sm:px-4 sm:py-8">
67+
<div className="mb-6 flex items-center gap-4">
68+
<Link
69+
href="/admin/moderation"
70+
className="rounded-lg p-2 text-muted transition-colors hover:bg-elevated hover:text-fg"
71+
>
72+
<ArrowLeftIcon className="h-5 w-5" />
73+
</Link>
74+
<div className="min-w-0">
75+
<p className="eyebrow">
76+
<span className="slash">{"// "}</span>preview
77+
</p>
78+
<h1 className="mt-1 font-display text-2xl font-extrabold tracking-tight text-fg">
79+
{record.title || "Untitled"}
80+
</h1>
81+
<p className="mt-1 font-mono text-xs text-faint">
82+
{record.type} · {record.status} · @
83+
{record.authorUsername ?? "unknown"}
84+
{record.readingTime ? ` · ${record.readingTime} min read` : ""}
85+
</p>
86+
</div>
87+
</div>
88+
89+
{record.moderationNote && (
90+
<p className="mb-6 rounded-lg border border-hairline bg-inset p-3 text-sm text-muted">
91+
<span className="font-medium text-fg">Flagged:</span>{" "}
92+
{record.moderationNote}
93+
</p>
94+
)}
95+
96+
{record.excerpt && (
97+
<p className="mb-6 text-base text-muted">{record.excerpt}</p>
98+
)}
99+
100+
{/* A link submission is judged on both halves: the member's own framing
101+
above, and the destination. rel/noreferrer keep the admin surface out
102+
of the referrer of a page that is under review precisely because it
103+
may be hostile. */}
104+
{record.type === "link" &&
105+
(externalHref ? (
106+
<p className="mb-6 break-all font-mono text-sm">
107+
<span className="text-faint">{"// destination "}</span>
108+
<a
109+
href={externalHref}
110+
target="_blank"
111+
rel="noopener noreferrer nofollow"
112+
className="text-accent underline"
113+
>
114+
{externalHref}
115+
</a>
116+
</p>
117+
) : (
118+
<p className="mb-6 font-mono text-sm text-danger">
119+
{"// destination missing or not a http(s) URL: "}
120+
{record.externalUrl ?? "none"}
121+
</p>
122+
))}
123+
124+
{tags.length > 0 && (
125+
<div className="mb-6 flex flex-wrap gap-2">
126+
{tags.map((t) => (
127+
<span
128+
key={t.title}
129+
className="rounded-sm border border-hairline px-2.5 py-0.5 font-mono text-xs text-muted"
130+
>
131+
{getCamelCaseFromLower(t.title)}
132+
</span>
133+
))}
134+
</div>
135+
)}
136+
137+
{record.body ? (
138+
<article className="prose max-w-none dark:prose-invert">
139+
<PostBody {...renderedBody} />
140+
</article>
141+
) : (
142+
<p className="font-mono text-sm text-faint">{"// no body submitted"}</p>
143+
)}
144+
145+
<p className="mt-8 font-mono text-xs text-faint">
146+
{"// read-only — approve or decline from the queue"}
147+
</p>
148+
</div>
149+
);
150+
}

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

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@ import { type Metadata } from "next";
66
import { SITE_ORIGIN } from "@/config/site";
77
import { db } from "@/server/db";
88
import { posts, user, feed_sources, post_tags, tag } from "@/server/db/schema";
9-
import { eq, and, lte, inArray, sql } from "drizzle-orm";
9+
import { eq, and, lte, inArray, or, sql } from "drizzle-orm";
1010
import UserLinkDetail from "./_userLinkDetail";
1111
import PostReader from "@/components/ContentDetail/PostReader";
1212
import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
13-
import { postVisibilityFilter } from "@/server/lib/postVisibility";
1413
import { serverApi } from "@/server/trpc/caller";
1514
import { JsonLd } from "@/components/JsonLd";
1615
import { getArticleSchema, getBreadcrumbSchema } from "@/lib/structured-data";
@@ -32,7 +31,6 @@ async function getUserPostUncached(
3231
username: string,
3332
postSlug: string,
3433
viewerId?: string | null,
35-
viewerIsAdmin = false,
3634
) {
3735
// Case-insensitive handle resolution (GitHub-style), matching the profile page.
3836
const userRecord = await db.query.user.findFirst({
@@ -42,7 +40,22 @@ async function getUserPostUncached(
4240

4341
if (!userRecord) return null;
4442

45-
const visibilityFilter = postVisibilityFilter({ viewerId, viewerIsAdmin });
43+
// Owner bypass: the author may view their own in_review/rejected post;
44+
// everyone else only sees published posts whose publish time has passed.
45+
const isAuthor = !!viewerId && viewerId === userRecord.id;
46+
47+
const visibilityFilter = isAuthor
48+
? or(
49+
and(
50+
eq(posts.status, "published"),
51+
lte(posts.publishedAt, new Date().toISOString()),
52+
),
53+
inArray(posts.status, ["in_review", "rejected"]),
54+
)
55+
: and(
56+
eq(posts.status, "published"),
57+
lte(posts.publishedAt, new Date().toISOString()),
58+
);
4659

4760
const postResults = await db
4861
.select({
@@ -363,12 +376,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
363376

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

526534
const host = (await headers()).get("host") || "";
527535

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

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

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

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ import { ogPostImage } from "@/lib/og/url";
77
import { getServerAuthSession } from "@/server/auth";
88
import { db } from "@/server/db";
99
import { posts, user, post_tags, tag, comments } from "@/server/db/schema";
10-
import { eq, and, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
10+
import { eq, and, lte, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
1111
import PostReader, {
1212
type ReaderPost,
1313
} from "@/components/ContentDetail/PostReader";
1414
import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
15-
import { postVisibilityFilter } from "@/server/lib/postVisibility";
1615
import { JsonLd } from "@/components/JsonLd";
1716
import {
1817
getDiscussionForumPostingSchema,
@@ -27,7 +26,6 @@ type Props = { params: Promise<{ slug: string }> };
2726
async function getDiscussionPostUncached(
2827
slug: string,
2928
viewerId?: string | null,
30-
viewerIsAdmin = false,
3129
): Promise<ReaderPost | null> {
3230
const urlId = parseUrlId(slug);
3331
if (!urlId) return null;
@@ -39,6 +37,13 @@ async function getDiscussionPostUncached(
3937
? eq(posts.urlId, urlId)
4038
: or(eq(posts.urlId, urlId), eq(posts.slug, slug))!;
4139

40+
const publicFilter = and(
41+
eq(posts.status, "published"),
42+
lte(posts.publishedAt, new Date().toISOString()),
43+
);
44+
45+
// Owner bypass: the author may view their own in_review/rejected discussion;
46+
// everyone else only sees published.
4247
const [row] = await db
4348
.select({
4449
id: posts.id,
@@ -68,7 +73,15 @@ async function getDiscussionPostUncached(
6873
and(
6974
idMatch,
7075
inArray(posts.type, ["discussion", "question"]),
71-
postVisibilityFilter({ viewerId, viewerIsAdmin }),
76+
viewerId
77+
? or(
78+
publicFilter,
79+
and(
80+
eq(posts.authorId, viewerId),
81+
inArray(posts.status, ["in_review", "rejected"]),
82+
),
83+
)
84+
: publicFilter,
7285
),
7386
)
7487
.limit(1);
@@ -147,11 +160,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
147160
const { slug } = await props.params;
148161
// Same viewerId as the page body so the cache()d resolver runs once per request.
149162
const session = await getServerAuthSession();
150-
const post = await getDiscussionPost(
151-
slug,
152-
session?.user?.id,
153-
session?.user?.role === "ADMIN",
154-
);
163+
const post = await getDiscussionPost(slug, session?.user?.id);
155164

156165
if (!post) {
157166
return { title: "Discussion Not Found" };
@@ -201,11 +210,7 @@ const DiscussionPage = async (props: Props) => {
201210
const { slug } = await props.params;
202211
const session = await getServerAuthSession();
203212

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

210215
if (!post) return notFound();
211216

0 commit comments

Comments
 (0)