Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion app/(admin)/admin/moderation/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,40 @@ const reasonLabels: Record<ReportReason, string> = {
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 (
<Link href={href} target="_blank" className="secondary-button">
Comment on lines +55 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the schema and all TypeScript write/read paths for external URLs.
fd -t f -e ts -e tsx | while IFS= read -r file; do
  rg -n -C 3 'externalUrl|external_url' "$file" || true
done

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- httpUrl and safeExternalHref definitions ---'
rg -n -C 8 'function httpUrl|const httpUrl|export .*httpUrl|safeExternalHref' .

printf '%s\n' '--- post write handlers and schemas ---'
rg -l 'externalUrl' --glob '*.ts' --glob '*.tsx' | while IFS= read -r file; do
  if rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = ' "$file"; then
    printf '\n### %s\n' "$file"
    rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = ' "$file"
  fi
done

printf '%s\n' '--- URL normalization and direct navigation helpers ---'
rg -n -C 8 'normalizeUrl|ensureHttps|new URL\(|startsWith\("http"\)|startsWith\('\''http' "$(
  rg -l 'externalUrl' --glob '*.ts' --glob '*.tsx' .
)" || true

Repository: codu-code/codu

Length of output: 16142


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- URL schema and render guard ---'
cat -n schema/shared.ts | sed -n '1,35p'
cat -n utils/url.ts | sed -n '1,70p'

printf '%s\n' '--- direct post write sites ---'
rg -l --glob '*.ts' --glob '*.tsx' \
  'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' . |
while IFS= read -r file; do
  printf '\n### %s\n' "$file"
  rg -n -C 18 \
    'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
    "$file"
done

printf '%s\n' '--- all externalUrl validation declarations ---'
rg -n -C 6 \
  'externalUrl: (httpUrl\(\)|z\.string\(\)\.url|z\.string\(\)\.trim|item\.link|input\.externalUrl)' \
  --glob '*.ts' --glob '*.tsx' .

Repository: codu-code/codu

Length of output: 17334


Guard externalUrl before navigation.

RSS importers persist item.link directly to posts, so httpUrl() does not cover every write path. Use safeExternalHref before passing externalUrl to Link, and reject invalid schemes during RSS imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(admin)/admin/moderation/_client.tsx around lines 55 - 70, Update
postPreviewHref and the RSS import path to validate external links with
safeExternalHref before navigation or persistence. For link posts, pass
post.externalUrl through safeExternalHref and return null for rejected schemes;
ensure RSS imports also reject invalid item.link schemes rather than storing
them in posts.

<ArrowTopRightOnSquareIcon className="h-4 w-4" />
Preview
</Link>
);
};

