Add temporary public utility-only site

This commit is contained in:
makearmy 2026-07-10 12:18:27 -04:00
parent 4884b5d367
commit 2a9f4df15f
6 changed files with 193 additions and 259 deletions

View file

@ -1,7 +1,6 @@
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;
@ -21,9 +20,6 @@ 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 });
}

View file

@ -1,49 +1,15 @@
// app/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import SignIn from "@/app/auth/sign-in/sign-in";
import SignUp from "@/app/auth/sign-up/sign-up";
import { isJwtValid } from "@/lib/jwt";
import { Suspense } from "react";
import TemporaryUtilityHub from "@/components/TemporaryUtilityHub";
type SearchParams = { [key: string]: string | string[] | undefined };
export default async function HomePage({
searchParams,
}: {
searchParams?: SearchParams;
}) {
// If already signed in with a VALID token, go straight to the app
const ck = await cookies();
const at = ck.get("ma_at")?.value;
if (isJwtValid(at)) redirect("/portal");
const reauth = searchParams?.reauth === "1";
export const metadata = {
title: "Laser Everything Community Utilities",
description: "Free laser calculators, public files, and image background removal tools.",
};
export default function HomePage() {
return (
<main className="mx-auto max-w-5xl px-4 py-12">
{reauth && (
<p className="mb-6 rounded-md border bg-yellow-50 p-3 text-sm text-yellow-900">
Your session expired. Please sign in again.
</p>
)}
<section className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight">MakeArmy</h1>
<p className="mt-2 text-base text-muted-foreground">
Free to use. Manage laser rigs, settings, and projectsall in one
place.
</p>
</section>
<section className="grid gap-6 md:grid-cols-2">
<SignUp nextPath="/portal" />
<SignIn nextPath="/portal" reauth={reauth} />
</section>
<section className="mt-8 text-center text-xs text-muted-foreground">
<p>This is the beta build v0.1.1 - this site is an active BETA. Some features may not be available or work as intended. If you're experiencing issues please report them here: https://forge.makearmy.io/makearmy/makearmy-app/issues </p>
<p>PRIVACY: We only use cookies strictly necessary to operate the site (e.g., your sign-in session). We do not store user data or telemetry other than what you provide and never share or sell data to third parties. Ever.</p>
</section>
</main>
<Suspense fallback={<main className="mx-auto max-w-7xl px-6 py-10">Loading utilities</main>}>
<TemporaryUtilityHub />
</Suspense>
);
}

View file

@ -0,0 +1,127 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
const LaserToolkit = dynamic(
() => import("@/components/portal/LaserToolkitSwitcher"),
{ ssr: false }
);
const FileBrowser = dynamic(
() => import("@/components/utilities/files/FileBrowserPanel"),
{ ssr: false }
);
const BackgroundRemover = dynamic(
() => import("@/components/utilities/BackgroundRemoverPanel"),
{ ssr: false }
);
const TOOLS = [
{
key: "laser-toolkit",
label: "Laser Toolkit",
description: "Calculators for beam size, overlap, hatch spacing, job time, and more.",
icon: "/images/utils/toolkit.png",
component: LaserToolkit,
},
{
key: "files",
label: "File Server",
description: "Browse, preview, and download files from the public library.",
icon: "/images/utils/fs.png",
component: FileBrowser,
},
{
key: "background-remover",
label: "Background Remover",
description: "Remove image backgrounds using our self-hosted processing service.",
icon: "/images/utils/bgrm.png",
component: BackgroundRemover,
},
] as const;
export default function TemporaryUtilityHub() {
const router = useRouter();
const searchParams = useSearchParams();
const activeKey = searchParams.get("tool") || TOOLS[0].key;
const active = useMemo(
() => TOOLS.find((tool) => tool.key === activeKey) || TOOLS[0],
[activeKey]
);
const ActiveTool = active.component;
function selectTool(key: string) {
const query = new URLSearchParams(searchParams.toString());
query.set("tool", key);
if (key !== "laser-toolkit") query.delete("lt");
router.replace(`/?${query.toString()}`, { scroll: false });
}
return (
<main className="mx-auto min-h-screen max-w-7xl px-4 py-6 sm:px-6 sm:py-10">
<header className="mb-8 border-b pb-6">
<p className="mb-2 text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
Laser Everything
</p>
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Community Utilities</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground sm:text-base">
Our database and member features are temporarily offline while we change backend
systems. These free utilities remain available without an account.
</p>
</header>
<nav aria-label="Utilities" className="mb-8 grid gap-3 md:grid-cols-3">
{TOOLS.map((tool) => {
const selected = tool.key === active.key;
return (
<button
key={tool.key}
type="button"
onClick={() => selectTool(tool.key)}
aria-pressed={selected}
className={cn(
"flex min-h-24 items-start gap-4 rounded-xl border p-4 text-left transition",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "bg-card hover:border-muted-foreground/50 hover:bg-muted/50"
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={tool.icon} alt="" className="h-10 w-10 rounded-lg object-cover" />
<span>
<span className="block font-semibold">{tool.label}</span>
<span
className={cn(
"mt-1 block text-sm leading-5",
selected ? "text-primary-foreground/80" : "text-muted-foreground"
)}
>
{tool.description}
</span>
</span>
</button>
);
})}
</nav>
<section aria-labelledby="active-tool-title" className="rounded-xl border bg-card p-3 sm:p-5">
<div className="mb-5 flex items-center gap-3 border-b pb-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={active.icon} alt="" className="h-8 w-8 rounded-md object-cover" />
<div>
<h2 id="active-tool-title" className="text-xl font-semibold">{active.label}</h2>
<p className="text-sm text-muted-foreground">{active.description}</p>
</div>
</div>
<ActiveTool basePath="/" />
</section>
<footer className="mt-8 text-center text-xs leading-5 text-muted-foreground">
No account is required. Database-backed profiles, projects, settings, and submissions
are intentionally unavailable on this temporary site.
</footer>
</main>
);
}

View file

@ -6,7 +6,7 @@ import { useSearchParams, useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { TOOLKIT_TABS } from "@/components/utilities/laser-toolkit/registry";
export default function LaserToolkitSwitcher() {
export default function LaserToolkitSwitcher({ basePath = "/portal/utilities" }: { basePath?: string }) {
const sp = useSearchParams();
const router = useRouter();
@ -24,7 +24,7 @@ export default function LaserToolkitSwitcher() {
function setTab(k: string) {
const q = new URLSearchParams(sp.toString());
q.set("lt", k);
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
router.replace(`${basePath}?${q.toString()}`, { scroll: false });
}
if (!active) {

View file

@ -208,8 +208,8 @@ export default function FileBrowserPanel() {
<div className="select-all text-xs text-muted-foreground">{path || "/"}</div>
<div className="grid grid-cols-[minmax(560px,1fr)_minmax(420px,42vw)] items-start gap-3">
<div className="overflow-hidden rounded-md border">
<div className="grid grid-cols-1 items-start gap-3 xl:grid-cols-[minmax(560px,1fr)_minmax(420px,42vw)]">
<div className="overflow-x-auto rounded-md border">
<div className="fs-table bg-muted px-2 py-2 fs-cell fs-tight">
<div className="px-1">Name</div>
<div className="text-center">Type</div>

View file

@ -1,217 +1,62 @@
// middleware.ts
import { NextResponse, NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
const PUBLIC_UTILITY_APIS = new Set([
"/api/bgbye/process",
"/api/files/list",
"/api/files/raw",
"/api/files/download",
]);
const LEGACY_TOOL_PATHS: Record<string, string> = {
"/laser-toolkit": "laser-toolkit",
"/files": "files",
"/background-remover": "background-remover",
};
/**
* Public pages that should remain reachable without being signed in.
* Everything else is considered protected (including most /api/*).
* Temporary utility-only site.
*
* Directus-backed pages and APIs remain in source history but cannot be reached
* through this deployment. This middleware deliberately performs no session or
* Directus request.
*/
const PUBLIC_PAGES = new Set<string>([
"/", // splash page is public
"/auth/sign-in",
"/auth/sign-up",
]);
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
/**
* API paths that are explicitly allowed without auth.
* Keep this list tiny; add broad /api/webhooks to allow ALL webhook endpoints.
*/
const PUBLIC_API_PREFIXES: string[] = [
"/api/auth/", // login/refresh/callback endpoints
];
/** Directus base (used to remotely validate the token after restarts). */
const DIRECTUS = (
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.DIRECTUS_URL ||
""
).replace(/\/$/, "");
type MapResult = { pathname: string; query?: Record<string, string> };
/** Helper: does the path start with any prefix in a list? */
function startsWithAny(pathname: string, prefixes: string[]) {
return prefixes.some((p) => pathname.startsWith(p));
}
/** Helper: are we about to redirect to the same URL? */
function isSameUrl(req: NextRequest, mapped: MapResult) {
const dest = new URL(req.url);
dest.pathname = mapped.pathname;
if (mapped.query) {
for (const [k, v] of Object.entries(mapped.query)) dest.searchParams.set(k, v);
}
return dest.href === req.url;
}
/** Decode JWT exp (seconds since epoch). We don't verify signature here. */
function jwtExp(token: string): number | null {
try {
const [, payload] = token.split(".");
if (!payload) return null;
const json = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
return typeof json.exp === "number" ? json.exp : null;
} catch {
return null;
}
}
/**
* Build redirect to /auth/sign-in?next=<original>.
* Only set reauth=1 (and clear cookies) when opts.reauth === true.
*/
function kickToSignIn(req: NextRequest, opts?: { reauth?: boolean }) {
const wantReauth = !!opts?.reauth;
const orig = new URL(req.url);
const next = orig.pathname + (orig.search || "");
const url = new URL(req.url);
url.pathname = "/auth/sign-in";
url.search = "";
if (wantReauth) url.searchParams.set("reauth", "1");
url.searchParams.set("next", next);
const res = NextResponse.redirect(url);
if (wantReauth) {
res.cookies.set("ma_at", "", { maxAge: 0, path: "/" });
res.cookies.set("ma_v", "", { maxAge: 0, path: "/" });
}
return res;
}
export async function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const { pathname } = url;
// ── -1) Allow only the explicitly configured external webhook.
if (pathname === "/api/webhooks/kofi") {
return NextResponse.next();
}
// ── 0) Root must never redirect
if (pathname === "/") return NextResponse.next();
// ── 1) Legacy → Portal mapping (before auth gating)
const mapped = legacyMap(pathname);
if (mapped && !isSameUrl(req, mapped)) {
url.pathname = mapped.pathname;
if (mapped.query) {
for (const [k, v] of Object.entries(mapped.query)) url.searchParams.set(k, v);
}
return NextResponse.redirect(url);
}
// ── 2) Auth gating
const token = req.cookies.get("ma_at")?.value ?? "";
const isAuthRoute = pathname.startsWith("/auth/");
const isProtected = !isPublicPath(pathname);
const forceAuth =
isAuthRoute &&
(url.searchParams.get("reauth") === "1" ||
url.searchParams.get("force") === "1");
if (!token && isProtected) {
return kickToSignIn(req, { reauth: false });
}
if (token) {
const exp = jwtExp(token);
const expired = !exp || exp * 1000 <= Date.now();
if (isAuthRoute && !expired && !forceAuth) {
url.pathname = "/portal";
const legacyTool = LEGACY_TOOL_PATHS[pathname.replace(/\/$/, "")];
if (legacyTool) {
const url = req.nextUrl.clone();
url.pathname = "/";
url.search = "";
url.searchParams.set("tool", legacyTool);
return NextResponse.redirect(url);
}
if (isProtected) {
if (expired) {
return kickToSignIn(req, { reauth: true });
if (pathname === "/portal/utilities") {
const url = req.nextUrl.clone();
const requested = url.searchParams.get("t");
url.pathname = "/";
url.search = "";
if (requested && ["laser-toolkit", "files", "background-remover"].includes(requested)) {
url.searchParams.set("tool", requested);
}
return NextResponse.redirect(url);
}
if (DIRECTUS) {
const nowMinute = Math.floor(Date.now() / 60000).toString();
const lastValidated = req.cookies.get("ma_v")?.value;
if (lastValidated !== nowMinute) {
try {
const r = await fetch(`${DIRECTUS}/users/me?fields=id`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
cache: "no-store",
});
if (!r.ok) {
return kickToSignIn(req, { reauth: true });
}
const res = NextResponse.next();
res.cookies.set("ma_v", nowMinute, {
path: "/",
httpOnly: false,
sameSite: "lax",
maxAge: 90,
});
return res;
} catch {
return kickToSignIn(req, { reauth: true });
}
}
}
}
}
return NextResponse.next();
}
function legacyMap(pathname: string): MapResult | null {
if (pathname === "/" || pathname.startsWith("/portal")) return null;
const listRules: Array<[RegExp, MapResult]> = [
[/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }],
[/^\/svgnest\/?$/i, { pathname: "/portal/utilities", query: { t: "svgnest" } }],
[/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }],
[/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }],
[/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }],
[/^\/lasers\/?$/i, { pathname: "/portal/laser-sources" }],
[/^\/projects\/?$/i, { pathname: "/portal/projects" }],
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
];
for (const [re, dest] of listRules) {
if (re.test(pathname)) return dest;
}
return null;
}
function isPublicPath(pathname: string): boolean {
if (PUBLIC_PAGES.has(pathname)) return true;
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/static/") ||
pathname.startsWith("/images/") ||
pathname === "/favicon.ico" ||
pathname === "/robots.txt" ||
pathname === "/sitemap.xml"
) {
return true;
}
if (PUBLIC_UTILITY_APIS.has(pathname)) return NextResponse.next();
if (pathname.startsWith("/api/")) {
return startsWithAny(pathname, PUBLIC_API_PREFIXES);
return NextResponse.json({ error: "Not available on the temporary utility site" }, { status: 404 });
}
return false;
}
const home = req.nextUrl.clone();
home.pathname = "/";
home.search = "";
return NextResponse.redirect(home);
}
// Match all except the usual static assets
export const config = {
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"],
};
};