Harden authentication and writable endpoints

This commit is contained in:
makearmy 2026-07-09 22:10:26 -04:00
parent c034824338
commit 0a7ee5ff35
33 changed files with 308 additions and 284 deletions

View file

@ -3,9 +3,12 @@ export const runtime = "nodejs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth"; import { requireBearer } from "@/app/api/_lib/auth";
import { hasValidImageSignature } from "@/lib/upload-security";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const AVATAR_FOLDER_ID = process.env.DIRECTUS_AVATAR_FOLDER_ID || ""; const AVATAR_FOLDER_ID = process.env.DIRECTUS_AVATAR_FOLDER_ID || "";
const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
const AVATAR_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
function bad(msg: string, code = 400) { function bad(msg: string, code = 400) {
return NextResponse.json({ error: msg }, { status: code }); return NextResponse.json({ error: msg }, { status: code });
@ -22,6 +25,11 @@ export async function POST(req: Request) {
const form = await req.formData(); const form = await req.formData();
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof Blob)) return bad("Missing file"); if (!(file instanceof Blob)) return bad("Missing file");
if (file.size > MAX_AVATAR_BYTES) return bad("Avatar must be 10 MB or smaller", 413);
if (!AVATAR_TYPES.has(file.type.toLowerCase())) {
return bad("Avatar must be a JPEG, PNG, WebP, or GIF image", 415);
}
if (!(await hasValidImageSignature(file))) return bad("Avatar file contents are not a supported image", 415);
// Upload directly into the configured avatars folder // Upload directly into the configured avatars folder
const up = new FormData(); const up = new FormData();

View file

@ -1,7 +1,7 @@
// app/api/account/password/route.ts // app/api/account/password/route.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth"; import { requireBearer } from "@/app/api/_lib/auth";
import { loginDirectus } from "@/lib/directus"; import { emailForUsername, loginDirectus } from "@/lib/directus";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -33,8 +33,8 @@ async function handle(req: Request) {
const bearer = requireBearer(req); const bearer = requireBearer(req);
const body = await req.json().catch(() => ({} as any)); const body = await req.json().catch(() => ({} as any));
const current = String(body?.current ?? body?.current_password ?? "").trim(); const current = String(body?.current ?? body?.current_password ?? "");
const next = String(body?.next ?? body?.new_password ?? "").trim(); const next = String(body?.next ?? body?.new_password ?? "");
// NEW: allow client to provide identifier explicitly // NEW: allow client to provide identifier explicitly
let identifier = String(body?.identifier ?? "").trim(); let identifier = String(body?.identifier ?? "").trim();
@ -78,8 +78,12 @@ async function handle(req: Request) {
}); });
} }
// 4) Verify CURRENT password by logging in with identifier // 4) Directus local auth accepts email only. Resolve a client-provided
const auth = await loginDirectus(identifier, current).catch(() => null); // username before verifying the current password.
const email = identifier.includes("@")
? identifier.toLowerCase()
: await emailForUsername(identifier).catch(() => null);
const auth = email ? await loginDirectus(email, current).catch(() => null) : null;
const access = auth?.access_token ?? auth?.data?.access_token; const access = auth?.access_token ?? auth?.data?.access_token;
if (!access) { if (!access) {
return bad("Current password is incorrect", 401); return bad("Current password is incorrect", 401);

View file

@ -59,7 +59,7 @@ export async function PATCH(req: Request) {
resp.cookies.set({ resp.cookies.set({
name: "ma_ra", name: "ma_ra",
value: "", value: "",
httpOnly: false, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
path: "/", path: "/",

View file

@ -84,7 +84,7 @@ export async function PATCH(req: Request) {
resp.cookies.set({ resp.cookies.set({
name: "ma_ra", name: "ma_ra",
value: "", value: "",
httpOnly: false, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure, secure,
path: "/", path: "/",

View file

@ -1,53 +1,58 @@
// app/api/auth/login/route.ts // app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { emailForUsername, loginDirectus } from "@/lib/directus"; import { emailForUsername, loginDirectus } from "@/lib/directus";
import { rateLimit, requestIp } from "@/lib/rate-limit";
export const runtime = "nodejs"; export const runtime = "nodejs";
const secure = process.env.NODE_ENV === "production"; const secure = process.env.NODE_ENV === "production";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
if (!rateLimit(`login:${requestIp(req)}`, 20, 60_000)) {
return NextResponse.json({ error: "Too many sign-in attempts. Please try again shortly." }, { status: 429 });
}
const body = await req.json().catch(() => ({} as any)); const body = await req.json().catch(() => ({} as any));
const identifier = const identifier =
String(body?.identifier ?? body?.email ?? body?.username ?? "").trim(); String(body?.identifier ?? body?.email ?? body?.username ?? "").trim();
const password = String(body?.password ?? "").trim(); // Passwords may legally begin or end with whitespace. Do not mutate them.
const password = String(body?.password ?? "");
const reauth = body?.reauth === true;
if (!identifier || !password) { if (!identifier || !password) {
return NextResponse.json({ error: "Missing credentials" }, { status: 400 }); return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
} }
// 1) Try Directus directly with the identifier (email OR username) // Directus' local auth endpoint only accepts a valid email in its
// Directus expects the field name "email" for both. // `email` field. Resolve our custom username field before calling it.
const tryIds: string[] = [identifier]; // Never send a username as `email`: Directus rejects that payload with
// INVALID_PAYLOAD before it even checks the password.
// 2) Fallback: if it doesnt look like an email, try the canonical email (if any) let email = identifier.toLowerCase();
if (!identifier.includes("@")) { if (!identifier.includes("@")) {
try { try {
const em = await emailForUsername(identifier); // returns string|null email = (await emailForUsername(identifier))?.trim().toLowerCase() ?? "";
if (em && em !== identifier) tryIds.push(em); } catch (error) {
} catch { console.error("[auth/login] Unable to resolve username", error);
// ignore lookup errors, we'll just rely on the first attempt return NextResponse.json(
{ error: "Sign-in is temporarily unavailable." },
{ status: 503 }
);
} }
} }
if (!email) {
return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 });
}
let tokens: any = null; let tokens: any = null;
let lastErr: any = null; try {
for (const id of tryIds) { tokens = await loginDirectus(email, password);
try { } catch {
tokens = await loginDirectus(id, password); // { access_token, refresh_token, expires? } // Do not expose Directus payload details or whether an account exists.
if (tokens) break; return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 });
} catch (e) {
lastErr = e;
}
} }
if (!tokens?.access_token) { if (!tokens?.access_token) {
const msg = return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 });
lastErr?.response?.data?.errors?.[0]?.message ||
lastErr?.response?.data?.error ||
lastErr?.message ||
"Invalid credentials.";
return NextResponse.json({ error: msg }, { status: 401 });
} }
// Set HttpOnly cookies for your middleware // Set HttpOnly cookies for your middleware
@ -69,6 +74,15 @@ return NextResponse.json({ error: msg }, { status: 401 });
maxAge: 60 * 60 * 24 * 30, // 30d maxAge: 60 * 60 * 24 * 30, // 30d
}); });
} }
if (reauth) {
res.cookies.set("ma_ra", "1", {
path: "/",
httpOnly: true,
sameSite: "lax",
secure,
maxAge: 5 * 60,
});
}
return res; return res;
} catch (err: any) { } catch (err: any) {
const message = const message =

View file

@ -9,15 +9,19 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json().catch(() => ({} as any)); const body = await req.json().catch(() => ({} as any));
const identifier = String(body?.identifier ?? "").trim(); const identifier = String(body?.identifier ?? "").trim();
const password = String(body?.password ?? "").trim(); const password = String(body?.password ?? "");
if (!identifier || !password) { if (!identifier || !password) {
return NextResponse.json({ error: "Missing credentials" }, { status: 400 }); return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
} }
// Resolve identifier -> email (username or email accepted) // Resolve identifier -> email (username or email accepted)
const email = identifier.includes("@") ? identifier : await emailForUsername(identifier); const email = identifier.includes("@")
if (!email) return NextResponse.json({ error: "User not found" }, { status: 404 }); ? identifier.trim().toLowerCase()
: (await emailForUsername(identifier))?.trim().toLowerCase();
if (!email) {
return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 });
}
const auth = await loginDirectus(email, password); const auth = await loginDirectus(email, password);
const access = auth?.access_token ?? auth?.data?.access_token; const access = auth?.access_token ?? auth?.data?.access_token;
@ -46,7 +50,7 @@ export async function POST(req: NextRequest) {
res.cookies.set({ res.cookies.set({
name: "ma_ra", name: "ma_ra",
value: "1", value: "1",
httpOnly: false, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure, secure,
path: "/", path: "/",
@ -55,12 +59,7 @@ export async function POST(req: NextRequest) {
return res; return res;
} catch (err: any) { } catch (err: any) {
const msg = console.error("[auth/reconfirm] Re-authentication failed", err);
err?.response?.data?.errors?.[0]?.message || return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 });
err?.response?.data?.error ||
err?.message ||
"Re-auth failed";
const status = /invalid|credential/i.test(msg) ? 401 : 400;
return NextResponse.json({ error: msg }, { status });
} }
} }

