migrated lib/directus.ts to user bearer from public submit

This commit is contained in:
makearmy 2025-09-29 12:01:08 -04:00
parent 6e3cba4308
commit 756efbf948
2 changed files with 121 additions and 175 deletions

View file

@ -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<T = any>(
path: string,
init?: RequestInit
): Promise<T> {
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<T = any>(
path: string,
init?: RequestInit
): Promise<T> {
if (!TOKEN_ADMIN_REGISTER) {
throw new Error("Missing DIRECTUS_TOKEN_ADMIN_REGISTER");
}
export async function dxGET<T = any>(path: string, bearer: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { headers: authHeaders(bearer), cache: "no-store" });
return (await throwIfNotOk(res)) as T;
}
export async function dxPOST<T = any>(path: string, bearer: string, body: any): Promise<T> {
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<T = any>(path: string, bearer: string, body: any): Promise<T> {
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<T = any>(path: string, bearer: string): Promise<T> {
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<T = any>(path: string, init?: RequestInit): Promise<T> {
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<T = any>(
path: string,
init?: RequestInit
): Promise<T> {
const token =
TOKEN_SCHEMA_READ || TOKEN_ADMIN_REGISTER || TOKEN_SUBMIT;
/** Server-only schema/meta reads (no SUBMIT fallback) */
export async function dxSchemaGET<T = any>(path: string): Promise<T> {
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" = "<parent>/<child>"
* 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<string, string | undefined>();
let folderListCache: FolderItem[] | null = null;
let folderListCacheAt = 0;
@ -152,20 +149,13 @@ async function getFolderIdByPath(path: string): Promise<string | undefined> {
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<string | undefined> {
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: "<parent>/<child>" 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) || "<empty>"
}`
);
}
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<string> {
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<string | null> {
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<string | null> {
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;
}