Harden authentication and writable endpoints
This commit is contained in:
parent
c034824338
commit
0a7ee5ff35
33 changed files with 308 additions and 284 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<any>("/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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <no-reply@makearmy.io>";
|
||||
|
||||
// TODO: replace with your actual auth/session resolver
|
||||
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
|
||||
const uid = req.headers.get("x-user-id");
|
||||
return uid && uid.trim() ? uid : null;
|
||||
const bearer = requireBearer(req);
|
||||
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) {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
const uid = req.headers.get("x-user-id");
|
||||
return uid && uid.trim() ? uid : null;
|
||||
const bearer = requireBearer(req);
|
||||
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) {
|
||||
const userId = await getCurrentUserId(req);
|
||||
const userId = await getCurrentUserId(req).catch(() => null);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props)
|
|||
const body = {
|
||||
identifier: identifier.trim(),
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
|
||||
);
|
||||
return <HighlightText text={text} query={String(q ?? '')} />;
|
||||
};
|
||||
|
||||
const opText = (row: LaserRow) => {
|
||||
|
|
|
|||
|
|
@ -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() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
|
||||
);
|
||||
return <HighlightText text={text} query={String(query ?? '')} />;
|
||||
}
|
||||
|
||||
export default function CoatingsPage() {
|
||||
|
|
|
|||
|
|
@ -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() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
|
||||
);
|
||||
return <HighlightText text={text} query={query} />;
|
||||
}
|
||||
|
||||
export default function MaterialsPage() {
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export default function ProjectDetailPage() {
|
|||
<a
|
||||
key={i}
|
||||
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"
|
||||
rel="noopener noreferrer"
|
||||
title={fname}
|
||||
|
|
|
|||
|
|
@ -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 ProjectRow = {
|
||||
id?: string | number;
|
||||
|
|
@ -58,12 +59,6 @@ export default function ProjectsPage() {
|
|||
.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) =>
|
||||
String(str ?? "").toLowerCase().replace(/[_\s]/g, "");
|
||||
|
||||
|
|
@ -313,27 +308,15 @@ export default function ProjectsPage() {
|
|||
<div className="project-content">
|
||||
<div>
|
||||
<Link href={href} className="text-base font-semibold text-accent underline">
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(project.title || "Untitled"),
|
||||
}}
|
||||
/>
|
||||
<HighlightText text={project.title || "Untitled"} query={debouncedQuery} />
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uploaded by:{" "}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(project.uploader || "—"),
|
||||
}}
|
||||
/>
|
||||
<HighlightText text={project.uploader || "—"} query={debouncedQuery} />
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Category:{" "}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(project.category || "—"),
|
||||
}}
|
||||
/>
|
||||
<HighlightText text={project.category || "—"} query={debouncedQuery} />
|
||||
</p>
|
||||
</div>
|
||||
<div className="project-tags">
|
||||
|
|
|
|||
|
|
@ -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, "<mark>$1</mark>");
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return settings.filter((entry) => {
|
||||
|
|
@ -260,47 +255,14 @@ export default function CO2GantrySettingsPage() {
|
|||
<Link
|
||||
href={detailHref(s.submission_id)}
|
||||
className="text-accent underline"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.setting_title || "—"),
|
||||
}}
|
||||
/>
|
||||
><HighlightText text={s.setting_title || "—"} query={debouncedQuery} /></Link>
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(ownerLabel(s.owner)),
|
||||
}}
|
||||
/>
|
||||
<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 || "—"),
|
||||
}}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={ownerLabel(s.owner)} query={debouncedQuery} /></td>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.uploader || "—"} query={debouncedQuery} /></td>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.mat?.name || "—"} query={debouncedQuery} /></td>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -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, "<mark>$1</mark>");
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return settings.filter((entry) => {
|
||||
|
|
@ -259,51 +254,20 @@ export default function FiberSettingsPage() {
|
|||
<Link
|
||||
href={detailHref(setting.submission_id)}
|
||||
className="text-accent underline"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.setting_title || "—"),
|
||||
}}
|
||||
/>
|
||||
><HighlightText text={setting.setting_title || "—"} query={debouncedQuery} /></Link>
|
||||
</td>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{ __html: highlight(ownerName(setting)) }}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={ownerName(setting)} query={debouncedQuery} /></td>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.uploader || "—"),
|
||||
}}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.uploader || "—"} query={debouncedQuery} /></td>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.mat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.mat?.name || "—"} query={debouncedQuery} /></td>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.mat_coat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.mat_coat?.name || "—"} query={debouncedQuery} /></td>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.source?.model || "—"),
|
||||
}}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.source?.model || "—"} query={debouncedQuery} /></td>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.lens?.field_size || "—"),
|
||||
}}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={setting.lens?.field_size || "—"} query={debouncedQuery} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -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, "<mark>$1</mark>");
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return settings.filter((entry) => {
|
||||
|
|
@ -226,47 +221,14 @@ export default function UVSettingsPage() {
|
|||
<Link
|
||||
href={detailHref(s.submission_id)}
|
||||
className="text-accent underline"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.setting_title || "—"),
|
||||
}}
|
||||
/>
|
||||
><HighlightText text={s.setting_title || "—"} query={debouncedQuery} /></Link>
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(ownerLabel(s.owner)),
|
||||
}}
|
||||
/>
|
||||
<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 || "—"),
|
||||
}}
|
||||
/>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={ownerLabel(s.owner)} query={debouncedQuery} /></td>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.uploader || "—"} query={debouncedQuery} /></td>
|
||||
<td className="px-2 py-2 whitespace-nowrap"><HighlightText text={s.mat?.name || "—"} query={debouncedQuery} /></td>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,14 +32,7 @@ export default function SupporterBadges({
|
|||
const [error, setError] = useState<string | null>(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) => {
|
||||
|
|
|
|||
23
app/components/common/HighlightText.tsx
Normal file
23
app/components/common/HighlightText.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
34
app/lib/rate-limit.ts
Normal file
34
app/lib/rate-limit.ts
Normal 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;
|
||||
}
|
||||
28
app/lib/upload-security.ts
Normal file
28
app/lib/upload-security.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue