diff --git a/.env.local b/.env.local index 5558d126..6cddf541 100644 --- a/.env.local +++ b/.env.local @@ -3,7 +3,5 @@ NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net # Server-side (used by API routes) DIRECTUS_URL=https://forms.lasereverything.net -DIRECTUS_TOKEN_SUBMIT=2uD5w9sFLgPtTtjqfUft8i_pLZRzCSTu DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7 DIRECTUS_TOKEN_SCHEMA_READ=BCaWlfTkuVIYyEnwBCXujLTIY6lScZbF -DIRECTUS_ROLE_MEMBER_ID=296a28bc-60ab-4251-8bef-27f6dfb67948 diff --git a/lib/directus.ts b/lib/directus.ts index d8c03fdb..ec5e7555 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -1,126 +1,123 @@ // lib/directus.ts -// Central Directus helpers used by API routes. +// Central Directus helpers used by API routes. (SUBMIT token removed — user bearer only) -const BASE = process.env.DIRECTUS_URL!; -const TOKEN_SUBMIT = process.env.DIRECTUS_TOKEN_SUBMIT!; -const TOKEN_ADMIN_REGISTER = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; -const TOKEN_SCHEMA_READ = process.env.DIRECTUS_TOKEN_SCHEMA_READ || ""; // ← new (schema-bot) +const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const TOKEN_ADMIN_REGISTER = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; // server-only +const TOKEN_SCHEMA_READ = process.env.DIRECTUS_TOKEN_SCHEMA_READ || ""; // server-only const ROLE_MEMBER_ID_ENV = process.env.DIRECTUS_ROLE_MEMBER_ID || ""; const ROLE_MEMBER_NAME_ENV = process.env.DIRECTUS_ROLE_MEMBER_NAME || "Users"; -const PROJECTS_COLLECTION = -process.env.DIRECTUS_PROJECTS_COLLECTION || "projects"; +const PROJECTS_COLLECTION = process.env.DIRECTUS_PROJECTS_COLLECTION || "projects"; if (!BASE) console.warn("[directus] Missing DIRECTUS_URL"); -if (!TOKEN_SUBMIT) console.warn("[directus] Missing DIRECTUS_TOKEN_SUBMIT"); if (!TOKEN_ADMIN_REGISTER) - console.warn( - "[directus] Missing DIRECTUS_TOKEN_ADMIN_REGISTER (used for registration)" - ); + console.warn("[directus] Missing DIRECTUS_TOKEN_ADMIN_REGISTER (used for registration)"); if (!TOKEN_SCHEMA_READ) - console.warn( - "[directus] Missing DIRECTUS_TOKEN_SCHEMA_READ (used for schema reads)" - ); + console.warn("[directus] Missing DIRECTUS_TOKEN_SCHEMA_READ (used for schema reads)"); export function bytesFromMB(mb: number) { return Math.round(mb * 1024 * 1024); } +// Extract a user's bearer (ma_at) from a Next.js Request (server routes) +export function getUserBearerFromRequest(req: Request): string | null { + const cookieHeader = req.headers.get("cookie") ?? ""; + const m = cookieHeader.match(/(?:^|;\s*)ma_at=([^;]+)/); + return m?.[1] ?? null; +} + +// ───────────────────────────────────────────────────────────── +// Low-level helpers (bearer REQUIRED; no fallbacks) +// ───────────────────────────────────────────────────────────── + +function authHeaders(bearer: string, extra?: HeadersInit): HeadersInit { + return { Accept: "application/json", Authorization: `Bearer ${bearer}`, ...extra }; +} + // Read response as text first; parse JSON if present so we never throw // "Unexpected end of JSON input" for empty/HTML bodies. async function parseJsonSafe(res: Response) { const text = await res.text(); let json: any = null; - try { - json = text ? JSON.parse(text) : null; - } catch { - // non-JSON body; keep as null and let caller see status if needed - } + try { json = text ? JSON.parse(text) : null; } catch {} return { json, text }; } -/** directusFetch with the SUBMIT token (used by options + settings submit) */ -export async function directusFetch( - path: string, - init?: RequestInit -): Promise { - const res = await fetch(`${BASE}${path}`, { - ...init, - headers: { - Accept: "application/json", - Authorization: `Bearer ${TOKEN_SUBMIT}`, - ...(init?.headers || {}), - }, - }); - +async function throwIfNotOk(res: Response) { const { json, text } = await parseJsonSafe(res); if (!res.ok) { - throw new Error(`Directus error ${res.status}: ${text || res.statusText}`); + const err: any = new Error(`Directus ${res.status}: ${text || res.statusText}`); + err.status = res.status; + err.detail = json ?? text; + throw err; } - return (json ?? {}) as T; + return json; } -/** Same as above, but uses the ADMIN REGISTER token */ -export async function directusAdminFetch( - path: string, - init?: RequestInit -): Promise { - if (!TOKEN_ADMIN_REGISTER) { - throw new Error("Missing DIRECTUS_TOKEN_ADMIN_REGISTER"); - } +export async function dxGET(path: string, bearer: string): Promise { + const res = await fetch(`${BASE}${path}`, { headers: authHeaders(bearer), cache: "no-store" }); + return (await throwIfNotOk(res)) as T; +} + +export async function dxPOST(path: string, bearer: string, body: any): Promise { + const res = await fetch(`${BASE}${path}`, { + method: "POST", + headers: authHeaders(bearer, { "content-type": "application/json" }), + body: JSON.stringify(body), + cache: "no-store", + }); + return (await throwIfNotOk(res)) as T; +} + +export async function dxPATCH(path: string, bearer: string, body: any): Promise { + const res = await fetch(`${BASE}${path}`, { + method: "PATCH", + headers: authHeaders(bearer, { "content-type": "application/json" }), + body: JSON.stringify(body), + cache: "no-store", + }); + return (await throwIfNotOk(res)) as T; +} + +export async function dxDELETE(path: string, bearer: string): Promise { + const res = await fetch(`${BASE}${path}`, { + method: "DELETE", + headers: authHeaders(bearer), + cache: "no-store", + }); + return (await throwIfNotOk(res)) as T; +} + +// ───────────────────────────────────────────────────────────── +// Admin/schema helpers — SERVER ONLY (never expose to client) +// ───────────────────────────────────────────────────────────── + +/** Server-only admin fetch (registration flows, etc.) */ +export async function directusAdminFetch(path: string, init?: RequestInit): Promise { + if (!TOKEN_ADMIN_REGISTER) throw new Error("Missing DIRECTUS_TOKEN_ADMIN_REGISTER"); const res = await fetch(`${BASE}${path}`, { ...init, - headers: { - Accept: "application/json", - Authorization: `Bearer ${TOKEN_ADMIN_REGISTER}`, - ...(init?.headers || {}), - }, + headers: { Accept: "application/json", Authorization: `Bearer ${TOKEN_ADMIN_REGISTER}`, ...(init?.headers || {}) }, + cache: "no-store", }); - - const { json, text } = await parseJsonSafe(res); - if (!res.ok) { - throw new Error(`Directus error ${res.status}: ${text || res.statusText}`); - } - return (json ?? {}) as T; + return (await throwIfNotOk(res)) as T; } -/** Schema-safe fetch: prefers schema-bot token, falls back to admin-register, then submit */ -export async function directusSchemaFetch( - path: string, - init?: RequestInit -): Promise { - const token = - TOKEN_SCHEMA_READ || TOKEN_ADMIN_REGISTER || TOKEN_SUBMIT; - +/** Server-only schema/meta reads (no SUBMIT fallback) */ +export async function dxSchemaGET(path: string): Promise { + if (!TOKEN_SCHEMA_READ) throw new Error("Missing DIRECTUS_TOKEN_SCHEMA_READ"); const res = await fetch(`${BASE}${path}`, { - ...init, - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - ...(init?.headers || {}), - }, + headers: { Accept: "application/json", Authorization: `Bearer ${TOKEN_SCHEMA_READ}` }, + cache: "no-store", }); - - const { json, text } = await parseJsonSafe(res); - if (!res.ok) { - throw new Error(`Directus error ${res.status}: ${text || res.statusText}`); - } - return (json ?? {}) as T; + return (await throwIfNotOk(res)) as T; } -/* ───────────────────────────────────────────────────────────── - * On-the-fly folder lookup by "path" = "/" - * Caches results in-memory. If lookup is forbidden (403) or not found, - * we return undefined and uploads proceed without a folder. - * Requires READ on directus_folders (id,name,parent.name) to fully work. - * ──────────────────────────────────────────────────────────── */ - -type FolderItem = { - id: string; - name: string; - parent?: { id?: string; name?: string } | null; -}; +// ───────────────────────────────────────────────────────────── +// Optional folder lookup (server-only if using admin token) +// ───────────────────────────────────────────────────────────── +type FolderItem = { id: string; name: string; parent?: { id?: string; name?: string } | null }; const folderCache = new Map(); let folderListCache: FolderItem[] | null = null; let folderListCacheAt = 0; @@ -152,20 +149,13 @@ async function getFolderIdByPath(path: string): Promise { return undefined; } - const parts = path - .split("/") - .map((s) => s.trim()) - .filter(Boolean); + const parts = path.split("/").map((s) => s.trim()).filter(Boolean); const [parentName, childName] = parts; - - const eq = (a?: string | null, b?: string | null) => - String(a ?? "").toLowerCase() === String(b ?? "").toLowerCase(); + const eq = (a?: string | null, b?: string | null) => String(a ?? "").toLowerCase() === String(b ?? "").toLowerCase(); let match: FolderItem | undefined; if (parts.length >= 2) { - match = list.find( - (f) => eq(f.name, childName) && eq(f.parent?.name ?? "", parentName) - ); + match = list.find((f) => eq(f.name, childName) && eq(f.parent?.name ?? "", parentName)); } else { match = list.find((f) => eq(f.name, parts[0])); } @@ -175,16 +165,20 @@ async function getFolderIdByPath(path: string): Promise { return id; } +// ───────────────────────────────────────────────────────────── +// Files & items — user bearer ONLY +// ───────────────────────────────────────────────────────────── + /** * uploadFile: - * Robustly uploads a file (Blob/File). Accepts optional folder routing: - * - options.folderId: direct UUID if you have it - * - options.folderNamePath: "/" lookup via /folders - * Also sets filename_download & optional title. + * Upload a file as the CURRENT USER (bearer required). + * If you also pass folderNamePath, ensure this is called SERVER-SIDE + * so the admin token used in getFolderIdByPath never reaches the client. */ export async function uploadFile( file: Blob | File, filename: string, + bearer: string, options?: { folderId?: string; folderNamePath?: string; title?: string } ): Promise<{ id: string }> { const form = new FormData(); @@ -194,93 +188,59 @@ export async function uploadFile( let folderId = options?.folderId; if (!folderId && options?.folderNamePath) { - try { - folderId = await getFolderIdByPath(options.folderNamePath); - } catch { - // ignore; proceed without a folder - } + // SERVER-ONLY: resolve folder by path using admin fetch/cache + try { folderId = await getFolderIdByPath(options.folderNamePath); } catch {} } if (folderId) form.set("folder", folderId); const res = await fetch(`${BASE}/files`, { method: "POST", - headers: { - Authorization: `Bearer ${TOKEN_SUBMIT}`, - Accept: "application/json", - }, - body: form, + headers: authHeaders(bearer), + body: form, + cache: "no-store", }); - const { json, text } = await parseJsonSafe(res); - if (!res.ok) { - throw new Error( - `File upload failed: status=${res.status} ${res.statusText} body=${ - (text || "").slice(0, 400) || "" - }` - ); - } - + const json = await throwIfNotOk(res); const id = json?.data?.id ?? json?.id; if (!id) throw new Error("File upload succeeded but no id returned"); return { id: String(id) }; } -/** Create a settings item (used by settings submissions) */ +/** Create a settings item (on behalf of the current user) */ export async function createSettingsItem( collection: string, - payload: any + payload: any, + bearer: string ): Promise<{ data: { id: string } }> { - return directusFetch<{ data: { id: string } }>(`/items/${collection}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); + return dxPOST<{ data: { id: string } }>(`/items/${collection}`, bearer, payload); } -/** ───────────────────────────────────────────────────────────── - * Project helpers (used by app/api/submit/project/route.ts) - * Collection can be overridden via DIRECTUS_PROJECTS_COLLECTION - * ──────────────────────────────────────────────────────────── */ - +/** Project helpers (bearer required) */ export async function createProjectRow( - payload: any + payload: any, + bearer: string ): Promise<{ data: { id: string } }> { - return directusFetch<{ data: { id: string } }>( - `/items/${PROJECTS_COLLECTION}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); + return dxPOST<{ data: { id: string } }>(`/items/${PROJECTS_COLLECTION}`, bearer, payload); } export async function patchProject( id: string | number, - payload: any + payload: any, + bearer: string ): Promise<{ data: { id: string } }> { - return directusFetch<{ data: { id: string } }>( - `/items/${PROJECTS_COLLECTION}/${id}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); + return dxPATCH<{ data: { id: string } }>(`/items/${PROJECTS_COLLECTION}/${id}`, bearer, payload); } -/* ───────────────────────────────────────────────────────────── - * Auth helpers (registration / login support) - * ──────────────────────────────────────────────────────────── */ +// ───────────────────────────────────────────────────────────── +// Auth helpers (registration / login support) +// ───────────────────────────────────────────────────────────── export async function resolveMemberRoleId(): Promise { if (ROLE_MEMBER_ID_ENV) return ROLE_MEMBER_ID_ENV; // Fallback by role name (e.g., "Users") const name = ROLE_MEMBER_NAME_ENV; - const q = `/roles?filter[name][_eq]=${encodeURIComponent( - name - )}&fields=id,name&limit=1`; + const q = `/roles?filter[name][_eq]=${encodeURIComponent(name)}&fields=id,name&limit=1`; const { data } = await directusAdminFetch<{ data: Array<{ id: string }> }>(q); const hit = data?.[0]?.id; if (!hit) throw new Error(`Role not found: ${name}`); @@ -318,15 +278,9 @@ export async function createDirectusUser(input: { } /** Find user's email by username (returns null if not found) */ -export async function emailForUsername( - username: string -): Promise { - const q = `/users?filter[username][_eq]=${encodeURIComponent( - username - )}&fields=email&limit=1`; - const { data } = await directusAdminFetch<{ data: Array<{ email?: string }> }>( - q - ); +export async function emailForUsername(username: string): Promise { + const q = `/users?filter[username][_eq]=${encodeURIComponent(username)}&fields=email&limit=1`; + const { data } = await directusAdminFetch<{ data: Array<{ email?: string }> }>(q); const em = data?.[0]?.email; return em ? String(em) : null; } @@ -335,17 +289,11 @@ export async function emailForUsername( export async function loginDirectus(email: string, password: string) { const res = await fetch(`${BASE}/auth/login`, { method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), + cache: "no-store", }); - - const { json, text } = await parseJsonSafe(res); - if (!res.ok) { - throw new Error(`Directus error ${res.status}: ${text || res.statusText}`); - } + const json = await throwIfNotOk(res); // Directus typically returns { data: { access_token, refresh_token, expires } } return json?.data ?? json; }