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
9 changes: 3 additions & 6 deletions web/src/app/api/community/atlas/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ENTRY_BY_ID } from "@/lib/atlas/data";
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { listAtlasPins } from "@/lib/community/server/queries";
import { rateLimit } from "@/lib/community/ratelimit";
Expand Down Expand Up @@ -84,12 +85,8 @@ async function handleWrite(request: Request, mode: "place" | "remove") {
return Response.json({ error: "Too many moves — let the pin settle a moment." }, { status: 429 });
}

let payload: Record<string, unknown>;
try {
payload = (await request.json()) as Record<string, unknown>;
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const payload = await readJsonObject(request);
if (payload instanceof Response) return payload;

const patternId = typeof payload.patternId === "string" ? payload.patternId : "";
if (!patternId) {
Expand Down
13 changes: 5 additions & 8 deletions web/src/app/api/community/builds/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type BuildPatternInput,
} from "@/lib/community/server/builds";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled } from "@/lib/community/server/db";
import { rateLimit } from "@/lib/community/ratelimit";
import { CPP_MAX } from "@/lib/community/validate";
Expand Down Expand Up @@ -70,12 +71,8 @@ async function handlePost(request: Request) {
);
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const body = await readJsonObject(request);
if (body instanceof Response) return body;

// "bin" asked for a whole firmware image. The community built those from 25
// July 2026 (c5e0907) until 4c13474 removed them on 12 August, and since
Expand All @@ -84,7 +81,7 @@ async function handlePost(request: Request) {
// for an image, sent no format, and a missing format meant "bin" until 12
// August. The 410 is for a caller outside this repository that names the
// format, as this route's header told it to from 27 July (d7cd193).
const rawFormat = (body as Record<string, unknown>).format ?? "pfm";
const rawFormat = body.format ?? "pfm";
if (rawFormat === "bin") {
return Response.json(
{
Expand All @@ -100,7 +97,7 @@ async function handlePost(request: Request) {
}
const format: BuildFormat = rawFormat;

const raw = (body as Record<string, unknown>).patterns;
const raw = body.patterns;
if (!Array.isArray(raw) || raw.length === 0) {
return Response.json({ error: "Send at least one pattern to build." }, { status: 400 });
}
Expand Down
11 changes: 4 additions & 7 deletions web/src/app/api/community/comments/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { clearNotificationsFor } from "@/lib/community/server/notify";
import { getCommentStub } from "@/lib/community/server/queries";
Expand Down Expand Up @@ -64,14 +65,10 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st
return Response.json({ error: "You can only edit your own comments." }, { status: 403 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const body = await readJsonObject(request);
if (body instanceof Response) return body;

const next = cleanComment((body as Record<string, unknown>).body);
const next = cleanComment(body.body);
if (!next) {
return Response.json(
{ error: `A comment cannot be empty, and must be under ${COMMENT_MAX} characters.` },
Expand Down
10 changes: 3 additions & 7 deletions web/src/app/api/community/decks/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck";
import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare";
Expand Down Expand Up @@ -101,13 +102,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st
return Response.json({ error: "You can only edit your own decks." }, { status: 403 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const raw = body as Record<string, unknown>;
const raw = await readJsonObject(request);
if (raw instanceof Response) return raw;

if (moderating) {
if (!moderatorVisibilityPatchOnly(raw)) {
Expand Down
10 changes: 3 additions & 7 deletions web/src/app/api/community/decks/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck";
import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare";
Expand Down Expand Up @@ -42,13 +43,8 @@ async function handlePost(request: Request) {
return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const raw = body as Record<string, unknown>;
const raw = await readJsonObject(request);
if (raw instanceof Response) return raw;

const title = cleanTitle(raw.title);
if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 });
Expand Down
11 changes: 4 additions & 7 deletions web/src/app/api/community/featured/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm";
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { featuredPatterns, patterns } from "@/lib/community/server/schema";

Expand Down Expand Up @@ -35,14 +36,10 @@ async function handlePut(request: Request) {
return Response.json({ error: "Not found." }, { status: 404 });
}

let payload: unknown;
try {
payload = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const payload = await readJsonObject(request);
if (payload instanceof Response) return payload;

const raw = (payload as { patternIds?: unknown }).patternIds;
const raw = payload.patternIds;
if (!Array.isArray(raw) || raw.some((id) => typeof id !== "string")) {
return Response.json({ error: "Expected patternIds: string[]." }, { status: 400 });
}
Expand Down
133 changes: 133 additions & 0 deletions web/src/app/api/community/nullBody.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, expect, it } from "vitest";

// `null` is valid JSON, so it got past the `catch` around every write route's
// `request.json()` and threw a TypeError on the first field read. Next.js
// answered that with a bare 500: no JSON saying why, and no CORS headers, so a
// Pattern Lab on another origin could not read even the status. Every route
// below is driven with a signed-in moderator and the Lab's Origin, on a
// database of its own, with something for each id in the path to find.
//
// territories/[code]/pin is not here: its body is optional, and a `null` one
// already pins with no note.

const LAB_ORIGIN = "https://lab.patternflow.test";
const ENV_KEYS = [
"COMMUNITY_DB_PATH",
"COMMUNITY_ENABLED",
"BETTER_AUTH_SECRET",
"COMMUNITY_ADMIN_USERNAMES",
"COMMUNITY_ALLOWED_ORIGINS",
"BUILD_ENABLED",
] as const;
const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
let tmp = "";
let cookie = "";

type Handler = (request: Request, context: { params: Promise<Record<string, string>> }) => Promise<Response>;
type RouteModule = Record<string, unknown>;

// [method, path, module loader, path params]
const ROUTES: [string, string, () => Promise<RouteModule>, Record<string, string>][] = [
["POST", "atlas", () => import("./atlas/route"), {}],
["DELETE", "atlas", () => import("./atlas/route"), {}],
["POST", "builds", () => import("./builds/route"), {}],
["PATCH", "comments/c1?on=pattern", () => import("./comments/[id]/route"), { id: "c1" }],
["POST", "decks", () => import("./decks/route"), {}],
["PATCH", "decks/d1", () => import("./decks/[id]/route"), { id: "d1" }],
["PUT", "featured", () => import("./featured/route"), {}],
["POST", "patterns", () => import("./patterns/route"), {}],
["PATCH", "patterns/p1", () => import("./patterns/[id]/route"), { id: "p1" }],
["POST", "patterns/p1/comments", () => import("./patterns/[id]/comments/route"), { id: "p1" }],
["POST", "patterns/p1/header", () => import("./patterns/[id]/header/route"), { id: "p1" }],
["POST", "patterns/p1/performance", () => import("./patterns/[id]/performance/route"), { id: "p1" }],
["PATCH", "ports/h1", () => import("./ports/[id]/route"), { id: "h1" }],
["POST", "posts", () => import("./posts/route"), {}],
["PATCH", "posts/s1", () => import("./posts/[id]/route"), { id: "s1" }],
["POST", "posts/s1/comments", () => import("./posts/[id]/comments/route"), { id: "s1" }],
["POST", "posts/s1/pin", () => import("./posts/[id]/pin/route"), { id: "s1" }],
["POST", "presence", () => import("./presence/route"), {}],
["POST", "reports", () => import("./reports/route"), {}],
["PATCH", "reports/r1", () => import("./reports/[id]/route"), { id: "r1" }],
["POST", "territories", () => import("./territories/route"), {}],
["PATCH", "territories/A1", () => import("./territories/[code]/route"), { code: "A1" }],
];

beforeAll(async () => {
// The database is a lazy singleton keyed off these variables, so they are
// set before anything imports it.
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-null-body-"));
process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db");
process.env.COMMUNITY_ENABLED = "1";
process.env.BETTER_AUTH_SECRET = "null-body-test-secret-not-a-real-one";
process.env.COMMUNITY_ADMIN_USERNAMES = "ada";
process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN;
process.env.BUILD_ENABLED = "1";

const { eq } = await import("drizzle-orm");
const { getAuth } = await import("@/lib/community/server/auth");
const { getDb } = await import("@/lib/community/server/db");
const schema = await import("@/lib/community/server/schema");

const response = await getAuth().api.signUpEmail({
body: { email: "ada@patternflow.local", password: "null-body-test-password", name: "ada", username: "ada" },
asResponse: true,
});
const setCookie = response.headers.get("set-cookie");
if (!setCookie) throw new Error("no session cookie for ada");
cookie = setCookie.split(";")[0];

const db = getDb();
const [ada] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, "ada"))
.limit(1);
const userId = ada!.id;
const now = new Date();
await db.insert(schema.patterns).values({
id: "p1", userId, title: "ada's pattern", code: "// js", visibility: "public", createdAt: now, updatedAt: now,
});
await db.insert(schema.decks).values({ id: "d1", userId, title: "ada's deck", createdAt: now, updatedAt: now });
await db.insert(schema.comments).values({ id: "c1", patternId: "p1", userId, body: "first", createdAt: now });
await db.insert(schema.patternHeaders).values({
id: "h1", patternId: "p1", userId, codeCpp: "#pragma once\n", createdAt: now,
});
await db.insert(schema.territories).values({ id: "t1", code: "A1", title: "Workshop", createdAt: now });
await db.insert(schema.posts).values({
id: "s1", territoryId: "t1", userId, title: "ada's post", body: "hello", createdAt: now, updatedAt: now,
});
await db.insert(schema.reports).values({
id: "r1", targetType: "pattern", targetId: "p1", reporterId: userId, reason: "other", createdAt: now,
});
}, 30_000);

afterAll(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
});

it.each(ROUTES)("%s /api/community/%s answers a null body with a 400 the Lab can read", async (
method,
route,
load,
params,
) => {
const handler = (await load())[method] as Handler;
const response = await handler(
new Request(`http://localhost:3000/api/community/${route}`, {
method,
headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie },
body: "null",
}),
{ params: Promise.resolve(params) },
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Expected a JSON object." });
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN);
});
11 changes: 4 additions & 7 deletions web/src/app/api/community/patterns/[id]/comments/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { notifyCommentAdded } from "@/lib/community/server/notify";
import { getPatternStub, newId } from "@/lib/community/server/queries";
Expand Down Expand Up @@ -46,14 +47,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str
return Response.json({ error: "Pattern not found." }, { status: 404 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const body = await readJsonObject(request);
if (body instanceof Response) return body;

const text = cleanComment((body as Record<string, unknown>).body);
const text = cleanComment(body.body);
if (!text) {
return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 });
}
Expand Down
10 changes: 3 additions & 7 deletions web/src/app/api/community/patterns/[id]/header/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { bakePatternHeader } from "@/lib/community/server/moduleCache";
import { notifyPortAdded } from "@/lib/community/server/notify";
Expand Down Expand Up @@ -106,13 +107,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str
return Response.json({ error: "No such pattern." }, { status: 404 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const raw = body as Record<string, unknown>;
const raw = await readJsonObject(request);
if (raw instanceof Response) return raw;

const codeCpp = cleanCpp(raw.codeCpp);
if (!codeCpp) {
Expand Down
10 changes: 3 additions & 7 deletions web/src/app/api/community/patterns/[id]/performance/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { notifyPerformanceAdded } from "@/lib/community/server/notify";
import {
Expand Down Expand Up @@ -129,13 +130,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str
return Response.json({ error: "No such pattern." }, { status: 404 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const raw = body as Record<string, unknown>;
const raw = await readJsonObject(request);
if (raw instanceof Response) return raw;

if (typeof raw.performanceJson !== "string" || raw.performanceJson.length === 0) {
return Response.json({ error: "performanceJson (a Director show, as its JSON string) is required." }, { status: 400 });
Expand Down
10 changes: 3 additions & 7 deletions web/src/app/api/community/patterns/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { readJsonObject } from "@/lib/community/jsonBody";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { bakePatternHeader } from "@/lib/community/server/moduleCache";
import {
Expand Down Expand Up @@ -128,13 +129,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st
return Response.json({ error: "You can only edit your own patterns." }, { status: 403 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const raw = body as Record<string, unknown>;
const raw = await readJsonObject(request);
if (raw instanceof Response) return raw;

// A moderator's reach into someone else's pattern is its visibility or its
// .h, never both at once. Every other field below simply never runs for
Expand Down
Loading
Loading