View file

@ -1,5 +1,6 @@
// app/api/auth/register/route.ts // app/api/auth/register/route.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { rateLimit, requestIp } from "@/lib/rate-limit";
const DIRECTUS = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); const DIRECTUS = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const SERVICE_TOKEN = const SERVICE_TOKEN =
@ -28,14 +29,17 @@ async function directusLogin(email: string, password: string) {
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
if (!rateLimit(`register:${requestIp(req)}`, 5, 60 * 60_000)) {
return bad("Too many registration attempts. Please try again later.", 429);
}
if (!DIRECTUS) return bad("Missing DIRECTUS_URL/NEXT_PUBLIC_API_BASE_URL", 500); if (!DIRECTUS) return bad("Missing DIRECTUS_URL/NEXT_PUBLIC_API_BASE_URL", 500);
if (!SERVICE_TOKEN) return bad("Missing DIRECTUS_SERVICE_TOKEN / admin token", 500); if (!SERVICE_TOKEN) return bad("Missing DIRECTUS_SERVICE_TOKEN / admin token", 500);
const body = await req.json().catch(() => ({} as any)); const body = await req.json().catch(() => ({} as any));
const email = String(body?.email ?? "").trim().toLowerCase(); const email = String(body?.email ?? "").trim().toLowerCase();
const username = String(body?.username ?? "").trim(); const username = String(body?.username ?? "").trim();
const password = String(body?.password ?? "").trim(); const password = String(body?.password ?? "");
const confirm = String(body?.confirmPassword ?? body?.confirm ?? "").trim(); const confirm = String(body?.confirmPassword ?? body?.confirm ?? "");
if (!email || !username || !password || !confirm) return bad("All fields are required"); if (!email || !username || !password || !confirm) return bad("All fields are required");
if (!EMAIL_RE.test(email)) return bad("Enter a valid email address"); if (!EMAIL_RE.test(email)) return bad("Enter a valid email address");

View file

@ -1,6 +1,10 @@
export const runtime = "nodejs"; export const runtime = "nodejs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getUserBearerFromRequest } from "@/lib/directus";
import { rateLimit, requestIp } from "@/lib/rate-limit";
const MAX_BODY_BYTES = 30 * 1024 * 1024;
/** /**
* Proxies multipart POSTs (file + method) to the bgbye upstream. * Proxies multipart POSTs (file + method) to the bgbye upstream.
@ -17,8 +21,26 @@ export async function POST(req: Request) {
); );
} }
if (!getUserBearerFromRequest(req)) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
if (!rateLimit(`bgbye:${requestIp(req)}`, 10, 60_000)) {
return NextResponse.json({ error: "Too many processing requests" }, { status: 429 });
}
const contentLength = Number(req.headers.get("content-length") || 0);
if (contentLength > MAX_BODY_BYTES) {
return NextResponse.json({ error: "Upload is too large" }, { status: 413 });
}
try { try {
const form = await req.formData(); const form = await req.formData();
const uploadedBytes = Array.from(form.values()).reduce(
(total, value) => total + (value instanceof Blob ? value.size : 0),
0
);
if (uploadedBytes > MAX_BODY_BYTES) {
return NextResponse.json({ error: "Upload is too large" }, { status: 413 });
}
// Forward the form as-is to the upstream // Forward the form as-is to the upstream
const ures = await fetch(upstream, { const ures = await fetch(upstream, {

View file

@ -16,13 +16,20 @@ function safeJoin(root: string, reqPath: string) {
return joined; return joined;
} }
async function ensureRealPathInsideRoot(candidate: string) {
const [realRoot, realCandidate] = await Promise.all([fsp.realpath(ROOT), fsp.realpath(candidate)]);
const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
if (realCandidate !== realRoot && !realCandidate.startsWith(prefix)) throw new Error("Symlink escape blocked");
return realCandidate;
}
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const reqPath = searchParams.get("path") || ""; const reqPath = searchParams.get("path") || "";
if (!reqPath) return new NextResponse("Missing path", { status: 400 }); if (!reqPath) return new NextResponse("Missing path", { status: 400 });
const abs = safeJoin(ROOT, reqPath); const abs = await ensureRealPathInsideRoot(safeJoin(ROOT, reqPath));
const stat = await fsp.stat(abs).catch(() => null); const stat = await fsp.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) return new NextResponse("Not found", { status: 404 }); if (!stat || !stat.isFile()) return new NextResponse("Not found", { status: 404 });

View file

@ -14,11 +14,18 @@ function safeJoin(root: string, reqPath: string) {
return joined; return joined;
} }
async function ensureRealPathInsideRoot(candidate: string) {
const [realRoot, realCandidate] = await Promise.all([fs.realpath(ROOT), fs.realpath(candidate)]);
const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
if (realCandidate !== realRoot && !realCandidate.startsWith(prefix)) throw new Error("Symlink escape blocked");
return realCandidate;
}
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const reqPath = searchParams.get("path") || "/"; const reqPath = searchParams.get("path") || "/";
const abs = safeJoin(ROOT, reqPath); const abs = await ensureRealPathInsideRoot(safeJoin(ROOT, reqPath));
const stat = await fs.stat(abs).catch(() => null); const stat = await fs.stat(abs).catch(() => null);
if (!stat) return NextResponse.json({ path: reqPath, items: [] }); if (!stat) return NextResponse.json({ path: reqPath, items: [] });

View file

@ -55,6 +55,13 @@ function safeJoin(root: string, reqPath: string) {
return joined; return joined;
} }
async function ensureRealPathInsideRoot(candidate: string) {
const [realRoot, realCandidate] = await Promise.all([fsp.realpath(ROOT), fsp.realpath(candidate)]);
const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
if (realCandidate !== realRoot && !realCandidate.startsWith(prefix)) throw new Error("Symlink escape blocked");
return realCandidate;
}
function isPreviewAllowed(absPath: string, contentType: string) { function isPreviewAllowed(absPath: string, contentType: string) {
const ext = path.extname(absPath).toLowerCase(); const ext = path.extname(absPath).toLowerCase();
@ -91,7 +98,7 @@ export async function GET(req: Request) {
}); });
} }
const abs = safeJoin(ROOT, reqPath); const abs = await ensureRealPathInsideRoot(safeJoin(ROOT, reqPath));
const stat = await fsp.stat(abs).catch(() => null); const stat = await fsp.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) { if (!stat || !stat.isFile()) {

View file

@ -8,6 +8,7 @@ import {
dxGET, dxGET,
} from "@/lib/directus"; } from "@/lib/directus";
import { requireBearer } from "@/app/api/_lib/auth"; import { requireBearer } from "@/app/api/_lib/auth";
import { hasValidImageSignature, isExecutableAttachment } from "@/lib/upload-security";
// Optional: tweak via env // Optional: tweak via env
const MAX_MB = Number(process.env.FILE_MAX_MB || 25); const MAX_MB = Number(process.env.FILE_MAX_MB || 25);
@ -91,6 +92,9 @@ if (hero && typeof hero === "object" && "size" in hero) {
if (hero.size > MAX_BYTES) { if (hero.size > MAX_BYTES) {
return NextResponse.json({ error: `Hero image exceeds ${MAX_MB} MB` }, { status: 400 }); return NextResponse.json({ error: `Hero image exceeds ${MAX_MB} MB` }, { status: 400 });
} }
if (!hero.type.startsWith("image/") || !(await hasValidImageSignature(hero))) {
return NextResponse.json({ error: "Hero image must be a valid JPEG, PNG, WebP, or GIF" }, { status: 415 });
}
const up = await uploadFile(hero, (hero as File).name || "project-image", bearer); const up = await uploadFile(hero, (hero as File).name || "project-image", bearer);
p_image_id = up.id; p_image_id = up.id;
} }
@ -102,6 +106,9 @@ for (const f of fileBlobs.slice(0, 20)) {
if (f.size > MAX_BYTES) { if (f.size > MAX_BYTES) {
return NextResponse.json({ error: `One of the files exceeds ${MAX_MB} MB` }, { status: 400 }); return NextResponse.json({ error: `One of the files exceeds ${MAX_MB} MB` }, { status: 400 });
} }
if (isExecutableAttachment(f.name || "")) {
return NextResponse.json({ error: `Executable attachment type is not allowed: ${f.name}` }, { status: 415 });
}
const up = await uploadFile(f, (f as File).name || "attachment", bearer); const up = await uploadFile(f, (f as File).name || "attachment", bearer);
attachIds.push(up.id); attachIds.push(up.id);
} }

View file

@ -1,5 +1,6 @@
// app/api/settings/route.ts // app/api/settings/route.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { hasValidImageSignature } from "@/lib/upload-security";
/** /**
* Fresh, minimal Directus client (no external helpers). * Fresh, minimal Directus client (no external helpers).
@ -15,6 +16,8 @@ export const runtime = "nodejs";
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
const DX = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); const DX = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const SUBMIT_TOKEN = process.env.DIRECTUS_TOKEN_SUBMIT || ""; const SUBMIT_TOKEN = process.env.DIRECTUS_TOKEN_SUBMIT || "";
const MAX_IMAGE_BYTES = Number(process.env.SETTINGS_IMAGE_MAX_MB || 25) * 1024 * 1024;
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
// Folder IDs from env (data sheet says fixed, not browsable) // Folder IDs from env (data sheet says fixed, not browsable)
const FOLDERS = { const FOLDERS = {
@ -47,6 +50,11 @@ function bearerFrom(req: Request) {
} }
async function dxUpload(file: File, folderId: string, bearer: string) { async function dxUpload(file: File, folderId: string, bearer: string) {
if (file.size > MAX_IMAGE_BYTES) throw new Error("Image exceeds the upload size limit.");
if (!IMAGE_TYPES.has(file.type.toLowerCase())) {
throw new Error("Image must be a JPEG, PNG, WebP, or GIF.");
}
if (!(await hasValidImageSignature(file))) throw new Error("Image file contents are invalid.");
const form = new FormData(); const form = new FormData();
form.set("file", file, file.name || "upload"); form.set("file", file, file.name || "upload");
if (folderId) form.set("folder", folderId); if (folderId) form.set("folder", folderId);

View file

@ -1,36 +1,35 @@
// /app/api/support/badges/route.ts // /app/api/support/badges/route.ts
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { fetchMembershipBadges } from "@/lib/memberships"; import { fetchMembershipBadges } from "@/lib/memberships";
import { requireBearer } from "@/app/api/_lib/auth";
import { dxGET } from "@/lib/directus";
// Replace this with your real auth lookup if/when you wire it in
async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> { async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> {
const uid = req.headers.get("x-user-id") || undefined; const bearer = requireBearer(req);
const email = req.headers.get("x-user-email") || undefined; const response = await dxGET<any>("/users/me?fields=id,email", bearer);
return uid || email ? { id: uid, email } : null; const me = response?.data ?? response;
return me?.id ? { id: String(me.id), email: me.email ? String(me.email) : undefined } : null;
} }
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const me = await getCurrentUser(req); const me = await getCurrentUser(req);
// Accept explicit query params so the component can work without special headers if (!me?.id) {
const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined; return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const userIdParam = (req.nextUrl.searchParams.get("userId") || "").trim() || undefined;
if (!emailParam && !userIdParam && !me?.id && !me?.email) {
return NextResponse.json({ error: "Provide email or userId" }, { status: 400 });
} }
const badges = await fetchMembershipBadges({ const badges = await fetchMembershipBadges({
userId: userIdParam || me?.id, userId: me.id,
email: emailParam || me?.email, email: me.email,
}); });
return NextResponse.json({ badges }); return NextResponse.json({ badges });
} catch (e: any) { } catch (e: any) {
const status = e?.status === 401 ? 401 : 500;
return NextResponse.json( return NextResponse.json(
{ error: "badges_fetch_failed", detail: String(e?.message || e) }, { error: status === 401 ? "Not authenticated" : "badges_fetch_failed" },
{ status: 500 } { status }
); );
} }
} }

View file

@ -2,6 +2,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto"; import crypto from "crypto";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { requireBearer } from "@/app/api/_lib/auth";
import { dxGET } from "@/lib/directus";
import { rateLimit } from "@/lib/rate-limit";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
@ -17,10 +20,11 @@ const SMTP_PASS = process.env.SMTP_PASS || "";
const SMTP_SECURE = process.env.SMTP_SECURE === "true"; const SMTP_SECURE = process.env.SMTP_SECURE === "true";
const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support <no-reply@makearmy.io>"; const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support <no-reply@makearmy.io>";
// TODO: replace with your actual auth/session resolver
async function getCurrentUserId(req: NextRequest): Promise<string | null> { async function getCurrentUserId(req: NextRequest): Promise<string | null> {
const uid = req.headers.get("x-user-id"); const bearer = requireBearer(req);
return uid && uid.trim() ? uid : null; const me = await dxGET<any>("/users/me?fields=id", bearer);
const uid = me?.data?.id ?? me?.id;
return uid ? String(uid) : null;
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@ -32,10 +36,13 @@ export async function POST(req: NextRequest) {
); );
} }
const userId = await getCurrentUserId(req); const userId = await getCurrentUserId(req).catch(() => null);
if (!userId) { if (!userId) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
} }
if (!rateLimit(`kofi-claim:${userId}`, 5, 15 * 60_000)) {
return NextResponse.json({ ok: false, error: "rate_limited" }, { status: 429 });
}
let email = ""; let email = "";
try { try {

View file

@ -1,18 +1,21 @@
// app/api/support/kofi/unlink/route.ts // app/api/support/kofi/unlink/route.ts
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
import { dxGET } from "@/lib/directus";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships"; const COLLECTION = "user_memberships";
// Replace with your real auth/session
async function getCurrentUserId(req: NextRequest): Promise<string | null> { async function getCurrentUserId(req: NextRequest): Promise<string | null> {
const uid = req.headers.get("x-user-id"); const bearer = requireBearer(req);
return uid && uid.trim() ? uid : null; const me = await dxGET<any>("/users/me?fields=id", bearer);
const uid = me?.data?.id ?? me?.id;
return uid ? String(uid) : null;
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const userId = await getCurrentUserId(req); const userId = await getCurrentUserId(req).catch(() => null);
if (!userId) { if (!userId) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 }); return NextResponse.json({ error: "unauthorized" }, { status: 401 });
} }

View file

@ -3,23 +3,15 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
const VERIFY = process.env.KOFI_VERIFY_TOKEN || ""; const VERIFY = process.env.KOFI_VERIFY_TOKEN || "";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || ""; const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || "";
const COLLECTION = "user_memberships"; const COLLECTION = "user_memberships";
// TEMP healthcheck: prove code + envs are live (remove after testing)
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({ ok: true });
ok: true,
env: {
hasVerifyToken: Boolean(VERIFY),
hasDirectusUrl: Boolean(DIRECTUS),
hasBotToken: Boolean(BOT_TOKEN),
collection: COLLECTION,
},
});
} }
type KofiPayload = { type KofiPayload = {
@ -66,6 +58,17 @@ function safeStringify(v: unknown) {
} }
} }
function secretsMatch(received: string, expected: string) {
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function redactedPayload(data: KofiPayload) {
const { verification_token: _secret, ...safe } = data;
return safe;
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const ct = req.headers.get("content-type") || ""; const ct = req.headers.get("content-type") || "";
log({ step: "recv", ct }); log({ step: "recv", ct });
@ -93,8 +96,8 @@ export async function POST(req: NextRequest) {
return json({ ok: false, error: "invalid_payload" }, 400); return json({ ok: false, error: "invalid_payload" }, 400);
} }
if (!data?.verification_token || data.verification_token !== VERIFY) { if (!data?.verification_token || !secretsMatch(data.verification_token, VERIFY)) {
log({ step: "verify-fail", got: data?.verification_token, expectSet: Boolean(VERIFY) }); log({ step: "verify-fail", tokenProvided: Boolean(data?.verification_token) });
return json({ ok: false, error: "unauthorized" }, 401); return json({ ok: false, error: "unauthorized" }, 401);
} }
log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment }); log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment });
@ -154,7 +157,7 @@ export async function POST(req: NextRequest) {
started_at, started_at,
renews_at, renews_at,
last_event_at: new Date().toISOString(), last_event_at: new Date().toISOString(),
raw: safeStringify(data), raw: safeStringify(redactedPayload(data)),
}; };
const url = existing?.id const url = existing?.id

View file

@ -22,7 +22,8 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props)
try { try {
const body = { const body = {
identifier: identifier.trim(), identifier: identifier.trim(),
password, password,
reauth,
}; };
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
@ -45,13 +46,6 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props)
throw new Error(msg); throw new Error(msg);
} }
// If this sign-in is being used as a re-auth step, set a short-lived 'recent auth' marker.
if (reauth && typeof document !== "undefined") {
document.cookie = `ma_ra=1; Max-Age=300; Path=/; SameSite=Lax${
process.env.NODE_ENV === "production" ? "; Secure" : ""
}`;
}
// Land where caller requested // Land where caller requested
router.replace(nextPath); router.replace(nextPath);
router.refresh(); router.refresh();

View file

@ -3,6 +3,7 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import HighlightText from '@/components/common/HighlightText';
type LaserRow = { type LaserRow = {
id: string | number; id: string | number;
@ -44,13 +45,7 @@ export default function LaserSourcesPage() {
}, [API]); }, [API]);
const highlightMatch = (text?: string, q?: string) => { const highlightMatch = (text?: string, q?: string) => {
const safeText = String(text ?? ''); return <HighlightText text={text} query={String(q ?? '')} />;
const query = String(q ?? '');
if (!query) return safeText;
const parts = safeText.split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
}; };
const opText = (row: LaserRow) => { const opText = (row: LaserRow) => {

View file

@ -2,15 +2,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import HighlightText from '@/components/common/HighlightText';
function highlightMatch(text?: string, query?: string) { function highlightMatch(text?: string, query?: string) {
const safeText = String(text ?? ''); return <HighlightText text={text} query={String(query ?? '')} />;
const q = String(query ?? '');
if (!q) return safeText;
const parts = safeText.split(new RegExp(`(${q})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === q.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
} }
export default function CoatingsPage() { export default function CoatingsPage() {

View file

@ -2,13 +2,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import HighlightText from '@/components/common/HighlightText';
function highlightMatch(text: string, query: string) { function highlightMatch(text: string, query: string) {
if (!query) return text; return <HighlightText text={text} query={query} />;
const parts = String(text).split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
} }
export default function MaterialsPage() { export default function MaterialsPage() {

View file

@ -98,7 +98,7 @@ export default function ProjectDetailPage() {
<a <a
key={i} key={i}
className="file-pill" className="file-pill"
href={`${process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net"}/assets/${fname}`} href={`${process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net"}/assets/${fname}?download`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={fname} title={fname}

View file

@ -4,6 +4,7 @@ import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import HighlightText from "@/components/common/HighlightText";
type ProjectRow = { type ProjectRow = {
id?: string | number; id?: string | number;
@ -58,12 +59,6 @@ export default function ProjectsPage() {
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, []); }, []);
const highlight = (text: string) => {
if (!debouncedQuery) return text;
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, "<mark>$1</mark>");
};
const normalize = (str: unknown) => const normalize = (str: unknown) =>
String(str ?? "").toLowerCase().replace(/[_\s]/g, ""); String(str ?? "").toLowerCase().replace(/[_\s]/g, "");
@ -313,27 +308,15 @@ export default function ProjectsPage() {
<div className="project-content"> <div className="project-content">
<div> <div>
<Link href={href} className="text-base font-semibold text-accent underline"> <Link href={href} className="text-base font-semibold text-accent underline">
<span <HighlightText text={project.title || "Untitled"} query={debouncedQuery} />
dangerouslySetInnerHTML={{
__html: highlight(project.title || "Untitled"),
}}
/>
</Link> </Link>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Uploaded by:{" "} Uploaded by:{" "}
<span <HighlightText text={project.uploader || "—"} query={debouncedQuery} />
dangerouslySetInnerHTML={{
__html: highlight(project.uploader || "—"),
}}
/>
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Category:{" "} Category:{" "}
<span <HighlightText text={project.category || "—"} query={debouncedQuery} />
dangerouslySetInnerHTML={{
__html: highlight(project.category || "—"),
}}
/>
</p> </p>
</div> </div>
<div className="project-tags"> <div className="project-tags">

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import HighlightText from "@/components/common/HighlightText";
type Owner = { type Owner = {
username?: string | null; username?: string | null;
@ -67,12 +68,6 @@ export default function CO2GantrySettingsPage() {
const ownerLabel = (o?: Owner) => (o?.username ?? "—"); const ownerLabel = (o?: Owner) => (o?.username ?? "—");
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase(); const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => { return settings.filter((entry) => {
@ -260,47 +255,14 @@ export default function CO2GantrySettingsPage() {
<Link <Link
href={detailHref(s.submission_id)} href={detailHref(s.submission_id)}
className="text-accent underline" className="text-accent underline"
dangerouslySetInnerHTML={{ ><HighlightText text={s.setting_title || "—"} query={debouncedQuery} /></Link>
__html: highlight(s.setting_title || "—"),
}}
/>
</td> </td>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={ownerLabel(s.owner)} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap" <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.uploader || "—"} query={debouncedQuery} /></td>
dangerouslySetInnerHTML={{ <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.mat?.name || "—"} query={debouncedQuery} /></td>
__html: highlight(ownerLabel(s.owner)), <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.mat_coat?.name || "—"} query={debouncedQuery} /></td>
}} <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.source?.model || "—"} query={debouncedQuery} /></td>
/> <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.lens?.name || "—"} query={debouncedQuery} /></td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.uploader || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat_coat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.source?.model || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.lens?.name || "—"),
}}
/>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import HighlightText from "@/components/common/HighlightText";
export default function FiberSettingsPage() { export default function FiberSettingsPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -60,12 +61,6 @@ export default function FiberSettingsPage() {
}; };
// ── Highlight helper for search matches ───────────────────────────────────── // ── Highlight helper for search matches ─────────────────────────────────────
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase(); const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => { return settings.filter((entry) => {
@ -259,51 +254,20 @@ export default function FiberSettingsPage() {
<Link <Link
href={detailHref(setting.submission_id)} href={detailHref(setting.submission_id)}
className="text-accent underline" className="text-accent underline"
dangerouslySetInnerHTML={{ ><HighlightText text={setting.setting_title || "—"} query={debouncedQuery} /></Link>
__html: highlight(setting.setting_title || "—"),
}}
/>
</td> </td>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={ownerName(setting)} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(ownerName(setting)) }}
/>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.uploader || "—"} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.uploader || "—"),
}}
/>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.mat?.name || "—"} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.mat?.name || "—"),
}}
/>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.mat_coat?.name || "—"} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.mat_coat?.name || "—"),
}}
/>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.source?.model || "—"} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.source?.model || "—"),
}}
/>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.lens?.field_size || "—"} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.lens?.field_size || "—"),
}}
/>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -4,6 +4,7 @@ import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import HighlightText from "@/components/common/HighlightText";
type Owner = { type Owner = {
id?: string | number; id?: string | number;
@ -62,12 +63,6 @@ export default function UVSettingsPage() {
const ownerLabel = (o?: Owner) => (o?.username ?? "—"); const ownerLabel = (o?: Owner) => (o?.username ?? "—");
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase(); const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => { return settings.filter((entry) => {
@ -226,47 +221,14 @@ export default function UVSettingsPage() {
<Link <Link
href={detailHref(s.submission_id)} href={detailHref(s.submission_id)}
className="text-accent underline" className="text-accent underline"
dangerouslySetInnerHTML={{ ><HighlightText text={s.setting_title || "—"} query={debouncedQuery} /></Link>
__html: highlight(s.setting_title || "—"),
}}
/>
</td> </td>
<td <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={ownerLabel(s.owner)} query={debouncedQuery} /></td>
className="px-2 py-2 whitespace-nowrap" <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.uploader || "—"} query={debouncedQuery} /></td>
dangerouslySetInnerHTML={{ <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.mat?.name || "—"} query={debouncedQuery} /></td>
__html: highlight(ownerLabel(s.owner)), <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.mat_coat?.name || "—"} query={debouncedQuery} /></td>
}} <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.source?.model || "—"} query={debouncedQuery} /></td>
/> <td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.lens?.field_size || "—"} query={debouncedQuery} /></td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.uploader || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat_coat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.source?.model || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.lens?.field_size || "—"),
}}
/>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -28,9 +28,6 @@ export default function ConnectKofi({
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
// if your auth middleware expects anything, set it here; otherwise cookies suffice
"x-user-id": userId ?? "",
"x-user-email": email ?? "",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ email: value }), body: JSON.stringify({ email: value }),
@ -68,7 +65,6 @@ export default function ConnectKofi({
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-user-id": userId ?? "",
}, },
credentials: "include", credentials: "include",
}); });

View file

@ -32,14 +32,7 @@ export default function SupporterBadges({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let url = "/api/support/badges"; fetch("/api/support/badges", { cache: "no-store", credentials: "include" })
const params = new URLSearchParams();
if (email) params.set("email", String(email));
if (userId) params.set("userId", String(userId));
const qs = params.toString();
if (qs) url += `?${qs}`;
fetch(url, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(r))) .then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : [])) .then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : []))
.catch(async (e) => { .catch(async (e) => {

View file

@ -0,0 +1,23 @@
import { Fragment, type ReactNode } from "react";
export default function HighlightText({ text, query }: { text: unknown; query: string }): ReactNode {
const value = String(text ?? "");
if (!query) return value;
const haystack = value.toLocaleLowerCase();
const needle = query.toLocaleLowerCase();
if (!needle) return value;
const parts: ReactNode[] = [];
let start = 0;
let match = haystack.indexOf(needle, start);
while (match !== -1) {
if (match > start) parts.push(value.slice(start, match));
parts.push(<mark key={`${match}-${parts.length}`}>{value.slice(match, match + query.length)}</mark>);
start = match + query.length;
match = haystack.indexOf(needle, start);
}
if (start < value.length) parts.push(value.slice(start));
return <Fragment>{parts.length ? parts : value}</Fragment>;
}

View file

@ -11,7 +11,7 @@ const PROJECTS_COLLECTION = process.env.DIRECTUS_PROJECTS_COLLECTION || "project
if (!BASE) console.warn("[directus] Missing DIRECTUS_URL"); if (!BASE) console.warn("[directus] Missing DIRECTUS_URL");
if (!TOKEN_ADMIN_REGISTER) 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 and username login)");
export function bytesFromMB(mb: number) { export function bytesFromMB(mb: number) {
return Math.round(mb * 1024 * 1024); return Math.round(mb * 1024 * 1024);

34
app/lib/rate-limit.ts Normal file
View file

@ -0,0 +1,34 @@
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
const MAX_BUCKETS = 10_000;
export function requestIp(req: Request): string {
// The deployment binds Next.js to localhost behind a reverse proxy. The
// proxy must replace client-supplied forwarding headers for this to be safe.
return (
req.headers.get("x-real-ip") ||
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown"
);
}
export function rateLimit(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
const current = buckets.get(key);
if (!current || current.resetAt <= now) {
if (buckets.size >= MAX_BUCKETS) {
for (const [bucketKey, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(bucketKey);
}
if (buckets.size >= MAX_BUCKETS) buckets.delete(buckets.keys().next().value as string);
}
buckets.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (current.count >= max) return false;
current.count += 1;
return true;
}

View file

@ -0,0 +1,28 @@
import path from "path";
const EXECUTABLE_EXTENSIONS = new Set([
".bat", ".cmd", ".com", ".dll", ".exe", ".hta", ".htm", ".html",
".jar", ".js", ".jsp", ".mjs", ".cjs", ".msi", ".php", ".phtml",
".phar", ".ps1", ".sh", ".vbs", ".war", ".wsf",
]);
export function isExecutableAttachment(filename: string): boolean {
return EXECUTABLE_EXTENSIONS.has(path.extname(filename).toLowerCase());
}
export async function hasValidImageSignature(file: Blob): Promise<boolean> {
const bytes = new Uint8Array(await file.slice(0, 16).arrayBuffer());
if (bytes.length < 3) return false;
const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
const png = bytes.length >= 8 &&
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
const gifHeader = bytes.length >= 6 ? String.fromCharCode(...bytes.slice(0, 6)) : "";
const gif = gifHeader === "GIF87a" || gifHeader === "GIF89a";
const webp = bytes.length >= 12 &&
String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" &&
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP";
return jpeg || png || gif || webp;
}

View file

@ -16,8 +16,7 @@ import { NextResponse, NextRequest } from "next/server";
* Keep this list tiny; add broad /api/webhooks to allow ALL webhook endpoints. * Keep this list tiny; add broad /api/webhooks to allow ALL webhook endpoints.
*/ */
const PUBLIC_API_PREFIXES: string[] = [ const PUBLIC_API_PREFIXES: string[] = [
"/api/auth", // login/refresh/callback endpoints "/api/auth/", // login/refresh/callback endpoints
"/api/webhooks", // allow ALL webhook endpoints
]; ];
/** Directus base (used to remotely validate the token after restarts). */ /** Directus base (used to remotely validate the token after restarts). */
@ -86,8 +85,8 @@ import { NextResponse, NextRequest } from "next/server";
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
const { pathname } = url; const { pathname } = url;
// ── -1) Always allow ALL webhook endpoints // ── -1) Allow only the explicitly configured external webhook.
if (pathname === "/api/webhooks" || pathname.startsWith("/api/webhooks/")) { if (pathname === "/api/webhooks/kofi") {
return NextResponse.next(); return NextResponse.next();
} }