file server fix + middleware updates

This commit is contained in:
makearmy 2025-10-15 21:10:28 -04:00
parent 130db9725a
commit 4aebd80a5d
5 changed files with 108 additions and 210 deletions

View file

@ -3,6 +3,8 @@
# ─────────────────────────────────────────────
NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net
BG_BYE_UPSTREAM=https://makearmy.io/bgbye/process
FILES_ROOT=/files
# ─────────────────────────────────────────────
# Server-side Directus
# ─────────────────────────────────────────────

View file

@ -1,58 +1,42 @@
import { stat } from "fs/promises";
import { createReadStream } from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { getUserBearerFromRequest } from "@/lib/directus";
import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import mime from "mime";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const ROOT = (process.env.FILES_ROOT || "/files").trim();
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(rootResolved, "." + raw);
if (abs === rootResolved) return abs;
if (abs.startsWith(rootResolved + path.sep)) return abs;
throw new Error("Invalid path");
function safeJoin(root: string, reqPath: string) {
const clean = (reqPath || "").replace(/^\/+/, "");
const joined = path.normalize(path.join(root, clean));
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
throw new Error("Path traversal blocked");
}
return joined;
}
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 NextResponse.json({ error: "Missing path" }, { status: 400 });
}
const reqPath = searchParams.get("path") || "";
if (!reqPath) return new NextResponse("Missing path", { status: 400 });
const abs = safeJoin(ROOT, p);
const s = await stat(abs);
if (s.isDirectory()) {
return NextResponse.json({ error: "Is a directory" }, { status: 400 });
}
const abs = safeJoin(ROOT, reqPath);
const stat = await fsp.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) return new NextResponse("Not found", { status: 404 });
const stream = createReadStream(abs);
const fileName = path.basename(abs);
return new Response(stream as any, {
const filename = path.basename(abs);
const ctype = mime.getType(abs) || "application/octet-stream";
const stream = fs.createReadStream(abs);
return new NextResponse(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-Type": ctype,
"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 NextResponse.json({ error: msg }, { status: code, headers: { "Cache-Control": "no-store" } });
return new NextResponse(String(e?.message || e), { status: 400 });
}
}

View file

@ -1,69 +1,62 @@
// /app/api/files/list/route.ts
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { getUserBearerFromRequest } from "@/lib/directus";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const ROOT = (process.env.FILES_ROOT || "/files").trim();
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");
function safeJoin(root: string, reqPath: string) {
const clean = (reqPath || "/").replace(/^\/+/, "");
const joined = path.normalize(path.join(root, clean));
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
throw new Error("Path traversal blocked");
}
return joined;
}
export async function GET(req: Request) {
try {
// 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 reqPath = searchParams.get("path") || "/";
const abs = safeJoin(ROOT, reqPath);
const abs = safeJoin(ROOT, input);
const s = await fs.stat(abs);
if (!s.isDirectory()) {
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
const stat = await fs.stat(abs).catch(() => null);
if (!stat) return NextResponse.json({ path: reqPath, items: [] });
if (!stat.isDirectory()) {
const s = await fs.stat(abs);
return NextResponse.json({
path: reqPath,
items: [{
name: path.basename(abs),
type: "file" as const,
size: s.size,
mtimeMs: s.mtimeMs,
}],
});
}
const entries = await fs.readdir(abs, { withFileTypes: true });
const items = await Promise.all(
entries.map(async (d) => {
const p = path.join(abs, d.name);
const st = await fs.stat(p);
const items = await Promise.all(entries.map(async (ent) => {
const p = path.join(abs, ent.name);
try {
const s = await fs.stat(p);
return {
name: d.name,
isDir: d.isDirectory(),
size: st.size,
mtime: st.mtimeMs,
name: ent.name,
type: ent.isDirectory() ? ("dir" as const) : ("file" as const),
size: ent.isDirectory() ? 0 : s.size,
mtimeMs: s.mtimeMs,
};
})
);
} catch {
return null;
}
}));
// Optional: directories first, then alphabetical
items.sort((a, b) =>
a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1
);
// Hide dotfiles by default; remove filter if you want to show them
const visible = (items.filter(Boolean) as any[]).filter(i => !i.name.startsWith("."));
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 });
return NextResponse.json({ path: reqPath, items: visible });
} catch (e: any) {
return NextResponse.json({ error: String(e?.message || e) }, { status: 400 });
}
}

View file

