fix: product subdomain cleanup

This commit is contained in:
2026-07-23 20:52:49 -04:00
parent a6ef4d2955
commit 8e33af43a5
39 changed files with 1597 additions and 630 deletions

View File

@@ -1,4 +1,9 @@
import { createTRPCRouter, publicProcedure, protectedProcedure, csrfProtectedProcedure } from "../utils";
import {
createTRPCRouter,
publicProcedure,
protectedProcedure,
csrfProtectedProcedure
} from "../utils";
import { z } from "zod";
import {
S3Client,
@@ -42,7 +47,7 @@ export function sanitizeS3PathComponent(value: string): string {
// Strip path traversal characters and normalize whitespace
return value
.replace(/\s+/g, "-")
.replace(/[\/\\]/g, "-")
.replace(/[/\\]/g, "-")
.replace(/\.\./g, "")
.replace(/[^a-zA-Z0-9_-]/g, "")
.replace(/-+/g, "-")
@@ -70,15 +75,17 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
// tested in `bun:test` without a populated `.env`. Re-exported here for the
// tRPC mutation below + for callers that already import from `misc`.
export {
// Import into local scope FIRST — `sendDeletionRequestEmail` below uses
// these names directly. A bare `export { ... } from` re-export does NOT make
// the bindings available locally, which caused a ReferenceError that crashed
// the entire tRPC router (503 on every /api/trpc call).
import {
DELETION_PRODUCT_SCHEMA,
deletionCookieName,
deletionEmailContent
} from "./deletion-email";
export type {
DeletionProduct,
DeletionEmailContent
} from "./deletion-email";
export { DELETION_PRODUCT_SCHEMA, deletionCookieName, deletionEmailContent };
export type { DeletionProduct, DeletionEmailContent } from "./deletion-email";
const assets: Record<string, string> = {
"shapes-with-abigail": "shapes-with-abigail.apk",
@@ -378,10 +385,13 @@ export const miscRouter = createTRPCRouter({
);
if (!turnstileValid) {
console.error("Turnstile verification failed for contact form submission");
console.error(
"Turnstile verification failed for contact form submission"
);
throw new TRPCError({
code: "FORBIDDEN",
message: "Security verification failed. Please refresh the page and try again."
message:
"Security verification failed. Please refresh the page and try again."
});
}

View File

@@ -14,6 +14,7 @@ import type { APIEvent } from "@solidjs/start/server";
import type { H3Event } from "vinxi/http";
import {
resolveSiteFromHost,
resolveSiteFromPath,
type Site,
MAIN_SITE
} from "~/lib/site-context";
@@ -53,7 +54,9 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
try {
const nodeReq = (
event as {
nativeEvent?: { node?: { req?: { headers?: Record<string, string | string[]> } } };
nativeEvent?: {
node?: { req?: { headers?: Record<string, string | string[]> } };
};
} | null
)?.nativeEvent?.node?.req;
const raw = nodeReq?.headers?.host;
@@ -78,16 +81,76 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
return null;
}
/**
* Best-effort extraction of the URL pathname from any request-shaped event.
*
* SolidStart's FetchEvent exposes `event.request.url` (a full URL string);
* nitro/H3 exposes the raw Node request URL via `event.nativeEvent.node.req.url`.
* We try both so this works for API route handlers, the SSR document handler,
* and tRPC procedures operating on the underlying `H3Event`.
*/
function pathFromEventLike(event: ServerSiteEvent | unknown): string | null {
// 1) Web FetchEvent / APIEvent / PageEvent → standard Request URL.
try {
const req = (event as { request?: Request } | null)?.request;
const url = req?.url;
if (url) return new URL(url).pathname;
} catch {
/* noop */
}
// 2) vinxi/nitro H3Event → raw Node request URL.
try {
const nodeReq = (
event as {
nativeEvent?: { node?: { req?: { url?: string } } };
} | null
)?.nativeEvent?.node?.req;
const raw = nodeReq?.url;
if (typeof raw === "string" && raw) {
// Node request URLs may be path-only (`/nessa/contact`) or full URLs.
return raw.startsWith("/") ? raw : new URL(raw).pathname;
}
} catch {
/* noop */
}
// 3) Some H3 shapes expose `event.node.req` directly.
try {
const nodeReq = (
event as {
node?: { req?: { url?: string } };
} | null
)?.node?.req;
const raw = nodeReq?.url;
if (typeof raw === "string" && raw) {
return raw.startsWith("/") ? raw : new URL(raw).pathname;
}
} catch {
/* noop */
}
return null;
}
/**
* Resolve the active `Site` from a SolidStart APIEvent / PageEvent or a
* vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape,
* so the SSR document handler can pass its `PageEvent` directly. Falls back
* to `main` if no host can be determined.
* to `main` if no host or path can be determined.
*/
export function getSiteFromEvent(
event: APIEvent | H3Event | ServerSiteEvent
): Site {
return resolveSiteFromHost(hostFromEventLike(event));
const hostResult = resolveSiteFromHost(hostFromEventLike(event));
// Subdomain host (incl. `*.localhost` dev) → authoritative, use it.
if (hostResult.id !== "main") return hostResult;
// Host resolved to main — fall back to the URL path prefix. On localhost
// the vercel.json host rewrites don't run, so `localhost:3000/nessa/contact`
// has host `localhost` (→ main) but path `/nessa/contact` (→ nessa).
// Without this, every subdomain page in dev renders the main-site nav.
return resolveSiteFromPath(pathFromEventLike(event)) ?? hostResult;
}
/**
@@ -95,7 +158,9 @@ export function getSiteFromEvent(
* functions that receive a `Request` directly). Falls back to `main`.
*/
export function getSiteFromRequest(request: Request): Site {
return resolveSiteFromHost(request.headers.get("host"));
const hostResult = resolveSiteFromHost(request.headers.get("host"));
if (hostResult.id !== "main") return hostResult;
return resolveSiteFromPath(new URL(request.url).pathname) ?? hostResult;
}
export { MAIN_SITE };