files(api): gate /api/files with ma_at, unify ROOT & safeJoin, harden errors, no-store caching

This commit is contained in:
makearmy 2025-09-29 14:31:50 -04:00
parent 1e699b51ae
commit 096da254ec
3 changed files with 113 additions and 53 deletions

View file

@ -1,44 +1,58 @@
import { stat } from "fs/promises";
import { createReadStream } from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { getUserBearerFromRequest } from "@/lib/directus";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const ROOT = process.env.FILES_ROOT || "/app/files";
function safeJoin(root: string, p: string) {
const rootResolved = path.resolve(root);
const raw = path.normalize("/" + (p || "/"));
const abs = path.resolve(root, "." + raw);
if (!abs.startsWith(path.resolve(root))) throw new Error("Invalid path");
return abs;
const abs = path.resolve(rootResolved, "." + raw);
if (abs === rootResolved) return abs;
if (abs.startsWith(rootResolved + path.sep)) return abs;
throw new Error("Invalid path");
}
export async function GET(req: Request) {
try {
// Auth gate: require ma_at
const bearer = getUserBearerFromRequest(req);
if (!bearer) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const p = searchParams.get("path");
if (!p) return new Response(JSON.stringify({ error: "Missing path" }), { status: 400 });
if (!p) {
return NextResponse.json({ error: "Missing path" }, { status: 400 });
}
const abs = safeJoin(ROOT, p);
const s = await stat(abs);
if (s.isDirectory()) {
return new Response(JSON.stringify({ error: "Is a directory" }), { status: 400 });
return NextResponse.json({ error: "Is a directory" }, { status: 400 });
}
const stream = createReadStream(abs);
const fileName = path.basename(abs);
return new Response(stream as any, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(s.size),
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"Cache-Control": "no-store",
}
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"Cache-Control": "no-store",
},
});
} catch (e: any) {
const msg = e?.message || "Not found";
const code = msg === "Invalid path" ? 400 : 404;
return new Response(JSON.stringify({ error: msg }), { status: code, headers: { "Cache-Control": "no-store" } });
return NextResponse.json({ error: msg }, { status: code, headers: { "Cache-Control": "no-store" } });
}
}

View file

@ -1,43 +1,69 @@
// /var/www/makearmy.io/app/app/api/files/list/route.ts
// /app/api/files/list/route.ts
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import { join, normalize } from "path";
import path from "path";
import { getUserBearerFromRequest } from "@/lib/directus";
const ROOT = "/app/files";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const ROOT = process.env.FILES_ROOT || "/app/files";
function safeJoin(root: string, p: string) {
const rootResolved = path.resolve(root);
const raw = path.normalize("/" + (p || "/")); // normalize input
const abs = path.resolve(rootResolved, "." + raw); // resolve under root
if (abs === rootResolved) return abs; // allow root itself
if (abs.startsWith(rootResolved + path.sep)) return abs;
throw new Error("Invalid path");
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const input = searchParams.get("path") || "/";
// Normalize and lock to ROOT to prevent traversal
const safeInput = input.startsWith("/") ? input : `/${input}`;
const fullPath = normalize(join(ROOT, `.${safeInput}`));
if (!fullPath.startsWith(ROOT)) {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
// Require auth (no anonymous browsing)
const bearer = getUserBearerFromRequest(req);
if (!bearer) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const input = searchParams.get("path") || "/";
const abs = safeJoin(ROOT, input);
const s = await fs.stat(abs);
if (!s.isDirectory()) {
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
}
const entries = await fs.readdir(abs, { withFileTypes: true });
const items = await Promise.all(
entries.map(async (d) => {
const p = join(fullPath, d.name);
const s = await fs.stat(p);
const p = path.join(abs, d.name);
const st = await fs.stat(p);
return {
name: d.name,
isDir: d.isDirectory(),
size: s.size,
mtime: s.mtimeMs,
size: st.size,
mtime: st.mtimeMs,
};
})
);
return NextResponse.json({ path: safeInput, items });
} catch (err: any) {
return NextResponse.json(
{ error: err?.message ?? String(err) },
{ status: 404 }
// Optional: directories first, then alphabetical
items.sort((a, b) =>
a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1
);
const safePath = input.startsWith("/") ? input : `/${input}`;
return NextResponse.json(
{ path: safePath, items },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (err: any) {
const msg = err?.message === "Invalid path" ? "Invalid path" : "Not found";
const code = msg === "Invalid path" ? 400 : 404;
return NextResponse.json({ error: msg }, { status: code });
}
}

View file

@ -1,39 +1,59 @@
import { NextResponse } from "next/server";
import fssync from "node:fs";
import path from "node:path";
import { stat } from "fs/promises";
import { createReadStream } from "fs";
import path from "path";
import mime from "mime";
import { getUserBearerFromRequest } from "@/lib/directus";
const BASE = "/app/files";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const revalidate = 0;
function safeJoin(base: string, reqPath: string) {
const decoded = decodeURIComponent(reqPath || "/");
const normalized = path.posix.normalize("/" + decoded).replace(/^(\.\.(\/|\\|$))+/g, "");
const full = path.join(base, normalized);
if (!full.startsWith(base)) throw new Error("Invalid path");
return full;
const ROOT = process.env.FILES_ROOT || "/app/files";
function safeJoin(root: string, p: string) {
const rootResolved = path.resolve(root);
const raw = path.normalize("/" + (p || "/")); // always absolute-ish
const abs = path.resolve(rootResolved, "." + raw); // stays under root
if (abs === rootResolved) return abs; // allow root
if (abs.startsWith(rootResolved + path.sep)) return abs;
throw new Error("Invalid path");
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const p = searchParams.get("path");
if (!p) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
const abs = safeJoin(BASE, p);
if (!fssync.existsSync(abs) || !fssync.statSync(abs).isFile()) {
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
// Auth gate: require ma_at
const bearer = getUserBearerFromRequest(req);
if (!bearer) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const stream = fssync.createReadStream(abs);
const { searchParams } = new URL(req.url);
const p = searchParams.get("path");
if (!p) {
return NextResponse.json({ error: "Missing path" }, { status: 400 });
}
const abs = safeJoin(ROOT, p);
const s = await stat(abs);
if (!s.isFile()) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const stream = createReadStream(abs);
const type = mime.getType(abs) || "application/octet-stream";
return new Response(stream as any, {
headers: {
"Content-Type": type,
"Cache-Control": "public, max-age=3600",
"Content-Length": String(s.size),
// cache only for the requesting user/agent
"Cache-Control": "private, max-age=3600",
},
});
} catch (err: any) {
return NextResponse.json({ ok: false, error: err?.message || "Error" }, { status: 400 });
const msg = err?.message || "Error";
const code = msg === "Invalid path" ? 400 : 404;
return NextResponse.json({ error: msg }, { status: code });
}
}