Loading files
import { eq } from "drizzle-orm";
import {
hiddenLockError,
isAdminSession,
moderatorHeaderPatchOnly,
moderatorVisibilityChange,
moderatorVisibilityPatchOnly,
} from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { originBlocked, preflight, withCors } from "@/lib/community/cors";
import { communityEnabled, getDb } from "@/lib/community/server/db";
import { bakePatternHeader } from "@/lib/community/server/moduleCache";
import {
clearNotificationsFor,
notifyHeaderModerated,
notifyPerformancePinned,
notifyPortPinned,
notifyVisibilityModerated,
} from "@/lib/community/server/notify";
import {
getPattern,
getPatternStub,
getPerformanceStub,
getPortStub,
newId,
} from "@/lib/community/server/queries";
import { rateLimit } from "@/lib/community/ratelimit";
import { comments, patternHeaders, patterns } from "@/lib/community/server/schema";
import { buildStoredPatternCode, lineageFrom } from "@/lib/community/license";
import {
CODE_MAX,
COMMENT_MAX,
cleanCode,
cleanCpp,
cleanDescription,
cleanMadeHow,
cleanMadeOn,
cleanModerationReason,
cleanTitle,
} from "@/lib/community/validate";
import { cleanVisibility } from "@/lib/community/visibility";
import { KNOWN_LICENSES, forkLicenseAllowed, stripShareWrapping } from "@/lib/pattern/share";
// PATCH /api/community/patterns/[id] — the author edits their own pattern.
//
// Partial update: only the fields present in the body change. Whatever the
// edit touches, the licence block is rebuilt from the row afterwards (see
// lib/community/license.ts) so the header in the source always matches the
// pattern's real title, licence and author.
//
// A moderator may PATCH somebody else's pattern too, for two things only, one
// request each: its firmware header — a broken .h is a broken download for
// everyone who flashes it, and deleting the whole pattern over it throws away
// working JavaScript — and its visibility, to take it off the wall without
// deleting it (or put back one a moderator took). The rules and their
// reasoning live in lib/community/server/admin.ts; either act is marked on
// the row and the author is told about it.
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
const blocked = originBlocked(request);
if (blocked) return blocked;
return withCors(request, await handlePatch(request, context));
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
const blocked = originBlocked(request);
if (blocked) return blocked;
return withCors(request, await handleDelete(request, context));
}
export const OPTIONS = preflight;
// Deleting takes the comments and likes with it (both cascade). Forks survive:
// their `parent_id` is set to null, so a remix someone else built on top of
// this pattern doesn't vanish because the original author changed their mind —
// it just loses the lineage link.
async function handleDelete(request: Request, context: { params: Promise<{ id: string }> }) {
if (!communityEnabled()) {
return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 });
}
const session = await getAuth().api.getSession({ headers: request.headers });
if (!session) {
return Response.json({ error: "Sign in to delete your pattern." }, { status: 401 });
}
const { id } = await context.params;
const pattern = await getPattern(id);
if (!pattern) {
return Response.json({ error: "Pattern not found." }, { status: 404 });
}
// Moderators can take anything down. Editing stays owner-only: removing
// someone's pattern is a moderation act, rewriting it in their name is not.
if (pattern.userId !== session.user.id && !isAdminSession(session)) {
return Response.json({ error: "You can only delete your own patterns." }, { status: 403 });
}
await getDb().delete(patterns).where(eq(patterns.id, id));
// Rows pointing at the pattern, and "deck took your pattern" rows triggered
// by it — a notification for something gone is noise, not a record.
await clearNotificationsFor({ targetType: "pattern", targetId: id, sourceId: id });
return Response.json({ ok: true });
}
async function handlePatch(request: Request, context: { params: Promise<{ id: string }> }) {
if (!communityEnabled()) {
return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 });
}
const session = await getAuth().api.getSession({ headers: request.headers });
if (!session) {
return Response.json({ error: "Sign in to edit your pattern." }, { status: 401 });
}
if (!rateLimit(`patch:${session.user.id}`, 20, 60_000)) {
return Response.json({ error: "Too many edits — wait a minute and try again." }, { status: 429 });
}
const { id } = await context.params;
const pattern = await getPattern(id);
if (!pattern) {
return Response.json({ error: "Pattern not found." }, { status: 404 });
}
const isOwner = pattern.userId === session.user.id;
const moderating = !isOwner && isAdminSession(session);
if (!isOwner && !moderating) {
// 403, not 404 — the pattern is public, it just isn't theirs to edit.
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>;
// 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
// them.
if (moderating && moderatorVisibilityPatchOnly(raw)) {
return moderateVisibility(pattern, raw, session.user.id);
}
if (moderating && !moderatorHeaderPatchOnly(raw)) {
return Response.json(
{ error: "A moderator can only make this pattern private or fix its firmware header." },
{ status: 403 },
);
}
// Repairing a header is moderation; ATTACHING one is authorship. An author
// header out-ranks every community port (lib/community/ports.ts), so writing
// one here would demote a real porter's work and credit it to the author. A
// moderator with a working .h proposes a port like anybody else.
if (moderating && !pattern.codeCpp) {
return Response.json(
{ error: "This pattern has no header of its own — propose a port instead." },
{ status: 403 },
);
}
// ── Resolve each field, falling back to what is already stored ────────────
let title = pattern.title;
if (raw.title !== undefined) {
const next = cleanTitle(raw.title);
if (!next) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 });
title = next;
}
let description = pattern.description;
if (raw.description !== undefined) {
const next = cleanDescription(raw.description);
if (next === undefined) {
return Response.json({ error: "Description is too long (max 2000 chars)." }, { status: 400 });
}
description = next;
}
// The parent is needed twice below: to keep a fork's licence compatible, and
// to re-bake the upstream credit into the source.
const parent = pattern.parentId ? await getPatternStub(pattern.parentId) : null;
let license = pattern.license;
if (raw.license !== undefined) {
// KNOWN_LICENSES, not LICENSE_OPTIONS: a pattern published under a licence
// that has since been retired must stay editable by its author. Keeping the
// stored value is always allowed; only *changing* it is restricted.
const match = KNOWN_LICENSES.find((option) => option.spdx === raw.license);
if (!match) return Response.json({ error: "Unknown licence." }, { status: 400 });
if (match.spdx !== pattern.license && parent && !forkLicenseAllowed(parent.license, match.spdx)) {
return Response.json(
{
error: `This pattern is a fork of a ${parent.license} pattern, so it cannot be relicensed as ${match.spdx}.`,
},
{ status: 400 },
);
}
license = match.spdx;
}
let madeOn = pattern.madeOn;
if (raw.madeOn !== undefined) {
const next = cleanMadeOn(raw.madeOn);
if (next === undefined) {
return Response.json(
{ error: "Made-on date must be a real past date in YYYY-MM-DD form." },
{ status: 400 },
);
}
madeOn = next;
}
let madeHow = pattern.madeHow;
if (raw.madeHow !== undefined) {
const next = cleanMadeHow(raw.madeHow);
if (next === undefined) {
return Response.json({ error: 'Unknown "made how" value.' }, { status: 400 });
}
madeHow = next;
}
// Going private leaves any published deck carrying this pattern with a gap
// at its position — deliberate: the author's withdrawal wins, and the deck
// page says what happened rather than silently closing the hole.
let visibility = pattern.visibility;
if (raw.visibility !== undefined) {
const next = cleanVisibility(raw.visibility);
if (!next) return Response.json({ error: "Unknown visibility value." }, { status: 400 });
// A moderator took it off the wall, so only a moderator puts it back.
// Private stays accepted: it is what the pattern already is, and the
// lab's Share sends a visibility with every update.
if (next === "public" && pattern.hiddenAt) {
return Response.json({ error: hiddenLockError("pattern") }, { status: 403 });
}
visibility = next;
}
// The author's pick among community ports. Owner-only by virtue of being in
// this handler — picking the header that ships under the pattern's name is
// exactly the author's call and nobody else's.
let pinnedHeaderId = pattern.pinnedHeaderId;
let newlyPinnedPort: Awaited<ReturnType<typeof getPortStub>> = null;
if (raw.pinnedHeaderId !== undefined) {
if (raw.pinnedHeaderId === null || raw.pinnedHeaderId === "") {
pinnedHeaderId = null;
} else if (typeof raw.pinnedHeaderId === "string") {
const port = await getPortStub(raw.pinnedHeaderId);
if (!port || port.patternId !== id) {
return Response.json({ error: "No such port on this pattern." }, { status: 400 });
}
if (port.stale) {
return Response.json(
{ error: "That port was made for an older version of the code — it cannot be pinned." },
{ status: 400 },
);
}
if (port.id !== pattern.pinnedHeaderId) newlyPinnedPort = port;
pinnedHeaderId = port.id;
} else {
return Response.json({ error: "Unknown pinned port." }, { status: 400 });
}
}
// Same picking rule for performance recordings — the author's call which
// ride represents the pattern (lib/community/performances.ts).
let pinnedPerformanceId = pattern.pinnedPerformanceId;
let newlyPinnedPerformance: Awaited<ReturnType<typeof getPerformanceStub>> | null = null;
if (raw.pinnedPerformanceId !== undefined) {
if (raw.pinnedPerformanceId === null || raw.pinnedPerformanceId === "") {
pinnedPerformanceId = null;
} else if (typeof raw.pinnedPerformanceId === "string") {
const recording = await getPerformanceStub(raw.pinnedPerformanceId);
if (!recording || recording.patternId !== id) {
return Response.json({ error: "No such performance on this pattern." }, { status: 400 });
}
if (recording.id !== pattern.pinnedPerformanceId) newlyPinnedPerformance = recording;
pinnedPerformanceId = recording.id;
} else {
return Response.json({ error: "Unknown pinned performance." }, { status: 400 });
}
}
// Compare the bodies with any licence wrapping removed, so re-saving without
// touching the code isn't mistaken for a code change.
let bareCode = stripShareWrapping(pattern.code);
let codeChanged = false;
if (raw.code !== undefined) {
// Measured with the wrapping off, like the publish route: a pattern just
// under the cap is stored a few hundred characters over it, and "Save"
// with nothing edited must not be refused for that.
const next = cleanCode(
typeof raw.code === "string" && raw.code.length <= CODE_MAX * 2 ? stripShareWrapping(raw.code) : raw.code,
);
if (!next) {
return Response.json({ error: "Pattern code is missing or over 100KB." }, { status: 400 });
}
const nextBare = stripShareWrapping(next);
if (nextBare.length === 0) {
return Response.json({ error: "Pattern code is empty once the licence header is removed." }, { status: 400 });
}
codeChanged = nextBare !== bareCode;
bareCode = nextBare;
}
let codeCpp = pattern.codeCpp;
if (raw.codeCpp !== undefined) {
const next = cleanCpp(raw.codeCpp);
if (next === undefined) {
return Response.json(
{ error: "That does not look like a Patternflow header — it must start with `#pragma once` and be under 200KB." },
{ status: 400 },
);
}
codeCpp = next;
} else if (codeChanged) {
// A header is a port of a SPECIFIC version of the pattern, verified on real
// hardware. Once the JavaScript moves, that guarantee is gone, so the
// pattern drops back to "not hardware ready" until a fresh port is attached
// — better than leaving a badge that quietly lies.
codeCpp = null;
}
// Whose .h this is now. A moderator's fix leaves a mark the page can show —
// the row keeps the author's name on it either way. The author touching
// their own header clears the mark, and so does the header going away.
let cppModeratedAt = pattern.cppModeratedAt;
if (moderating) cppModeratedAt = new Date();
else if (raw.codeCpp !== undefined || codeChanged) cppModeratedAt = null;
if (codeCpp === null) cppModeratedAt = null;
const handle = pattern.displayUsername ?? pattern.username ?? null;
await getDb()
.update(patterns)
.set({
title,
description,
license,
madeOn,
madeHow,
// The header records when the work was MADE, which is what a licence
// notice is about. Falls back to the upload date when unset; `createdAt`
// itself never moves.
//
// A moderator's edit skips the rebuild entirely and leaves the stored
// source byte for byte. The re-bake is normally a no-op — it reproduces
// what it last wrote — but on a row published before this route started
// baking, the first PATCH does change the bytes, and that is not a
// change anyone but the author gets to make.
code: moderating
? pattern.code
: buildStoredPatternCode(bareCode, {
title,
license,
handle,
date: madeOn ?? pattern.createdAt,
// Rebuilt from the parent row every time, never carried over from
// the submitted code — an author editing their fork cannot drop
// the credit by deleting the line from the editor.
basedOn: lineageFrom(parent),
}),
codeCpp,
cppModeratedAt,
visibility,
pinnedHeaderId,
pinnedPerformanceId,
updatedAt: new Date(),
})
.where(eq(patterns.id, id));
// A port is of a SPECIFIC version of the JS. Once the source moves, every
// community port goes stale — same guarantee-expiry that just cleared the
// author's own header above, applied to everyone else's.
if (codeChanged) {
await getDb()
.update(patternHeaders)
.set({ stale: true })
.where(eq(patternHeaders.patternId, id));
}
// Whatever this edit did to the header that ships — a new or repaired .h,
// a different pinned port, the pattern going public — compile the result
// now so the next install finds it built. Never throws; a no-op without a
// build worker, for a private pattern, or with no header left.
if (raw.codeCpp !== undefined || raw.pinnedHeaderId !== undefined || raw.visibility !== undefined) {
await bakePatternHeader(id);
}
// Nobody's header changes under them in silence — least of all by the hand
// that also runs the place.
if (moderating) {
await notifyHeaderModerated({
recipientId: pattern.userId,
patternId: id,
patternTitle: title,
removed: codeCpp === null,
reason: typeof raw.reason === "string" ? raw.reason.trim() || null : null,
actorId: session.user.id,
});
}
if (newlyPinnedPort) {
await notifyPortPinned({
porterId: newlyPinnedPort.userId,
patternId: id,
patternTitle: title,
portId: newlyPinnedPort.id,
actorId: session.user.id,
});
}
if (newlyPinnedPerformance) {
await notifyPerformancePinned({
recorderId: newlyPinnedPerformance.userId,
patternId: id,
patternTitle: title,
performanceId: newlyPinnedPerformance.id,
actorId: session.user.id,
});
}
return Response.json({
ok: true,
hasCpp: codeCpp !== null,
headerDetached: codeChanged && raw.codeCpp === undefined && pattern.codeCpp !== null,
});
}
// A moderator taking somebody's pattern off the wall, or putting back one a
// moderator took. Only the visibility moves: the row is otherwise exactly the
// author's, so none of the rebuild in handlePatch runs. What is allowed, and
// why, is lib/community/server/admin.ts.
async function moderateVisibility(
pattern: NonNullable<Awaited<ReturnType<typeof getPattern>>>,
raw: Record<string, unknown>,
actorId: string,
): Promise<Response> {
const requested = cleanVisibility(raw.visibility);
if (!requested) return Response.json({ error: "Unknown visibility value." }, { status: 400 });
const reason = cleanModerationReason(raw.reason);
if (reason === undefined) {
return Response.json(
{ error: `The reason is posted as a comment, so it has to fit in one: ${COMMENT_MAX} characters at most.` },
{ status: 400 },
);
}
const change = moderatorVisibilityChange(pattern, requested, "pattern");
if (!change.ok) return Response.json({ error: change.error }, { status: change.status });
const db = getDb();
const now = new Date();
await db
.update(patterns)
.set({ visibility: change.visibility, hiddenAt: change.hiddenAt, updatedAt: now })
.where(eq(patterns.id, pattern.id));
// The reason goes where the author will look and can answer it: under the
// pattern, signed by the moderator. The alert below carries it too, which
// is why this comment does not notify on its own — one act, one row.
if (reason) {
await db.insert(comments).values({
id: newId(),
patternId: pattern.id,
userId: actorId,
body: reason,
createdAt: now,
});
}
// Back on the wall means installable again, so compile what it ships now.
if (change.visibility === "public") await bakePatternHeader(pattern.id);
await notifyVisibilityModerated({
recipientId: pattern.userId,
targetType: "pattern",
targetId: pattern.id,
targetTitle: pattern.title,
hidden: change.visibility === "private",
reason,
actorId,
});
return Response.json({ ok: true, visibility: change.visibility });
}
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.