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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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/bashset -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"||truedone

Repository: codu-code/codu

Length of output: 35426


🏁 Script executed:

#!/bin/bashset -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;doif rg -q 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file";thenprintf'\n### %s\n'"$file"
rg -n -C 12 'insert\(posts\)|update\(posts\)|INSERT INTO posts|externalUrl: input\.externalUrl|externalUrl: item\.link|externalUrl = '"$file"fidoneprintf'%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/bashset -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;doprintf'\n### %s\n'"$file"
rg -n -C 18 \
'externalUrl: input\.externalUrl|externalUrl: item\.link|INSERT INTO posts|createLinkPost' \
"$file"doneprintf'%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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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