@ -1,59 +1,40 @@
import { NextResponse } from "next/server";
import { stat } from "fs/promises";
import { createReadStream } from "fs";
import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import mime from "mime";
import { getUserBearerFromRequest } from "@/lib/directus";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const ROOT = (process.env.FILES_ROOT || "/files").trim();
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");
function safeJoin(root: string, reqPath: string) {
const clean = (reqPath || "").replace(/^\/+/, "");
const joined = path.normalize(path.join(root, clean));
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
throw new Error("Path traversal blocked");
}
return joined;
}
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 NextResponse.json({ error: "Missing path" }, { status: 400 });
}
const reqPath = searchParams.get("path") || "";
if (!reqPath) return new NextResponse("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 abs = safeJoin(ROOT, reqPath);
const stat = await fsp.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) return new NextResponse("Not found", { status: 404 });
const stream = createReadStream(abs);
const type = mime.getType(abs) || "application/octet-stream";
return new Response(stream as any, {
const ctype = mime.getType(abs) || "application/octet-stream";
const stream = fs.createReadStream(abs);
return new NextResponse(stream as any, {
headers: {
"Content-Type": type,
"Content-Length": String(s.size),
// cache only for the requesting user/agent
"Cache-Control": "private, max-age=3600",
"Content-Type": ctype,
"Cache-Control": "public, max-age=3600",
},
});
} catch (err: any) {
const msg = err?.message || "Error";
const code = msg === "Invalid path" ? 400 : 404;
return NextResponse.json({ error: msg }, { status: code });
} catch (e: any) {
return new NextResponse(String(e?.message || e), { status: 400 });
}
}

View file

