files(api): gate /api/files with ma_at, unify ROOT & safeJoin, harden errors, no-store caching
This commit is contained in:
parent
1e699b51ae
commit
096da254ec
3 changed files with 113 additions and 53 deletions
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue