user auth bearer updates to multiple apis and scripts
This commit is contained in:
parent
eb1a97541e
commit
b54120d88e
4 changed files with 182 additions and 160 deletions
|
|
@ -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<any>("/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: "<file-id>" }, ...]
|
||||
const patch: Record<string, any> = {};
|
||||
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<string, any> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ReadResult> {
|
|||
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<ReadResult> {
|
|||
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<ReadResult> {
|
|||
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<any>("/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<string, any> = {
|
||||
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`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue