Skip to content

fix(admin): move the moderation preview off the public reader routes - #1342

Closed
NiallJoeMaher wants to merge 1 commit into
developfrom
fix/moderation-preview-scope
Closed

fix(admin): move the moderation preview off the public reader routes#1342
NiallJoeMaher wants to merge 1 commit into
developfrom
fix/moderation-preview-scope

Conversation

@NiallJoeMaher

Copy link
Copy Markdown
Contributor

Follow-up to #1340. A high-effort review of that PR (run after it had already merged) found the approach was the problem, not just details of it.

What #1340 got wrong

It let admins resolve in_review/rejected posts at their public URLs. Three consequences:

  1. The public reader has engagement controls. Preview rendered vote, bookmark and comment UI 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 declining.
  2. Admins stopped seeing the site as readers do. Checking whether a takedown took effect returned 200 for an admin where everyone else gets 404.
  3. The rejected-post banner became untrue. It still promises authors "This post is not visible to anyone else."

Plus two on the link path:

  1. Link submissions were never actually readable. Preview went straight off-site, so the Codú-side title/excerpt/body — where a spammer would put the payload — was never shown. gatePublish routes links to in_review precisely on spam signals, so this was the case the feature existed for.
  2. That off-site href skipped safeExternalHref and rel. An externalUrl that never passed httpUrl() validation (feed ingestion, legacy rows) would run as a javascript: URL inside the authenticated admin session, and the destination under review received https://www.codu.co/admin/moderation as its referrer.

What this does

Preview moves to /admin/moderation/preview/{id} — read-only, no engagement controls, inside the existing admin gate. It renders title, author, flag reason, excerpt, tags and body, and for a link submission also the destination (through safeExternalHref, with rel="noopener noreferrer nofollow").

The public reader routes and postVisibilityFilter revert to what they were, so the only person who can reach an unpublished post there is its author, as before.

Body rendering moves into a shared PostBody used by both the preview and the public reader, so the two cannot drift.

Keying by post id also means Preview is available for every queued post, including one whose author has not set a username — #1340 silently rendered no button in that case.

Verified locally

  • Admin at a public in_review URL → 404 again (both /{username}/{slug} and /d/{slug}); published posts still 200.
  • Preview renders for article, question and link posts; the link preview shows the member's body and the guarded destination.
  • Public reader still renders bodies correctly through the extracted component.
  • npm run lint, npm run prettier, npm run test:unit, npm run build.

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.
@NiallJoeMaher
NiallJoeMaher requested a review from a team as a code owner August 12, 2026 07:26
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
codu Ready Ready Preview Aug 12, 2026 7:29am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@NiallJoeMaher, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 58971b40-14c8-4be7-9425-b48f072272de

📥 Commits

Reviewing files that changed from the base of the PR and between 33771d3 and 4d3435a.

📒 Files selected for processing (9)
  • app/(admin)/admin/moderation/_client.tsx
  • app/(admin)/admin/moderation/preview/[postId]/page.tsx
  • app/(app)/[username]/[slug]/page.tsx
  • app/(app)/d/[slug]/page.tsx
  • components/ContentDetail/PostBody.tsx
  • components/ContentDetail/PostReader.tsx
  • server/api/router/admin.ts
  • server/lib/postVisibility.test.ts
  • server/lib/postVisibility.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Uh oh! @vercel[bot], the image you shared is missing helpful alt text. Check #1342 (comment).

Alt text is an invisible description that helps screen readers describe images to blind or low-vision users. If you are using markdown to display images, add your alt text inside the brackets of the markdown image.

Learn more about alt text at Basic writing and formatting syntax: images on GitHub Docs.

@NiallJoeMaher

Copy link
Copy Markdown
Contributor Author

Superseded by #1344, which consolidates this with #1343 and adds the email-origin fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant