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
39 changes: 20 additions & 19 deletions app/src/app/api/repos/[id]/fs/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceDir } from "@/lib/store";
import { repoAccessDenied } from "@/lib/authz";
import { requireWorkspace } from "@/lib/authz";
import {
listDir,
readWorkspaceFile,
Expand All @@ -19,25 +18,25 @@ import path from "node:path";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

function workspaceOr404(id: string) {
return getWorkspaceDir(id);
}

function err(e: unknown, fallback = 500) {
const msg = e instanceof Error ? e.message : String(e);
const status = e instanceof WorkspacePathError ? 400 : fallback;
return NextResponse.json({ error: msg }, { status });
// WorkspacePathError messages are authored to be client-safe (no absolute
// paths); everything else (raw ENOENT/EISDIR from node:fs) embeds the
// server's absolute workspace path — log it server-side, return a generic
// message to the client (F023).
if (e instanceof WorkspacePathError) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
console.warn("fs route error:", e instanceof Error ? e.message : String(e));
return NextResponse.json({ error: "File operation failed" }, { status: fallback });
}

// GET /api/repos/:id/fs?op=list&path=src -> FsEntry[]
// GET /api/repos/:id/fs?op=read&path=src/index.ts -> { content, truncated, size, binary }
// GET /api/repos/:id/fs?op=download&path=a/b.png -> raw file bytes (download)
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;
const ws = workspaceOr404(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
const { searchParams } = new URL(req.url);
const op = searchParams.get("op") || "list";
const relPath = searchParams.get("path") || ".";
Expand All @@ -47,10 +46,16 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
if (op === "download") {
const full = resolveSafe(ws.dir, relPath);
const buf = readFileSync(full);
const name = path.basename(full);
// A filename with `"`, `\`, or non-ASCII bytes corrupts a quoted
// Content-Disposition header (Next.js rejects it -> 500, and raw CR/LF
// would enable header injection). Emit an ASCII-sanitized fallback plus
// an RFC 5987 UTF-8 encoded `filename*` for correct clients.
const asciiFallback = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
return new NextResponse(new Uint8Array(buf), {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${path.basename(full)}"`,
"Content-Disposition": `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(name)}`,
},
});
}
Expand All @@ -64,10 +69,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
// { op: "write"|"create"|"rename"|"duplicate"|"upload", path, to?, type?, content?, contentBase64? }
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;
const ws = workspaceOr404(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const { op, path: relPath, to, type, content, contentBase64 } = body as {
op: string; path: string; to?: string; type?: "file" | "dir"; content?: string; contentBase64?: string;
Expand Down Expand Up @@ -118,10 +121,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// trash (restorable via /api/repos/:id/trash) instead of erasing it.
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;
const ws = workspaceOr404(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
const { searchParams } = new URL(req.url);
const relPath = searchParams.get("path");
if (!relPath) return NextResponse.json({ error: "Missing path" }, { status: 400 });
Expand Down
12 changes: 4 additions & 8 deletions app/src/app/api/repos/[id]/git/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getRepo, getWorkspaceDir, getSaveMode, setSaveMode } from "@/lib/store";
import { repoAccessDenied } from "@/lib/authz";
import { getRepo, getSaveMode, setSaveMode } from "@/lib/store";
import { requireWorkspace } from "@/lib/authz";
import {
isGitRepo,
getStatus,
Expand Down Expand Up @@ -38,10 +38,8 @@ function err(e: unknown, status = 500) {
// GET /api/repos/:id/git?op=status|branches|log|diff&path=&limit=
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;
const ws = getWorkspaceDir(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
if (!(await isGitRepo(ws.dir))) return NextResponse.json({ error: "Not a git workspace" }, { status: 409 });

const { searchParams } = new URL(req.url);
Expand Down Expand Up @@ -79,10 +77,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
// { op: "commit"|"push"|"pull"|"checkout"|"createBranch"|"setSaveMode", message?, name?, from?, githubToken?, saveMode? }
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;
const ws = getWorkspaceDir(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });

const body = await req.json().catch(() => ({}));
const { op, message, name, from, githubToken, saveMode } = body as {
Expand Down
7 changes: 2 additions & 5 deletions app/src/app/api/repos/[id]/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceDir } from "@/lib/store";
import { repoAccessDenied } from "@/lib/authz";
import { requireWorkspace } from "@/lib/authz";
import { searchWorkspace } from "@/lib/workspace";

export const runtime = "nodejs";
Expand All @@ -9,10 +8,8 @@ export const dynamic = "force-dynamic";
// GET /api/repos/:id/search?q=needle -> { results: SearchMatch[] }
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;
const ws = getWorkspaceDir(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") || "";
const results = searchWorkspace(ws.dir, q, 200);
Expand Down
13 changes: 3 additions & 10 deletions app/src/app/api/repos/[id]/timeline/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceDir } from "@/lib/store";
import { repoAccessDenied } from "@/lib/authz";
import { requireWorkspace } from "@/lib/authz";
import { TimelineEngine, Strategies } from "@/lib/gitops/timelineApi";
import { loadSnapshotCache } from "@/lib/gitops/timelineStore";
import { isGitRepo } from "@/lib/gitops";
Expand All @@ -16,11 +15,8 @@ function err(e: unknown, status = 500) {
// GET /api/repos/:id/timeline?op=metadata|trends|snapshot|compare
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;

const ws = getWorkspaceDir(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
if (!(await isGitRepo(ws.dir))) return NextResponse.json({ error: "Not a git workspace" }, { status: 409 });

const { searchParams } = new URL(req.url);
Expand Down Expand Up @@ -76,12 +72,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
// { op: "build", strategy?: "monthly" }
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
const { denied, ws } = requireWorkspace(req, id);
if (denied) return denied;

const ws = getWorkspaceDir(id);
if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });

const body = await req.json().catch(() => ({}));
const op = body.op;
const strategy = (body.strategy || "monthly") as keyof typeof Strategies;
Expand Down
20 changes: 19 additions & 1 deletion app/src/lib/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
// a private repo's mere existence isn't leaked to anyone but its owner.
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "./session";
import { getRepoOwnerId } from "./store";
import { getRepoOwnerId, getWorkspaceDir } from "./store";
import type { SourceType } from "./types";

/** Current viewer's userId for scoping list queries, or `null` if signed out. */
export function viewerId(req: NextRequest): number | null {
Expand All @@ -28,3 +29,20 @@ export function repoAccessDenied(req: NextRequest, id: string): NextResponse | n
if (ownerId === null) return null; // public bucket — open to everyone
return viewerId(req) === ownerId ? null : NextResponse.json({ error: "Repo not found" }, { status: 404 });
}

/**
* Combines the ownership check with workspace-directory resolution — every
* route that touches a repo's on-disk workspace (fs, git, search, timeline)
* needs both, in this order. `denied` is a `NextResponse` to return
* immediately when set; otherwise `ws` is the resolved workspace.
*/
export function requireWorkspace(
req: NextRequest,
id: string
): { denied: NextResponse; ws?: undefined } | { denied?: undefined; ws: { dir: string; sourceType: SourceType } } {
const denied = repoAccessDenied(req, id);
if (denied) return { denied };
const ws = getWorkspaceDir(id);
if (!ws) return { denied: NextResponse.json({ error: "Workspace not ready" }, { status: 404 }) };
return { ws };
}
Loading
Loading