// 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".
function localDateTimeMin(): string {
Expand Down Expand Up @@ -263,14 +297,20 @@ const ModerationQueue = () => {
@{post.authorUsername ?? "unknown"} ·{" "}
{getRelativeTime(post.createdAt!)}
</p>
{post.excerpt && (
<p className="mt-1 line-clamp-2 text-sm text-muted">
{post.excerpt}
</p>
)}
{post.moderationNote && (
<p className="mt-1 text-sm text-muted">
<span className="font-medium text-fg">Reason:</span>{" "}
{post.moderationNote}
</p>
)}
</div>
<div className="flex shrink-0 gap-2">
<div className="flex shrink-0 flex-wrap gap-2">
<PreviewLink post={post} />
<button
className="primary-button"
disabled={isModerating}
Expand Down
35 changes: 16 additions & 19 deletions app/(app)/[username]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { type Metadata } from "next";
import { SITE_ORIGIN } from "@/config/site";
import { db } from "@/server/db";
import { posts, user, feed_sources, post_tags, tag } from "@/server/db/schema";
import { eq, and, lte, inArray, or, sql } from "drizzle-orm";
import { eq, and, lte, inArray, sql } from "drizzle-orm";
import UserLinkDetail from "./_userLinkDetail";
import PostReader from "@/components/ContentDetail/PostReader";
import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
import { postVisibilityFilter } from "@/server/lib/postVisibility";
import { serverApi } from "@/server/trpc/caller";
import { JsonLd } from "@/components/JsonLd";
import { getArticleSchema, getBreadcrumbSchema } from "@/lib/structured-data";
Expand All @@ -31,6 +32,7 @@ async function getUserPostUncached(
username: string,
postSlug: string,
viewerId?: string | null,
viewerIsAdmin = false,
) {
// Case-insensitive handle resolution (GitHub-style), matching the profile page.
const userRecord = await db.query.user.findFirst({
Expand All @@ -40,22 +42,7 @@ async function getUserPostUncached(

if (!userRecord) return null;

// Owner bypass: the author may view their own in_review/rejected post;
// everyone else only sees published posts whose publish time has passed.
const isAuthor = !!viewerId && viewerId === userRecord.id;

const visibilityFilter = isAuthor
? or(
and(
eq(posts.status, "published"),
lte(posts.publishedAt, new Date().toISOString()),
),
inArray(posts.status, ["in_review", "rejected"]),
)
: and(
eq(posts.status, "published"),
lte(posts.publishedAt, new Date().toISOString()),
);
const visibilityFilter = postVisibilityFilter({ viewerId, viewerIsAdmin });

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

// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
const userPost = await getUserPost(username, slug, session?.user?.id);
const userPost = await getUserPost(
username,
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
if (userPost) {
// Discussions/questions canonicalize to /d/{slug}; redirect before metadata.
if (isDiscussionKind(userPost.type)) {
Expand Down Expand Up @@ -533,7 +525,12 @@ const UnifiedPostPage = async (props: Props) => {

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

const userPost = await getUserPost(username, slug, session?.user?.id);
const userPost = await getUserPost(
username,
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);

if (userPost) {
// Discussions/questions live under /d/{slug} — redirect before rendering.
Expand Down
33 changes: 14 additions & 19 deletions app/(app)/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { ogPostImage } from "@/lib/og/url";
import { getServerAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import { posts, user, post_tags, tag, comments } from "@/server/db/schema";
import { eq, and, lte, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
import { eq, and, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
import PostReader, {
type ReaderPost,
} from "@/components/ContentDetail/PostReader";
import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
import { postVisibilityFilter } from "@/server/lib/postVisibility";
import { JsonLd } from "@/components/JsonLd";
import {
getDiscussionForumPostingSchema,
Expand All @@ -26,6 +27,7 @@ type Props = { params: Promise<{ slug: string }> };
async function getDiscussionPostUncached(
slug: string,
viewerId?: string | null,
viewerIsAdmin = false,
): Promise<ReaderPost | null> {
const urlId = parseUrlId(slug);
if (!urlId) return null;
Expand All @@ -37,13 +39,6 @@ async function getDiscussionPostUncached(
? eq(posts.urlId, urlId)
: or(eq(posts.urlId, urlId), eq(posts.slug, slug))!;

const publicFilter = and(
eq(posts.status, "published"),
lte(posts.publishedAt, new Date().toISOString()),
);

// Owner bypass: the author may view their own in_review/rejected discussion;
// everyone else only sees published.
const [row] = await db
.select({
id: posts.id,
Expand Down Expand Up @@ -73,15 +68,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
viewerId
? or(
publicFilter,
and(
eq(posts.authorId, viewerId),
inArray(posts.status, ["in_review", "rejected"]),
),
)
: publicFilter,
postVisibilityFilter({ viewerId, viewerIsAdmin }),
),
)
.limit(1);
Expand Down Expand Up @@ -160,7 +147,11 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
const { slug } = await props.params;
// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
const post = await getDiscussionPost(slug, session?.user?.id);
const post = await getDiscussionPost(
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);

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

const post = await getDiscussionPost(slug, session?.user?.id);
const post = await getDiscussionPost(
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);

if (!post) return notFound();

Expand Down
5 changes: 3 additions & 2 deletions components/ContentDetail/PostReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ const PostReader = async ({
commentsDisabledLabel = "post",
emitArticleSchema = true,
}: PostReaderProps) => {
// Only reachable by the author (the resolver only returns non-published posts
// when viewerId matches the author's id).
// Only reachable by the author or an admin (the resolvers only return
// non-published posts when viewerId matches the author's id, or the viewer is
// an admin previewing from the moderation queue).
const isAwaitingReview = post.status === "in_review";
const isRejected = post.status === "rejected";
const bodyContent = post.body ?? "";
Expand Down
7 changes: 7 additions & 0 deletions server/api/router/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,19 @@ export const adminRouter = createTRPCRouter({

// Auto-moderation queue: posts awaiting human review (status `in_review`).
// `moderationNote` surfaces WHY a post was flagged (auto-mod reason, etc.).
// `excerpt` gives a moderator a first impression in the queue itself, while
// `type` and `externalUrl` decide where its Preview link points: /d/{slug}
// for discussions and questions, the linked page for shared links, and
// /{user}/{slug} for everything the site renders itself.
listInReview: adminOnlyProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
type: posts.type,
excerpt: posts.excerpt,
externalUrl: posts.externalUrl,
authorId: posts.authorId,
authorUsername: user.username,
authorName: user.name,
Expand Down
62 changes: 62 additions & 0 deletions server/lib/postVisibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { PgDialect } from "drizzle-orm/pg-core";
import { postVisibilityFilter } from "./postVisibility";

const dialect = new PgDialect();

// Statuses and ids are bound as parameters, so the interesting assertions are
// about which values a viewer's filter binds, not the SQL text.
const render = (viewer: Parameters<typeof postVisibilityFilter>[0]) => {
const { sql, params } = dialect.sqlToQuery(postVisibilityFilter(viewer));
return {
sql,
params,
scopesToAuthor: sql.includes('"author_id" = '),
allowsUnpublished:
params.includes("in_review") && params.includes("rejected"),
};
};

describe("postVisibilityFilter", () => {
it("shows an anonymous viewer only live posts", () => {
const filter = render({});

expect(filter.params).toContain("published");
expect(filter.sql).toContain('"published_at" <= ');
expect(filter.allowsUnpublished).toBe(false);
});

it("lets a signed-in viewer see unpublished posts only when they wrote them", () => {
const filter = render({ viewerId: "viewer-1" });

expect(filter.allowsUnpublished).toBe(true);
// The author predicate is what stops one member reading another's drafts.
expect(filter.scopesToAuthor).toBe(true);
expect(filter.params).toContain("viewer-1");
});

it("lets an admin see unpublished posts by any author", () => {
const filter = render({ viewerId: "admin-1", viewerIsAdmin: true });

expect(filter.allowsUnpublished).toBe(true);
expect(filter.scopesToAuthor).toBe(false);
});

it("does not grant the admin bypass on a plain signed-in session", () => {
const admin = render({ viewerId: "admin-1", viewerIsAdmin: true });
const member = render({ viewerId: "admin-1" });

expect(member.sql).not.toEqual(admin.sql);
expect(member.scopesToAuthor).toBe(true);
});

it("never exposes drafts, whoever is looking", () => {
for (const viewer of [
{},
{ viewerId: "viewer-1" },
{ viewerId: "admin-1", viewerIsAdmin: true },
]) {
expect(render(viewer).params).not.toContain("draft");
}
});
});
38 changes: 38 additions & 0 deletions server/lib/postVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { and, eq, inArray, lte, or, type SQL } from "drizzle-orm";
import { posts } from "@/server/db/schema";

/**
* Who is allowed to see a post that is not live yet.
*
* Every reader resolver applies the same rule, so it lives here rather than
* being restated per route: a post is visible when it is published and its
* publish time has passed, OR it is awaiting/failed review and the viewer is
* either its author or an admin. Admins get the author's view so the moderation
* queue can link straight to a full preview of a post it is asking them to
* approve.
*
* Callers that already pin an author in their WHERE (the /{username}/{slug}
* resolvers) still get the right answer: the extra authorId predicate here is
* simply redundant with theirs.
*/
export function postVisibilityFilter(viewer: {
viewerId?: string | null;
viewerIsAdmin?: boolean;
}): SQL {
const live = and(
eq(posts.status, "published"),
lte(posts.publishedAt, new Date().toISOString()),
)!;

const notLiveYet = inArray(posts.status, ["in_review", "rejected"]);

if (viewer.viewerIsAdmin) {
return or(live, notLiveYet)!;
}

if (viewer.viewerId) {
return or(live, and(notLiveYet, eq(posts.authorId, viewer.viewerId)))!;
}

return live;
}
Loading