diff --git a/app/api/_lib/auth.ts b/app/api/_lib/auth.ts new file mode 100644 index 00000000..06d693a7 --- /dev/null +++ b/app/api/_lib/auth.ts @@ -0,0 +1,18 @@ +// app/api/_lib/auth.ts +import { getUserBearerFromRequest } from "@/lib/directus"; + +export function requireBearer(req: Request): string { + const bearer = getUserBearerFromRequest(req); + if (!bearer) { + const err: any = new Error("UNAUTHENTICATED"); + err.status = 401; + throw err; + } + return bearer; +} + +export function jsonError(e: any, fallback = 500) { + const status = e?.status ?? fallback; + const body = { error: e?.message || "ERROR", detail: e?.detail ?? String(e) }; + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} diff --git a/app/api/submit/project/route.ts b/app/api/submit/project/route.ts index 83e5a93c..c70aa020 100644 --- a/app/api/submit/project/route.ts +++ b/app/api/submit/project/route.ts @@ -5,7 +5,9 @@ import { createProjectRow, patchProject, bytesFromMB, + dxGET, } from "@/lib/directus"; +import { requireBearer } from "@/app/api/_lib/auth"; // Optional: tweak via env const MAX_MB = Number(process.env.FILE_MAX_MB || 25); @@ -32,127 +34,105 @@ export const runtime = "nodejs"; export async function POST(req: NextRequest) { try { - const ip = - req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - "0.0.0.0"; + const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "0.0.0.0"; if (!rateLimitOk(ip)) { return NextResponse.json({ error: "Rate limited" }, { status: 429 }); } + // NEW: enforce user auth + const bearer = requireBearer(req); + const ct = req.headers.get("content-type") || ""; if (!ct.includes("multipart/form-data")) { - return NextResponse.json( - { error: "Expected multipart/form-data" }, - { status: 400 } - ); + return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 }); } const form = await req.formData(); - // Required read-side field names from your repo: - // title, body (markdown), uploader, category, tags[], p_image (file), p_files (M2M to files) const title = String(form.get("title") || "").trim(); - const uploader = String(form.get("uploader") || "").trim(); const category = String(form.get("category") || "").trim(); const body = String(form.get("body") || form.get("description") || "").trim(); - if (!title || !uploader || !body) { - return NextResponse.json( - { error: "Missing required fields: title, uploader, body" }, - { status: 400 } - ); + if (!title || !body) { + return NextResponse.json({ error: "Missing required fields: title, body" }, { status: 400 }); } - // tags: allow comma-separated string or JSON array - let tags: string[] = []; - const rawTags = form.get("tags"); - if (typeof rawTags === "string" && rawTags.trim()) { - try { - // Accept JSON array - const maybeArray = JSON.parse(rawTags); - if (Array.isArray(maybeArray)) { - tags = maybeArray.map((t) => String(t).trim()).filter(Boolean); - } else { - // Fallback: comma list - tags = rawTags - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - } - } catch { - // Comma-separated - tags = rawTags - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - } + // Derive uploader from the authenticated user (ignore any spoofed form value) + const me = await dxGET("/users/me?fields=username,display_name,first_name,last_name,email", bearer); + const uploader = + me?.display_name || + me?.username || + [me?.first_name, me?.last_name].filter(Boolean).join(" ") || + me?.email || + "user"; + +// tags: allow comma-separated string or JSON array +let tags: string[] = []; +const rawTags = form.get("tags"); +if (typeof rawTags === "string" && rawTags.trim()) { + try { + const maybeArray = JSON.parse(rawTags); + if (Array.isArray(maybeArray)) { + tags = maybeArray.map((t) => String(t).trim()).filter(Boolean); + } else { + tags = rawTags.split(",").map((t) => t.trim()).filter(Boolean); } - - // Optional license (not shown on read pages, but harmless to store) - const license = - (form.get("license") && String(form.get("license")).trim()) || - undefined; - - // Upload hero image (single) - const hero = form.get("image") as File | null; // input name="image" - let p_image_id: string | undefined; - if (hero && typeof hero === "object" && "size" in hero) { - if (hero.size > MAX_BYTES) { - return NextResponse.json( - { error: `Hero image exceeds ${MAX_MB} MB` }, - { status: 400 } - ); - } - const up = await uploadFile(hero, (hero as File).name || "project-image"); - p_image_id = up.id; - } - - // Upload attachments (multiple) - const fileBlobs = form.getAll("files").filter(Boolean) as File[]; - const attachIds: string[] = []; - for (const f of fileBlobs.slice(0, 20)) { - if (f.size > MAX_BYTES) { - return NextResponse.json( - { error: `One of the files exceeds ${MAX_MB} MB` }, - { status: 400 } - ); - } - const up = await uploadFile(f, (f as File).name || "attachment"); - attachIds.push(up.id); - } - - // 1) Create the project row - const { data: created } = await createProjectRow({ - title, - body, // you render `project.body` in detail page - uploader, // exact key used by your list/detail - category, - tags, // stored as array - ...(license ? { license } : {}), - status: "pending", - submitted_via: "makearmy-app", - submitted_at: new Date().toISOString(), - }); - - // 2) Patch hero image + M2M attachments in one go - // For M2M (p_files), Directus accepts nested objects to create junction rows - // e.g. [{ directus_files_id: "" }, ...] - const patch: Record = {}; - if (p_image_id) patch.p_image = p_image_id; - if (attachIds.length) { - patch.p_files = attachIds.map((id) => ({ directus_files_id: id })); - } - - if (Object.keys(patch).length) { - await patchProject(created.id, patch); - } - - return NextResponse.json({ ok: true, id: created.id }); - } catch (err: any) { - return NextResponse.json( - { error: err?.message || "Unknown error" }, - { status: 500 } - ); + } catch { + tags = rawTags.split(",").map((t) => t.trim()).filter(Boolean); } } +const license = (form.get("license") && String(form.get("license")).trim()) || undefined; + +// Upload hero image (single) +const hero = form.get("image") as File | null; +let p_image_id: string | undefined; +if (hero && typeof hero === "object" && "size" in hero) { + if (hero.size > MAX_BYTES) { + return NextResponse.json({ error: `Hero image exceeds ${MAX_MB} MB` }, { status: 400 }); + } + const up = await uploadFile(hero, (hero as File).name || "project-image", bearer); + p_image_id = up.id; +} + +// Upload attachments (multiple) +const fileBlobs = form.getAll("files").filter(Boolean) as File[]; +const attachIds: string[] = []; +for (const f of fileBlobs.slice(0, 20)) { + if (f.size > MAX_BYTES) { + return NextResponse.json({ error: `One of the files exceeds ${MAX_MB} MB` }, { status: 400 }); + } + const up = await uploadFile(f, (f as File).name || "attachment", bearer); + attachIds.push(up.id); +} + +// 1) Create the project row (as current user) +const { data: created } = await createProjectRow( + { + title, + body, + uploader, // server-derived + category, + tags, + ...(license ? { license } : {}), + status: "pending", + submitted_via: "makearmy-app", + submitted_at: new Date().toISOString(), + }, + bearer +); + +// 2) Patch hero image + M2M attachments in one go +const patch: Record = {}; +if (p_image_id) patch.p_image = p_image_id; +if (attachIds.length) patch.p_files = attachIds.map((id) => ({ directus_files_id: id })); + +if (Object.keys(patch).length) { + await patchProject(created.id, patch, bearer); +} + +return NextResponse.json({ ok: true, id: created.id }); + } catch (err: any) { + return NextResponse.json({ error: err?.message || "Unknown error" }, { status: err?.status ?? 500 }); + } +} diff --git a/app/api/submit/settings/route.ts b/app/api/submit/settings/route.ts index 93ff6b4d..76ef3925 100644 --- a/app/api/submit/settings/route.ts +++ b/app/api/submit/settings/route.ts @@ -1,6 +1,7 @@ // app/api/submit/settings/route.ts import { NextResponse } from "next/server"; -import { uploadFile, createSettingsItem, bytesFromMB } from "@/lib/directus"; +import { uploadFile, createSettingsItem, bytesFromMB, dxGET } from "@/lib/directus"; +import { requireBearer } from "@/app/api/_lib/auth"; /** ───────────────────────────────────────────────────────────── * Accepts EITHER: @@ -8,7 +9,7 @@ import { uploadFile, createSettingsItem, bytesFromMB } from "@/lib/directus"; * (photo/screen can be existing file ids on the body) * - multipart/form-data with: * - payload = JSON string (same shape as JSON body) - * - photo = File (required) + * - photo = File (required if no photo id present) * - screen = File (optional) * * Targets (collections): @@ -47,10 +48,6 @@ function num(v: any, fallback: number | null = null) { return Number.isFinite(n) ? n : fallback; } -function bool(v: any) { - return !!v; -} - type ReadResult = { mode: "json" | "multipart"; body: any; @@ -64,7 +61,6 @@ async function readJsonOrMultipart(req: Request): Promise { if (ct.includes("multipart/form-data")) { const form = await (req as any).formData(); - // payload JSON (required for the structured fields) const payloadRaw = String(form.get("payload") ?? "{}"); let body: any = {}; try { @@ -73,7 +69,6 @@ async function readJsonOrMultipart(req: Request): Promise { throw new Error("Invalid JSON in 'payload'"); } - // Files (File or null) const p = form.get("photo"); const s = form.get("screen"); const photoFile = p instanceof File && p.size > 0 ? (p as File) : null; @@ -82,7 +77,6 @@ async function readJsonOrMultipart(req: Request): Promise { return { mode: "multipart", body, photoFile, screenFile }; } - // JSON body const body = await (req as any).json().catch(() => ({})); return { mode: "json", body, photoFile: null, screenFile: null }; } @@ -119,6 +113,9 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Rate limited" }, { status: 429 }); } + // NEW: enforce user auth + const bearer = requireBearer(req); + const { mode, body, photoFile, screenFile } = await readJsonOrMultipart(req); const target: Target = body?.target; @@ -128,9 +125,16 @@ export async function POST(req: Request) { // Required basics const setting_title = String(body?.setting_title || "").trim(); - const uploader = String(body?.uploader || "").trim(); if (!setting_title) return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 }); - if (!uploader) return NextResponse.json({ error: "Missing required: uploader" }, { status: 400 }); + + // Derive uploader from the authenticated user (ignore any spoofed value in body) + const me = await dxGET("/users/me?fields=username,display_name,first_name,last_name,email", bearer); + const uploader = + me?.display_name || + me?.username || + [me?.first_name, me?.last_name].filter(Boolean).join(" ") || + me?.email || + "user"; // Relations & numerics const mat = body?.mat ?? null; @@ -155,7 +159,7 @@ export async function POST(req: Request) { if (!photo_id && photoFile) { if (photoFile.size > MAX_BYTES) return NextResponse.json({ error: `Photo exceeds ${MAX_MB} MB` }, { status: 400 }); - const up = await uploadFile(photoFile, (photoFile as File).name, { + const up = await uploadFile(photoFile, (photoFile as File).name, bearer, { folderNamePath: folderPathFor(target, "photo"), title: setting_title, }); @@ -168,7 +172,7 @@ export async function POST(req: Request) { if (!screen_id && screenFile) { if (screenFile.size > MAX_BYTES) return NextResponse.json({ error: `Screenshot exceeds ${MAX_MB} MB` }, { status: 400 }); - const up = await uploadFile(screenFile, (screenFile as File).name, { + const up = await uploadFile(screenFile, (screenFile as File).name, bearer, { folderNamePath: folderPathFor(target, "screen"), title: `${setting_title} (screen)`, }); @@ -185,11 +189,11 @@ export async function POST(req: Request) { const payload: Record = { setting_title, - uploader, + uploader, // ← server-derived from /users/me setting_notes, - submission_date: nowIso, // required by your schema - last_modified_date: nowIso, // ← add this to satisfy required rule + submission_date: nowIso, // required by your schema + last_modified_date: nowIso, // keep in sync photo: photo_id, screen: screen_id ?? null, @@ -217,11 +221,11 @@ export async function POST(req: Request) { payload.repeat_all = repeat_all ?? null; } - const { data } = await createSettingsItem(target, payload); + const { data } = await createSettingsItem(target, payload, bearer); return NextResponse.json({ ok: true, id: data.id }); } catch (err: any) { console.error("[submit/settings] error", err?.message || err); - return NextResponse.json({ error: err?.message || "Unknown error" }, { status: 500 }); + return NextResponse.json({ error: err?.message || "Unknown error" }, { status: err?.status ?? 500 }); } finally { const ms = Date.now() - started; if (ms) console.log(`[submit/settings] handled in ~${ms}ms`); diff --git a/app/components/forms/SettingsSubmit.tsx b/app/components/forms/SettingsSubmit.tsx index 194755c6..f8e99275 100644 --- a/app/components/forms/SettingsSubmit.tsx +++ b/app/components/forms/SettingsSubmit.tsx @@ -17,6 +17,37 @@ type Me = { const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); +// ───────────────────────────────────────────────────────────── +// Local enums (no schema introspection) +// ───────────────────────────────────────────────────────────── +const FILL_TYPE_OPTIONS = [ + { label: "UniDirectional", value: "uni" }, +{ label: "BiDirectional", value: "bi" }, +{ label: "Offset Fill", value: "offset" }, +]; + +const RASTER_TYPE_OPTIONS = [ + { label: "UniDirectional", value: "uni" }, +{ label: "BiDirectional", value: "bi" }, +{ label: "Offset Fill", value: "offset" }, +]; + +const RASTER_DITHER_OPTIONS = [ + { label: "Threshold", value: "threshold" }, +{ label: "Ordered", value: "ordered" }, +{ label: "Atkinson", value: "atkinson" }, +{ label: "Dither", value: "dither" }, +{ label: "Stucki", value: "stucki" }, +{ label: "Jarvis", value: "jarvis" }, +{ label: "Newsprint", value: "newsprint" }, +{ label: "Halftone", value: "halftone" }, +{ label: "Sketch", value: "sketch" }, +{ label: "Grayscale", value: "grayscale" }, +]; + +const toOpts = (arr: { label: string; value: string }[]): Opt[] => +arr.map((x) => ({ id: x.value, label: x.label })); + function shortId(s?: string) { if (!s) return ""; return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`; @@ -108,25 +139,6 @@ function useOptions(path: string) { }); }; } - } else if (rawPath === "repeater-choices") { - const group = params.get("group") || ""; - const field = params.get("field") || ""; - const collection = params.get("target") || ""; - - const proxy = `/api/directus/choices?collection=${encodeURIComponent(collection)}&group=${encodeURIComponent(group)}&field=${encodeURIComponent(field)}`; - const r = await fetch(proxy, { cache: "no-store" }); - if (!r.ok) throw new Error(`Proxy ${r.status} fetching ${proxy}`); - const j = await r.json().catch(() => ({})); - const mapped: Opt[] = Array.isArray(j?.data) ? j.data : []; - - if (alive) { - const needle = (q || "").trim().toLowerCase(); - const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; - setOpts(filtered); - setLoading(false); - } - return; - } else { // unknown path → empty setOpts([]); @@ -259,11 +271,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ useEffect(() => { let alive = true; - fetch(`/api/auth/me`, { cache: "no-store", credentials: "include" }) + // use our bearer-only API + fetch(`/api/me`, { cache: "no-store", credentials: "include" }) .then((r) => (r.ok ? r.json() : Promise.reject(r))) .then((j) => { if (!alive) return; - // j is the user object directly (not wrapped in { data }) setMe(j || null); }) .catch(() => { @@ -276,10 +288,10 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ }, []); const meLabel = + me?.display_name?.trim() || + [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() || me?.username?.trim() || me?.email?.trim() || - [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() || - me?.display_name?.trim() || (me?.id ? `User ${me.id.slice(0, 8)}…${me.id.slice(-4)}` : "Unknown user"); // Options @@ -293,10 +305,10 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ const srcs = useOptions(`laser_source?target=${typeForOptions}`); const lens = useOptions(`lens?target=${typeForOptions}`); - // Repeater choice options - const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); - const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); - const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); + // Repeater choice options (LOCAL now, no network) + const fillType = { opts: toOpts(FILL_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} }; + const rasterType = { opts: toOpts(RASTER_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} }; + const rasterDither = { opts: toOpts(RASTER_DITHER_OPTIONS), loading: false, setQ: (_: string) => {} }; const { register, @@ -361,7 +373,7 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ speed: num(r.speed), interval: num(r.interval), pass: num(r.pass), - type: r.type || "", + type: r.type || "", // now driven by local enum frequency: num(r.frequency), pulse: num(r.pulse), angle: num(r.angle), @@ -390,8 +402,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ name: r.name || "", power: num(r.power), speed: num(r.speed), - type: r.type || "", - dither: r.dither || "", + type: r.type || "", // now driven by local enum + dither: r.dither || "", // now driven by local enum halftone_cell: num(r.halftone_cell), halftone_angle: num(r.halftone_angle), inversion: bool(r.inversion), @@ -651,7 +663,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
Fill Settings -
@@ -663,8 +679,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ name={`fill_settings.${i}.type`} register={register} options={fillType.opts} - loading={fillType.loading} - onQuery={fillType.setQ} + loading={false} + onQuery={() => {}} placeholder="Select type" /> @@ -726,7 +742,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
Raster Settings -
@@ -740,8 +760,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ name={`raster_settings.${i}.type`} register={register} options={rasterType.opts} - loading={rasterType.loading} - onQuery={rasterType.setQ} + loading={false} + onQuery={() => {}} placeholder="Select type" /> {}} placeholder="Select dither" />