From 096da254ec2737ce648f6aee0ace1db05bdd5c24 Mon Sep 17 00:00:00 2001 From: makearmy Date: Mon, 29 Sep 2025 14:31:50 -0400 Subject: [PATCH] files(api): gate /api/files with ma_at, unify ROOT & safeJoin, harden errors, no-store caching --- app/api/files/download/route.ts | 32 ++++++++++---- app/api/files/list/route.ts | 74 ++++++++++++++++++++++----------- app/api/files/raw/route.ts | 60 +++++++++++++++++--------- 3 files changed, 113 insertions(+), 53 deletions(-) diff --git a/app/api/files/download/route.ts b/app/api/files/download/route.ts index 61ed1541..e47aad90 100644 --- a/app/api/files/download/route.ts +++ b/app/api/files/download/route.ts @@ -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" } }); } } diff --git a/app/api/files/list/route.ts b/app/api/files/list/route.ts index cf426708..52f72ea6 100644 --- a/app/api/files/list/route.ts +++ b/app/api/files/list/route.ts @@ -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 }); } } - diff --git a/app/api/files/raw/route.ts b/app/api/files/raw/route.ts index f9c19a8d..5ba02fbb 100644 --- a/app/api/files/raw/route.ts +++ b/app/api/files/raw/route.ts @@ -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 }); } } -