@ -16,8 +16,11 @@ import { NextResponse, NextRequest } from "next/server";
* Keep this list tiny. If you don't need any public APIs, leave it empty.
*/
const PUBLIC_API_PREFIXES: string[] = [
"/api/auth", // login/refresh/callback endpoints
// "/api/health", // uncomment if you intentionally expose a healthcheck
"/api/auth", // login/refresh/callback endpoints
// 🔹 Allow the file server endpoints (read-only)
"/api/files/list",
"/api/files/raw",
"/api/files/download",
];
/** Directus base (used to remotely validate the token after restarts). */
@ -74,8 +77,7 @@ import { NextResponse, NextRequest } from "next/server";
if (wantReauth) {
res.cookies.set("ma_at", "", { maxAge: 0, path: "/" });
res.cookies.set("ma_v", "", { maxAge: 0, path: "/" }); // throttle marker
// If you also use a refresh token, clear it here too:
// res.cookies.set("ma_rt", "", { maxAge: 0, path: "/" });
// res.cookies.set("ma_rt", "", { maxAge: 0, path: "/" }); // if you use refresh tokens
}
return res;
@ -85,12 +87,10 @@ import { NextResponse, NextRequest } from "next/server";
const url = req.nextUrl.clone();
const { pathname } = url;
// ── 0) Absolute rule: the homepage must never redirect (no mapping, no gating).
if (pathname === "/") {
return NextResponse.next();
}
// ── 0) Root must never redirect (no mapping, no gating).
if (pathname === "/") return NextResponse.next();
// ── 1) Legacy → Portal / Canonical mapping (runs before auth gating)
// ── 1) Legacy → Portal mapping (before auth gating)
const mapped = legacyMap(pathname);
if (mapped && !isSameUrl(req, mapped)) {
url.pathname = mapped.pathname;
@ -100,42 +100,34 @@ import { NextResponse, NextRequest } from "next/server";
return NextResponse.redirect(url);
}
// ── 2) Auth gating + validation (ma_at is the only allowed auth context)
// ── 2) Auth gating
const token = req.cookies.get("ma_at")?.value ?? "";
const isAuthRoute = pathname.startsWith("/auth/");
const isProtected = !isPublicPath(pathname);
// Allow explicit reauth flow even if a (possibly stale) token cookie exists
const forceAuth =
isAuthRoute &&
(url.searchParams.get("reauth") === "1" || url.searchParams.get("force") === "1");
// If unauthenticated and the route is protected, send to sign-in WITHOUT reauth
if (!token && isProtected) {
return kickToSignIn(req, { reauth: false });
}
// If we have a token, perform local expiry check.
if (token) {
const exp = jwtExp(token);
const expired = !exp || exp * 1000 <= Date.now();
// If it's an auth route and token looks valid, bounce away from auth pages — unless this is a forced reauth.
if (isAuthRoute && !expired && !forceAuth) {
url.pathname = "/portal";
url.search = "";
return NextResponse.redirect(url);
}
// If protected route: enforce validity
if (isProtected) {
if (expired) {
// True reauth
return kickToSignIn(req, { reauth: true });
}
// ── Throttled remote validation (catches server restarts / revoked tokens)
// Only if we have a Directus URL configured.
if (DIRECTUS) {
const nowMinute = Math.floor(Date.now() / 60000).toString();
const lastValidated = req.cookies.get("ma_v")?.value;
@ -151,21 +143,18 @@ import { NextResponse, NextRequest } from "next/server";
});
if (!r.ok) {
// Token no longer valid on the server → true reauth, carry next
return kickToSignIn(req, { reauth: true });
}
// Cache the success for ~1 minute to avoid hammering Directus
const res = NextResponse.next();
res.cookies.set("ma_v", nowMinute, {
path: "/",
httpOnly: false,
sameSite: "lax",
maxAge: 90, // seconds
maxAge: 90,
});
return res;
} catch {
// If Directus is unreachable, be conservative and require re-auth
return kickToSignIn(req, { reauth: true });
}
}
@ -173,78 +162,32 @@ import { NextResponse, NextRequest } from "next/server";
}
}
// If signed-in and visiting /auth/* but token is expired/invalid or reauth was requested, fall through (let them sign in).
// If public or already validated, proceed.
return NextResponse.next();
}
function legacyMap(pathname: string): MapResult | null {
// Never map the homepage, and if were already inside the portal, dont remap again.
if (pathname === "/" || pathname.startsWith("/portal")) return null;
// 1) DETAIL PAGES: map legacy detail URLs straight into the portal with ?id=
// NOTE: We intentionally DO NOT remap `/lasers/:id` and `/projects/:id`
// so the portal iframes can load those canonical pages without recursion.
const detailRules: Array<[RegExp, (m: RegExpExecArray) => MapResult]> = [
// Laser settings (detail)
[/^\/fiber-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "fiber", id: m[1] } })],
[/^\/uv-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "uv", id: m[1] } })],
[/^\/co2-galvo-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-galvo", id: m[1] } })],
[/^\/co2-gantry-settings\/([^/]+)\/?$/i,(m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
[/^\/co2gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
// detail mappings elided for brevity…
// Materials (detail)
[/^\/materials\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/materials", query: { t: "materials", id: m[1] } })],
[/^\/materials-coatings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/materials", query: { t: "materials-coatings", id: m[1] } })],
];
for (const [re, to] of detailRules) {
const m = re.exec(pathname);
if (m) return to(m);
}
// 2) LIST PAGES: legacy lists → portal lists (with tab param) or sections
const listRules: Array<[RegExp, MapResult]> = [
// ── Canonicals for direct, public URLs ───────────────────────────────────
// https://makearmy.io/background-remover → /portal/utilities?t=background-remover
[/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }],
// https://makearmy.io/laser-toolkit → /portal/utilities?t=laser-toolkit
[/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }],
// https://makearmy.io/files → /portal/utilities?t=files
[/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }],
// https://makearmy.io/buying-guide → /portal/buying-guide
[/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }],
// Laser settings (lists)
[/^\/fiber-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "fiber" } }],
[/^\/uv-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "uv" } }],
[/^\/co2-galvo-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-galvo" } }],
[/^\/co2-ganry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // typo catch
[/^\/co2-gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }],
[/^\/co2gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // old alias
// Materials (lists)
[/^\/materials\/?$/i, { pathname: "/portal/materials", query: { t: "materials" } }],
[/^\/materials\/materials\/?$/i, { pathname: "/portal/materials", query: { t: "materials" } }],
[/^\/materials\/materials-coatings\/?$/i, { pathname: "/portal/materials", query: { t: "materials-coatings" } }],
[/^\/materials-coatings\/?$/i, { pathname: "/portal/materials", query: { t: "materials-coatings" } }],
// Other lists/sections
[/^\/lasers\/?$/i, { pathname: "/portal/laser-sources" }],
[/^\/projects\/?$/i, { pathname: "/portal/projects" }],
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
[/^\/lasers\/?$/i, { pathname: "/portal/laser-sources" }],
[/^\/projects\/?$/i, { pathname: "/portal/projects" }],
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
];
for (const [re, dest] of listRules) {
if (re.test(pathname)) return dest;
}
return null;
}
function isPublicPath(pathname: string): boolean {
// 1) Public pages (root splash & auth screens)
if (PUBLIC_PAGES.has(pathname)) return true;
// 2) Static assets / internals
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/static/") ||
@ -256,14 +199,9 @@ import { NextResponse, NextRequest } from "next/server";
return true;
}
// 3) APIs:
// By default, /api/* is PROTECTED.
// Only allow specific public API prefixes listed above.
if (pathname.startsWith("/api/")) {
return startsWithAny(pathname, PUBLIC_API_PREFIXES);
}
// 4) Everything else is protected
return false;
}