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
166 changes: 166 additions & 0 deletions app/api/projects/[projectId]/canvas/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { get, put } from "@vercel/blob";
import { NextResponse } from "next/server";

import { getCurrentUserId, getProjectWithAccess } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import type { CanvasEdge, CanvasNode } from "@/types/canvas";

interface CanvasBody {
nodes?: unknown;
edges?: unknown;
}

interface CanvasSnapshot {
nodes: CanvasNode[];
edges: CanvasEdge[];
}

/**
* Validate canvas JSON body. Accepts an object with `nodes` and `edges`
* arrays; anything else is rejected.
*/
function parseCanvasBody(body: unknown): CanvasSnapshot | null {
if (body === null || typeof body !== "object") return null;
const { nodes, edges } = body as CanvasBody;
if (!Array.isArray(nodes) || !Array.isArray(edges)) return null;
return {
nodes: nodes as CanvasNode[],
edges: edges as CanvasEdge[],
};
Comment on lines +22 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Validate node and edge contents before persisting them.

The casts do not perform runtime validation. Any arrays—including malformed objects—are accepted, stored, and later passed into React Flow as CanvasNode[]/CanvasEdge[], allowing a bad client payload to poison the saved canvas. Add schema validation and reasonable payload/count limits.

🤖 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/api/projects/`[projectId]/canvas/route.ts around lines 22 - 29, Update
parseCanvasBody to perform runtime schema validation for every node and edge
before returning a CanvasSnapshot, replacing the unchecked CanvasNode[] and
CanvasEdge[] casts. Enforce reasonable per-item payload and total node/edge
count limits, returning null for malformed or oversized input so only validated
data reaches persistence and React Flow.

}

/**
* PUT /api/projects/[projectId]/canvas
*
* Upload the latest canvas JSON to Vercel Blob and store the blob URL on
* the Prisma project record (`canvasJsonPath`).
*/
export async function PUT(
request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await params;

const project = await getProjectWithAccess(projectId);
if (!project) {
// getProjectWithAccess returns null for both no-session and no access.
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

let body: unknown = null;
try {
body = await request.json();
} catch {
body = null;
}

const snapshot = parseCanvasBody(body);
if (!snapshot) {
return NextResponse.json(
{ error: "Invalid canvas payload. Expected { nodes, edges }." },
{ status: 400 },
);
}

const pathname = `canvas/${projectId}.json`;
const blob = await put(pathname, JSON.stringify(snapshot), {
access: "private",
contentType: "application/json",
addRandomSuffix: false,
allowOverwrite: true,
});

const updated = await prisma.project.update({
where: { id: projectId },
data: { canvasJsonPath: blob.url },
select: { id: true, canvasJsonPath: true, updatedAt: true },
Comment on lines +69 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Prevent stale concurrent saves from overwriting newer snapshots.

All collaborators overwrite the same pathname without a revision check. If an older request completes after a newer one, its stale snapshot becomes authoritative. Persist a revision/version and conditionally commit the Blob URL, or otherwise reject superseded writes.

🤖 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/api/projects/`[projectId]/canvas/route.ts around lines 69 - 80, Update
the canvas save flow around the blob write and project.update to prevent stale
concurrent snapshots from becoming authoritative: persist or derive a revision
for each save, then conditionally commit the Blob URL only when the request
still represents the latest revision, rejecting superseded writes. Keep the
existing project selection and successful response behavior for the winning
save.

});

return NextResponse.json({
canvasJsonPath: updated.canvasJsonPath,
updatedAt: updated.updatedAt,
});
}

/**
* GET /api/projects/[projectId]/canvas
*
* Read the project's saved blob URL from Prisma, fetch the canvas JSON
* from Vercel Blob, and return it to the editor.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await params;

const project = await getProjectWithAccess(projectId);
if (!project) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const record = await prisma.project.findUnique({
where: { id: projectId },
select: { canvasJsonPath: true },
});

if (!record?.canvasJsonPath) {
return NextResponse.json({
nodes: [] as CanvasNode[],
edges: [] as CanvasEdge[],
canvasJsonPath: null,
});
}

try {
const result = await get(record.canvasJsonPath, {
access: "private",
useCache: false,
});

if (!result || result.statusCode !== 200 || !result.stream) {
return NextResponse.json(
{ error: "Failed to fetch canvas blob" },
{ status: 502 },
);
}

const text = await new Response(result.stream).text();
let data: unknown = null;
try {
data = JSON.parse(text) as unknown;
} catch {
return NextResponse.json(
{ error: "Invalid canvas blob contents" },
{ status: 502 },
);
}

const snapshot = parseCanvasBody(data);
if (!snapshot) {
return NextResponse.json(
{ error: "Invalid canvas blob contents" },
{ status: 502 },
);
}

return NextResponse.json({
nodes: snapshot.nodes,
edges: snapshot.edges,
canvasJsonPath: record.canvasJsonPath,
});
} catch {
return NextResponse.json(
{ error: "Failed to load canvas blob" },
{ status: 502 },
);
}
}
Loading