diff --git a/app/app/api/account/avatar/route.ts b/app/app/api/account/avatar/route.ts index e0a625f..6313496 100644 --- a/app/app/api/account/avatar/route.ts +++ b/app/app/api/account/avatar/route.ts @@ -3,9 +3,12 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; import { requireBearer } from "@/app/api/_lib/auth"; +import { hasValidImageSignature } from "@/lib/upload-security"; const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); 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) { return NextResponse.json({ error: msg }, { status: code }); @@ -22,6 +25,11 @@ export async function POST(req: Request) { const form = await req.formData(); const file = form.get("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 const up = new FormData(); diff --git a/app/app/api/account/password/route.ts b/app/app/api/account/password/route.ts index e46dbfc..325c6c8 100644 --- a/app/app/api/account/password/route.ts +++ b/app/app/api/account/password/route.ts @@ -1,7 +1,7 @@ // app/api/account/password/route.ts import { NextResponse } from "next/server"; import { requireBearer } from "@/app/api/_lib/auth"; -import { loginDirectus } from "@/lib/directus"; +import { emailForUsername, loginDirectus } from "@/lib/directus"; export const runtime = "nodejs"; @@ -33,8 +33,8 @@ async function handle(req: Request) { const bearer = requireBearer(req); const body = await req.json().catch(() => ({} as any)); - const current = String(body?.current ?? body?.current_password ?? "").trim(); - const next = String(body?.next ?? body?.new_password ?? "").trim(); + const current = String(body?.current ?? body?.current_password ?? ""); + const next = String(body?.next ?? body?.new_password ?? ""); // NEW: allow client to provide identifier explicitly let identifier = String(body?.identifier ?? "").trim(); @@ -78,8 +78,12 @@ async function handle(req: Request) { }); } - // 4) Verify CURRENT password by logging in with identifier - const auth = await loginDirectus(identifier, current).catch(() => null); + // 4) Directus local auth accepts email only. Resolve a client-provided + // 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; if (!access) { return bad("Current password is incorrect", 401); diff --git a/app/app/api/account/profile/route.ts b/app/app/api/account/profile/route.ts index f6b4729..c5d3414 100644 --- a/app/app/api/account/profile/route.ts +++ b/app/app/api/account/profile/route.ts @@ -59,7 +59,7 @@ export async function PATCH(req: Request) { resp.cookies.set({ name: "ma_ra", value: "", - httpOnly: false, + httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", diff --git a/app/app/api/account/route.ts b/app/app/api/account/route.ts index 4a2efa3..38e7474 100644 --- a/app/app/api/account/route.ts +++ b/app/app/api/account/route.ts @@ -84,7 +84,7 @@ export async function PATCH(req: Request) { resp.cookies.set({ name: "ma_ra", value: "", - httpOnly: false, + httpOnly: true, sameSite: "lax", secure, path: "/", diff --git a/app/app/api/auth/login/route.ts b/app/app/api/auth/login/route.ts index 4f357ed..a1c4b2a 100644 --- a/app/app/api/auth/login/route.ts +++ b/app/app/api/auth/login/route.ts @@ -1,53 +1,58 @@ // app/api/auth/login/route.ts import { NextRequest, NextResponse } from "next/server"; import { emailForUsername, loginDirectus } from "@/lib/directus"; +import { rateLimit, requestIp } from "@/lib/rate-limit"; export const runtime = "nodejs"; const secure = process.env.NODE_ENV === "production"; export async function POST(req: NextRequest) { 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 identifier = 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) { return NextResponse.json({ error: "Missing credentials" }, { status: 400 }); } - // 1) Try Directus directly with the identifier (email OR username) - // Directus expects the field name "email" for both. - const tryIds: string[] = [identifier]; - - // 2) Fallback: if it doesn’t look like an email, try the canonical email (if any) + // Directus' local auth endpoint only accepts a valid email in its + // `email` field. Resolve our custom username field before calling it. + // Never send a username as `email`: Directus rejects that payload with + // INVALID_PAYLOAD before it even checks the password. + let email = identifier.toLowerCase(); if (!identifier.includes("@")) { try { - const em = await emailForUsername(identifier); // returns string|null - if (em && em !== identifier) tryIds.push(em); - } catch { - // ignore lookup errors, we'll just rely on the first attempt + email = (await emailForUsername(identifier))?.trim().toLowerCase() ?? ""; + } catch (error) { + console.error("[auth/login] Unable to resolve username", error); + 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 lastErr: any = null; - for (const id of tryIds) { - try { - tokens = await loginDirectus(id, password); // { access_token, refresh_token, expires? } - if (tokens) break; - } catch (e) { - lastErr = e; - } + try { + tokens = await loginDirectus(email, password); + } catch { + // Do not expose Directus payload details or whether an account exists. + return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 }); } if (!tokens?.access_token) { - const msg = - lastErr?.response?.data?.errors?.[0]?.message || - lastErr?.response?.data?.error || - lastErr?.message || - "Invalid credentials."; -return NextResponse.json({ error: msg }, { status: 401 }); + return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 }); } // Set HttpOnly cookies for your middleware @@ -69,6 +74,15 @@ return NextResponse.json({ error: msg }, { status: 401 }); 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; } catch (err: any) { const message = diff --git a/app/app/api/auth/reconfirm/route.ts b/app/app/api/auth/reconfirm/route.ts index 363f72e..87a4f4b 100644 --- a/app/app/api/auth/reconfirm/route.ts +++ b/app/app/api/auth/reconfirm/route.ts @@ -9,15 +9,19 @@ export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => ({} as any)); const identifier = String(body?.identifier ?? "").trim(); - const password = String(body?.password ?? "").trim(); + const password = String(body?.password ?? ""); if (!identifier || !password) { return NextResponse.json({ error: "Missing credentials" }, { status: 400 }); } // Resolve identifier -> email (username or email accepted) - const email = identifier.includes("@") ? identifier : await emailForUsername(identifier); - if (!email) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const email = identifier.includes("@") + ? 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 access = auth?.access_token ?? auth?.data?.access_token; @@ -46,7 +50,7 @@ export async function POST(req: NextRequest) { res.cookies.set({ name: "ma_ra", value: "1", - httpOnly: false, + httpOnly: true, sameSite: "lax", secure, path: "/", @@ -55,12 +59,7 @@ export async function POST(req: NextRequest) { return res; } catch (err: any) { - const msg = - err?.response?.data?.errors?.[0]?.message || - err?.response?.data?.error || - err?.message || - "Re-auth failed"; - const status = /invalid|credential/i.test(msg) ? 401 : 400; - return NextResponse.json({ error: msg }, { status }); + console.error("[auth/reconfirm] Re-authentication failed", err); + return NextResponse.json({ error: "Invalid email, username, or password." }, { status: 401 }); } } diff --git a/app/app/api/auth/register/route.ts b/app/app/api/auth/register/route.ts index b15034c..e9efabe 100644 --- a/app/app/api/auth/register/route.ts +++ b/app/app/api/auth/register/route.ts @@ -1,5 +1,6 @@ // app/api/auth/register/route.ts 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 SERVICE_TOKEN = @@ -28,14 +29,17 @@ async function directusLogin(email: string, password: string) { export async function POST(req: Request) { 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 (!SERVICE_TOKEN) return bad("Missing DIRECTUS_SERVICE_TOKEN / admin token", 500); const body = await req.json().catch(() => ({} as any)); const email = String(body?.email ?? "").trim().toLowerCase(); const username = String(body?.username ?? "").trim(); - const password = String(body?.password ?? "").trim(); - const confirm = String(body?.confirmPassword ?? body?.confirm ?? "").trim(); + const password = String(body?.password ?? ""); + const confirm = String(body?.confirmPassword ?? body?.confirm ?? ""); if (!email || !username || !password || !confirm) return bad("All fields are required"); if (!EMAIL_RE.test(email)) return bad("Enter a valid email address"); diff --git a/app/app/api/bgbye/process/route.ts b/app/app/api/bgbye/process/route.ts index eb3add0..7a91181 100644 --- a/app/app/api/bgbye/process/route.ts +++ b/app/app/api/bgbye/process/route.ts @@ -1,6 +1,10 @@ export const runtime = "nodejs"; 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. @@ -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 { 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 const ures = await fetch(upstream, { diff --git a/app/app/api/files/download/route.ts b/app/app/api/files/download/route.ts index f0a7a42..18c290a 100644 --- a/app/app/api/files/download/route.ts +++ b/app/app/api/files/download/route.ts @@ -16,13 +16,20 @@ function safeJoin(root: string, reqPath: string) { 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) { try { const { searchParams } = new URL(req.url); const reqPath = searchParams.get("path") || ""; 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); if (!stat || !stat.isFile()) return new NextResponse("Not found", { status: 404 }); diff --git a/app/app/api/files/list/route.ts b/app/app/api/files/list/route.ts index 4cb1771..54918db 100644 --- a/app/app/api/files/list/route.ts +++ b/app/app/api/files/list/route.ts @@ -14,11 +14,18 @@ function safeJoin(root: string, reqPath: string) { 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) { try { const { searchParams } = new URL(req.url); 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); if (!stat) return NextResponse.json({ path: reqPath, items: [] }); diff --git a/app/app/api/files/raw/route.ts b/app/app/api/files/raw/route.ts index 10ba7f5..9f26f58 100644 --- a/app/app/api/files/raw/route.ts +++ b/app/app/api/files/raw/route.ts @@ -55,6 +55,13 @@ function safeJoin(root: string, reqPath: string) { 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) { 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); if (!stat || !stat.isFile()) { diff --git a/app/app/api/project/route.ts b/app/app/api/project/route.ts index c70aa02..9b9f7f9 100644 --- a/app/app/api/project/route.ts +++ b/app/app/api/project/route.ts @@ -8,6 +8,7 @@ import { dxGET, } from "@/lib/directus"; import { requireBearer } from "@/app/api/_lib/auth"; +import { hasValidImageSignature, isExecutableAttachment } from "@/lib/upload-security"; // Optional: tweak via env 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) { 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); p_image_id = up.id; } @@ -102,6 +106,9 @@ 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 }); } + 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); attachIds.push(up.id); } diff --git a/app/app/api/settings/route.ts b/app/app/api/settings/route.ts index 469c37f..d41a322 100644 --- a/app/app/api/settings/route.ts +++ b/app/app/api/settings/route.ts @@ -1,5 +1,6 @@ // app/api/settings/route.ts import { NextResponse } from "next/server"; +import { hasValidImageSignature } from "@/lib/upload-security"; /** * 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 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) const FOLDERS = { @@ -47,6 +50,11 @@ function bearerFrom(req: Request) { } 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(); form.set("file", file, file.name || "upload"); if (folderId) form.set("folder", folderId); diff --git a/app/app/api/support/badges/route.ts b/app/app/api/support/badges/route.ts index f47c16f..38499b8 100644 --- a/app/app/api/support/badges/route.ts +++ b/app/app/api/support/badges/route.ts @@ -1,36 +1,35 @@ // /app/api/support/badges/route.ts import { NextRequest, NextResponse } from "next/server"; 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> { - const uid = req.headers.get("x-user-id") || undefined; - const email = req.headers.get("x-user-email") || undefined; - return uid || email ? { id: uid, email } : null; + const bearer = requireBearer(req); + const response = await dxGET("/users/me?fields=id,email", bearer); + 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) { try { const me = await getCurrentUser(req); - // Accept explicit query params so the component can work without special headers - const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined; - 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 }); + if (!me?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } const badges = await fetchMembershipBadges({ - userId: userIdParam || me?.id, - email: emailParam || me?.email, + userId: me.id, + email: me.email, }); return NextResponse.json({ badges }); } catch (e: any) { + const status = e?.status === 401 ? 401 : 500; return NextResponse.json( - { error: "badges_fetch_failed", detail: String(e?.message || e) }, - { status: 500 } + { error: status === 401 ? "Not authenticated" : "badges_fetch_failed" }, + { status } ); } } diff --git a/app/app/api/support/kofi/claim/start/route.ts b/app/app/api/support/kofi/claim/start/route.ts index 91cc777..8523520 100644 --- a/app/app/api/support/kofi/claim/start/route.ts +++ b/app/app/api/support/kofi/claim/start/route.ts @@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; 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 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 EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support "; -// TODO: replace with your actual auth/session resolver async function getCurrentUserId(req: NextRequest): Promise { - const uid = req.headers.get("x-user-id"); - return uid && uid.trim() ? uid : null; + const bearer = requireBearer(req); + const me = await dxGET("/users/me?fields=id", bearer); + const uid = me?.data?.id ?? me?.id; + return uid ? String(uid) : null; } 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) { 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 = ""; try { diff --git a/app/app/api/support/kofi/unlink/route.ts b/app/app/api/support/kofi/unlink/route.ts index 7625b98..f1f1da7 100644 --- a/app/app/api/support/kofi/unlink/route.ts +++ b/app/app/api/support/kofi/unlink/route.ts @@ -1,18 +1,21 @@ // app/api/support/kofi/unlink/route.ts 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 BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; const COLLECTION = "user_memberships"; -// Replace with your real auth/session async function getCurrentUserId(req: NextRequest): Promise { - const uid = req.headers.get("x-user-id"); - return uid && uid.trim() ? uid : null; + const bearer = requireBearer(req); + const me = await dxGET("/users/me?fields=id", bearer); + const uid = me?.data?.id ?? me?.id; + return uid ? String(uid) : null; } export async function POST(req: NextRequest) { - const userId = await getCurrentUserId(req); + const userId = await getCurrentUserId(req).catch(() => null); if (!userId) { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } diff --git a/app/app/api/webhooks/kofi/route.ts b/app/app/api/webhooks/kofi/route.ts index d95a156..e494932 100644 --- a/app/app/api/webhooks/kofi/route.ts +++ b/app/app/api/webhooks/kofi/route.ts @@ -3,23 +3,15 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; const VERIFY = process.env.KOFI_VERIFY_TOKEN || ""; const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || ""; const COLLECTION = "user_memberships"; -// TEMP healthcheck: prove code + envs are live (remove after testing) export async function GET() { - return NextResponse.json({ - ok: true, - env: { - hasVerifyToken: Boolean(VERIFY), - hasDirectusUrl: Boolean(DIRECTUS), - hasBotToken: Boolean(BOT_TOKEN), - collection: COLLECTION, - }, - }); + return NextResponse.json({ ok: true }); } 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) { const ct = req.headers.get("content-type") || ""; log({ step: "recv", ct }); @@ -93,8 +96,8 @@ export async function POST(req: NextRequest) { return json({ ok: false, error: "invalid_payload" }, 400); } - if (!data?.verification_token || data.verification_token !== VERIFY) { - log({ step: "verify-fail", got: data?.verification_token, expectSet: Boolean(VERIFY) }); + if (!data?.verification_token || !secretsMatch(data.verification_token, VERIFY)) { + log({ step: "verify-fail", tokenProvided: Boolean(data?.verification_token) }); return json({ ok: false, error: "unauthorized" }, 401); } log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment }); @@ -154,7 +157,7 @@ export async function POST(req: NextRequest) { started_at, renews_at, last_event_at: new Date().toISOString(), - raw: safeStringify(data), + raw: safeStringify(redactedPayload(data)), }; const url = existing?.id diff --git a/app/app/auth/sign-in/sign-in.tsx b/app/app/auth/sign-in/sign-in.tsx index ce4c686..48653d1 100644 --- a/app/app/auth/sign-in/sign-in.tsx +++ b/app/app/auth/sign-in/sign-in.tsx @@ -22,7 +22,8 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props) try { const body = { identifier: identifier.trim(), - password, + password, + reauth, }; const res = await fetch("/api/auth/login", { @@ -45,13 +46,6 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props) 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 router.replace(nextPath); router.refresh(); diff --git a/app/app/lasers/page.tsx b/app/app/lasers/page.tsx index fba889d..d22b52b 100644 --- a/app/app/lasers/page.tsx +++ b/app/app/lasers/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useMemo } from 'react'; import Link from 'next/link'; +import HighlightText from '@/components/common/HighlightText'; type LaserRow = { id: string | number; @@ -44,13 +45,7 @@ export default function LaserSourcesPage() { }, [API]); const highlightMatch = (text?: string, q?: string) => { - const safeText = String(text ?? ''); - 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() ? {part} : {part} - ); + return ; }; const opText = (row: LaserRow) => { diff --git a/app/app/materials/materials-coatings/page.tsx b/app/app/materials/materials-coatings/page.tsx index e14ad93..529096a 100644 --- a/app/app/materials/materials-coatings/page.tsx +++ b/app/app/materials/materials-coatings/page.tsx @@ -2,15 +2,10 @@ import Link from 'next/link'; import { useEffect, useState, useMemo } from 'react'; +import HighlightText from '@/components/common/HighlightText'; function highlightMatch(text?: string, query?: string) { - const safeText = String(text ?? ''); - 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() ? {part} : {part} - ); + return ; } export default function CoatingsPage() { diff --git a/app/app/materials/materials/page.tsx b/app/app/materials/materials/page.tsx index c2b7d67..c0eeb20 100644 --- a/app/app/materials/materials/page.tsx +++ b/app/app/materials/materials/page.tsx @@ -2,13 +2,10 @@ import Link from 'next/link'; import { useEffect, useState, useMemo } from 'react'; +import HighlightText from '@/components/common/HighlightText'; function highlightMatch(text: string, query: string) { - if (!query) return text; - const parts = String(text).split(new RegExp(`(${query})`, 'gi')); - return parts.map((part, i) => - part.toLowerCase() === query.toLowerCase() ? {part} : {part} - ); + return ; } export default function MaterialsPage() { diff --git a/app/app/projects/[id]/projects.tsx b/app/app/projects/[id]/projects.tsx index fd85441..dab3822 100644 --- a/app/app/projects/[id]/projects.tsx +++ b/app/app/projects/[id]/projects.tsx @@ -98,7 +98,7 @@ export default function ProjectDetailPage() { setLoading(false)); }, []); - const highlight = (text: string) => { - if (!debouncedQuery) return text; - const regex = new RegExp(`(${debouncedQuery})`, "gi"); - return text?.replace(regex, "$1"); - }; - const normalize = (str: unknown) => String(str ?? "").toLowerCase().replace(/[_\s]/g, ""); @@ -313,27 +308,15 @@ export default function ProjectsPage() {
- +

Uploaded by:{" "} - +

Category:{" "} - +

diff --git a/app/app/settings/co2-gantry/page.tsx b/app/app/settings/co2-gantry/page.tsx index c638119..73d0aef 100644 --- a/app/app/settings/co2-gantry/page.tsx +++ b/app/app/settings/co2-gantry/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; +import HighlightText from "@/components/common/HighlightText"; type Owner = { username?: string | null; @@ -67,12 +68,6 @@ export default function CO2GantrySettingsPage() { const ownerLabel = (o?: Owner) => (o?.username ?? "—"); - const highlight = (text?: string) => { - if (!debouncedQuery) return text || ""; - const regex = new RegExp(`(${debouncedQuery})`, "gi"); - return (text || "").replace(regex, "$1"); - }; - const filtered = useMemo(() => { const q = debouncedQuery.toLowerCase(); return settings.filter((entry) => { @@ -260,47 +255,14 @@ export default function CO2GantrySettingsPage() { + > - - - - - - + + + + + + ))} diff --git a/app/app/settings/fiber/page.tsx b/app/app/settings/fiber/page.tsx index 564ede2..a81c566 100644 --- a/app/app/settings/fiber/page.tsx +++ b/app/app/settings/fiber/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; +import HighlightText from "@/components/common/HighlightText"; export default function FiberSettingsPage() { const searchParams = useSearchParams(); @@ -60,12 +61,6 @@ export default function FiberSettingsPage() { }; // ── Highlight helper for search matches ───────────────────────────────────── - const highlight = (text?: string) => { - if (!debouncedQuery) return text || ""; - const regex = new RegExp(`(${debouncedQuery})`, "gi"); - return (text || "").replace(regex, "$1"); - }; - const filtered = useMemo(() => { const q = debouncedQuery.toLowerCase(); return settings.filter((entry) => { @@ -259,51 +254,20 @@ export default function FiberSettingsPage() { + > - + - + - + - + - + - + ))} diff --git a/app/app/settings/uv/page.tsx b/app/app/settings/uv/page.tsx index bbe39f2..f78463c 100644 --- a/app/app/settings/uv/page.tsx +++ b/app/app/settings/uv/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; +import HighlightText from "@/components/common/HighlightText"; type Owner = { id?: string | number; @@ -62,12 +63,6 @@ export default function UVSettingsPage() { const ownerLabel = (o?: Owner) => (o?.username ?? "—"); - const highlight = (text?: string) => { - if (!debouncedQuery) return text || ""; - const regex = new RegExp(`(${debouncedQuery})`, "gi"); - return (text || "").replace(regex, "$1"); - }; - const filtered = useMemo(() => { const q = debouncedQuery.toLowerCase(); return settings.filter((entry) => { @@ -226,47 +221,14 @@ export default function UVSettingsPage() { + > - - - - - - + + + + + + ))} diff --git a/app/components/account/ConnectKofi.tsx b/app/components/account/ConnectKofi.tsx index d2d4562..1560cf3 100644 --- a/app/components/account/ConnectKofi.tsx +++ b/app/components/account/ConnectKofi.tsx @@ -28,9 +28,6 @@ export default function ConnectKofi({ method: "POST", headers: { "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", body: JSON.stringify({ email: value }), @@ -68,7 +65,6 @@ export default function ConnectKofi({ method: "POST", headers: { "Content-Type": "application/json", - "x-user-id": userId ?? "", }, credentials: "include", }); diff --git a/app/components/account/SupporterBadges.tsx b/app/components/account/SupporterBadges.tsx index b10923e..528019f 100644 --- a/app/components/account/SupporterBadges.tsx +++ b/app/components/account/SupporterBadges.tsx @@ -32,14 +32,7 @@ export default function SupporterBadges({ const [error, setError] = useState(null); useEffect(() => { - let url = "/api/support/badges"; - 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" }) + fetch("/api/support/badges", { cache: "no-store", credentials: "include" }) .then((r) => (r.ok ? r.json() : Promise.reject(r))) .then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : [])) .catch(async (e) => { diff --git a/app/components/common/HighlightText.tsx b/app/components/common/HighlightText.tsx new file mode 100644 index 0000000..f64ea75 --- /dev/null +++ b/app/components/common/HighlightText.tsx @@ -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({value.slice(match, match + query.length)}); + start = match + query.length; + match = haystack.indexOf(needle, start); + } + if (start < value.length) parts.push(value.slice(start)); + + return {parts.length ? parts : value}; +} diff --git a/app/lib/directus.ts b/app/lib/directus.ts index 27d73ee..f9d705f 100644 --- a/app/lib/directus.ts +++ b/app/lib/directus.ts @@ -11,7 +11,7 @@ const PROJECTS_COLLECTION = process.env.DIRECTUS_PROJECTS_COLLECTION || "project if (!BASE) console.warn("[directus] Missing DIRECTUS_URL"); 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) { return Math.round(mb * 1024 * 1024); diff --git a/app/lib/rate-limit.ts b/app/lib/rate-limit.ts new file mode 100644 index 0000000..8568060 --- /dev/null +++ b/app/lib/rate-limit.ts @@ -0,0 +1,34 @@ +type Bucket = { count: number; resetAt: number }; + +const buckets = new Map(); +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; +} diff --git a/app/lib/upload-security.ts b/app/lib/upload-security.ts new file mode 100644 index 0000000..26ef92e --- /dev/null +++ b/app/lib/upload-security.ts @@ -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 { + 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; +} diff --git a/app/middleware.ts b/app/middleware.ts index 2df0e3b..2b5bf99 100644 --- a/app/middleware.ts +++ b/app/middleware.ts @@ -16,8 +16,7 @@ import { NextResponse, NextRequest } from "next/server"; * Keep this list tiny; add broad /api/webhooks to allow ALL webhook endpoints. */ const PUBLIC_API_PREFIXES: string[] = [ - "/api/auth", // login/refresh/callback endpoints -"/api/webhooks", // allow ALL webhook endpoints + "/api/auth/", // login/refresh/callback endpoints ]; /** 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 { pathname } = url; - // ── -1) Always allow ALL webhook endpoints - if (pathname === "/api/webhooks" || pathname.startsWith("/api/webhooks/")) { + // ── -1) Allow only the explicitly configured external webhook. + if (pathname === "/api/webhooks/kofi") { return NextResponse.next(); }