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 { 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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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: "/",
|
||||||
|
|
|
||||||
|
|
@ -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: "/",
|
||||||
|
|
|
||||||
|
|
@ -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 doesn’t 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 =
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: [] });
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
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 (!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
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.
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue