Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m39s
CI / tests-unit (push) Successful in 1m43s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m17s
`next build` had never completed on this host, so three real defects were
sitting in the tree untested. All three are now fixed and the build is green.
- The build was not memory-bound the way it looked. Turbopack's builder reached
20.5GB RSS and died, and raising `--max-old-space-size` could never have
helped: that flag caps the V8 heap, while the 20GB sat in Turbopack's own Rust
allocator. The first symptom was misleading because the process doing the
allocating is a grandchild of `npx`, so watching the direct child shows a
95MB shim the whole time. Building with `--webpack` puts the build back under
the JS heap, where the flag actually applies: peak 5.9GB, 150s, exit 0.
- withAdmin's second parameter was typed `{ params?: ... }` and given a `= {}`
default, which made it optional and `RouteContext | undefined`. Next's
generated route types assert that argument against `ParamCheck<RouteContext>`
and reject it, across 113 route files. `tsc --noEmit` cannot see this, because
Next only adds `.next/types` to the project during a production build — so the
type check that everyone runs locally was structurally incapable of catching
the only type error that blocks a deploy. `params` is now required, which is
also what the code already assumed: it is awaited with no guard. The 35 test
call sites that invoked a handler with one argument now pass a real context,
and the await got a guard so a direct internal call cannot turn a missing
context into a 500.
- `src/app/api/admin/import/furni/route.ts` re-exported `ensureDirectories` and
`importSingleFurni` for "backward compatibility" that nothing used; the batch
route imports from `@/lib/services/furni-import` directly. Next rejects any
value export from a route module that is not an HTTP verb or config, so this
had been breaking the build for as long as it existed. Removed.
- `isomorphic-dompurify` builds its server-side DOM through jsdom. Bundled, that
pulls jsdom's `browser/default-stylesheet.css` into the server chunk, where the
path no longer resolves, and page-data collection dies with ENOENT on every
page that sanitizes HTML. Marked external so Node resolves it from
node_modules and the standalone tracer includes it.
The remaining build warning is a pre-existing circular dependency between
chunks that share the webpack runtime. It costs hash reuse, not correctness, and
is left alone rather than churned here.
Verified: build exit 0, 276 static pages generated, 3223 tests pass, tsc and
biome clean.
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
import { after, type NextRequest, NextResponse } from "next/server";
|
|
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
|
import {
|
|
createStore,
|
|
runWithStore,
|
|
setContextUserId,
|
|
} from "@/lib/foundation/request-context";
|
|
import { validateCsrfToken } from "@/lib/foundation/security";
|
|
import type { IpAddress, UserId } from "@/lib/foundation/types";
|
|
import {
|
|
collectPerformance,
|
|
routeTemplate,
|
|
} from "@/lib/performance-diagnostics";
|
|
import { recordPerformance } from "@/lib/performance-store";
|
|
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
|
import { logServerError } from "@/lib/server-log";
|
|
|
|
import {
|
|
beginCatalogExport,
|
|
catalogExportEnabled,
|
|
isCatalogMutation,
|
|
} from "@/lib/services/catalog-git-queue";
|
|
|
|
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
|
|
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>;
|
|
// Next 15+ passes `params` as a promise and its generated route types require it
|
|
// to be present, so this is deliberately not optional. `routeContext.params` is
|
|
// awaited below without a guard, so an optional type would have been a lie
|
|
// anyway. A route with no dynamic segments still receives `Promise<{}>`.
|
|
type RouteContext = { params: Promise<Record<string, string | string[]>> };
|
|
type AdminHandler = (
|
|
request: NextRequest,
|
|
context: AdminContext,
|
|
routeContext: RouteContext,
|
|
) => Promise<Response> | Response;
|
|
|
|
export function withAdmin(
|
|
options: {
|
|
permission?: string;
|
|
requireCsrf?: boolean;
|
|
maxBodyBytes?: number;
|
|
},
|
|
handler: AdminHandler,
|
|
) {
|
|
// No default value here on purpose. `RouteContext` already has an optional
|
|
// `params`, so `{}` was always assignable and the default only widened the
|
|
// parameter to `RouteContext | undefined`. Next's generated route types assert
|
|
// the second argument against `ParamCheck<RouteContext>`, and the `undefined`
|
|
// fails that check. `tsc --noEmit` does not see it, because Next only adds
|
|
// `.next/types` to the project during a production build.
|
|
// Deliberately no default. A parameter with a default is optional in the
|
|
// signature, which makes its type `RouteContext | undefined` and fails Next's
|
|
// `ParamCheck`. Tests that call a handler directly pass `emptyRouteContext()`.
|
|
return async (request: NextRequest, routeContext: RouteContext) => {
|
|
const store = createStore("unknown" as IpAddress);
|
|
const { value: response, metrics } = await collectPerformance(() =>
|
|
runWithStore(store, async () => {
|
|
// CSRF required for mutating admin APIs unless explicitly opted out.
|
|
const csrfRequired =
|
|
options.requireCsrf !== false && MUTATING_METHODS.has(request.method);
|
|
if (csrfRequired) {
|
|
const csrfToken =
|
|
request.headers.get("x-csrf-token") ??
|
|
request.headers.get("csrf-token") ??
|
|
"";
|
|
const valid = await validateCsrfToken(csrfToken);
|
|
if (!valid) {
|
|
return NextResponse.json(
|
|
{ ok: false, error: "Invalid or missing CSRF token" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
|
|
if (MUTATING_METHODS.has(request.method)) {
|
|
const contentLength = request.headers.get("content-length");
|
|
const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
if (contentLength && Number(contentLength) > maxBytes) {
|
|
return NextResponse.json(
|
|
{ ok: false, error: `Request body exceeds ${maxBytes} bytes` },
|
|
{ status: 413 },
|
|
);
|
|
}
|
|
}
|
|
|
|
const context = await getApiAdminContext();
|
|
if (!context)
|
|
return NextResponse.json(
|
|
{ ok: false, error: "Unauthorized" },
|
|
{ status: 401 },
|
|
);
|
|
if (
|
|
options.permission &&
|
|
!canAccess(
|
|
context.permissions,
|
|
options.permission,
|
|
context.session.user.rank,
|
|
)
|
|
) {
|
|
await logAuthorizationEvent({
|
|
kind: "permission.denied",
|
|
userId: context.session.user.id,
|
|
username: context.session.user.username,
|
|
rank: context.session.user.rank,
|
|
permission: options.permission,
|
|
source: request.nextUrl.pathname,
|
|
reason: "API permission check denied",
|
|
});
|
|
return NextResponse.json(
|
|
{ ok: false, error: "Forbidden" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
setContextUserId(Number(context.session.user.id) as UserId);
|
|
let finishExport: (() => Promise<void>) | undefined;
|
|
try {
|
|
if (
|
|
catalogExportEnabled() &&
|
|
isCatalogMutation(request.method, request.nextUrl.pathname)
|
|
) {
|
|
finishExport = await beginCatalogExport();
|
|
}
|
|
const response = await handler(request, context, routeContext);
|
|
if (
|
|
finishExport &&
|
|
response.body &&
|
|
response.headers.get("content-type")?.includes("text/event-stream")
|
|
) {
|
|
const [client, completion] = response.body.tee();
|
|
const finish = finishExport;
|
|
after(async () => {
|
|
const reader = completion.getReader();
|
|
try {
|
|
while (!(await reader.read()).done) {
|
|
/* Wait for all import results. */
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
await finish();
|
|
}
|
|
});
|
|
return new Response(client, {
|
|
status: response.status,
|
|
headers: response.headers,
|
|
});
|
|
}
|
|
await finishExport?.();
|
|
return response;
|
|
} catch (error) {
|
|
await finishExport?.().catch(() => undefined);
|
|
const errorId = logServerError("admin.api_failed", error, {
|
|
path: request.nextUrl.pathname,
|
|
userId: context.session.user.id,
|
|
});
|
|
return NextResponse.json(
|
|
{ ok: false, error: "Internal server error", errorId },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
if (store.userId !== null) {
|
|
recordPerformance({
|
|
operationId: store.requestId,
|
|
route: routeTemplate(
|
|
request.nextUrl.pathname,
|
|
// Next always passes a context, and the type requires it, so
|
|
// this is only a guard against a direct internal call. The
|
|
// worst case is a slightly less specific metric label, which
|
|
// is not worth a 500.
|
|
await routeContext?.params,
|
|
),
|
|
method: request.method,
|
|
status: response.status,
|
|
at: Date.now(),
|
|
metrics,
|
|
streaming:
|
|
response.headers.get("content-type")?.includes("text/event-stream") ??
|
|
false,
|
|
});
|
|
}
|
|
const headers = new Headers(response.headers);
|
|
headers.set("x-operation-id", store.requestId);
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
};
|
|
}
|