@bygghammar/cms-platform 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Supabase server environment resolution.
3
+ *
4
+ * Supports both the current key names and the named-key JSON bundles used by
5
+ * self-hosted stacks, plus the legacy service-role variable.
6
+ */
7
+ export type SupabaseServerEnv = Partial<
8
+ Record<
9
+ | "SUPABASE_URL"
10
+ | "SUPABASE_PUBLISHABLE_KEYS"
11
+ | "SUPABASE_PUBLISHABLE_KEY"
12
+ | "SUPABASE_SECRET_KEYS"
13
+ | "SUPABASE_SECRET_KEY"
14
+ | "SUPABASE_SERVICE_ROLE_KEY",
15
+ string
16
+ >
17
+ >;
18
+
19
+ function defaultNamedKey(value: string | undefined): string | undefined {
20
+ if (!value) return undefined;
21
+
22
+ try {
23
+ const parsed = JSON.parse(value) as unknown;
24
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
25
+
26
+ const key = (parsed as Record<string, unknown>).default;
27
+ return typeof key === "string" && key.length > 0 ? key : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ /** Resolve both current Supabase keys and the legacy self-host service-role key. */
34
+ export function resolveSupabaseServerEnvironment(
35
+ env: SupabaseServerEnv = process.env as SupabaseServerEnv,
36
+ ): {
37
+ url: string | undefined;
38
+ publishableKey: string | undefined;
39
+ secretKey: string | undefined;
40
+ } {
41
+ return {
42
+ url: env.SUPABASE_URL,
43
+ publishableKey: defaultNamedKey(env.SUPABASE_PUBLISHABLE_KEYS) ?? env.SUPABASE_PUBLISHABLE_KEY,
44
+ secretKey:
45
+ defaultNamedKey(env.SUPABASE_SECRET_KEYS) ??
46
+ env.SUPABASE_SECRET_KEY ??
47
+ env.SUPABASE_SERVICE_ROLE_KEY,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * `fetch` shim for Supabase server clients.
53
+ *
54
+ * Opaque `sb_` keys are API keys, not bearer JWTs: PostgREST rejects them in an
55
+ * `Authorization` header, so send them only as `apikey`.
56
+ */
57
+ export function createServerFetch(key: string): typeof fetch {
58
+ return (input, init) => {
59
+ const headers = new Headers(
60
+ typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
61
+ );
62
+
63
+ if (init?.headers) {
64
+ new Headers(init.headers).forEach((value, name) => headers.set(name, value));
65
+ }
66
+
67
+ if (key.startsWith("sb_") && headers.get("Authorization") === `Bearer ${key}`) {
68
+ headers.delete("Authorization");
69
+ }
70
+
71
+ headers.set("apikey", key);
72
+ return fetch(input, { ...init, headers });
73
+ };
74
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Backup/restore HTTP handler cores.
3
+ *
4
+ * The site route file only wires these to `createFileRoute`; authorisation and
5
+ * archive work are injected so the same HTTP contract holds on every site.
6
+ */
7
+ export type BackupCaller = unknown;
8
+
9
+ export type BackupDeps = {
10
+ /** Throws (or returns a Response) when the caller is not a superadmin. */
11
+ requireSuperadmin: (request: Request) => Promise<BackupCaller>;
12
+ };
13
+
14
+ function errorResponse(e: unknown, fallback: string, status: number): Response {
15
+ if (e instanceof Response) return e;
16
+ return new Response(e instanceof Error ? e.message : fallback, { status });
17
+ }
18
+
19
+ /** Dry run: inspect an uploaded archive without writing anything. */
20
+ export async function backupAnalyzeResponse(
21
+ request: Request,
22
+ deps: BackupDeps & { analyzeArchive: (bytes: Uint8Array) => Promise<unknown> },
23
+ ): Promise<Response> {
24
+ try {
25
+ await deps.requireSuperadmin(request);
26
+ const bytes = new Uint8Array(await request.arrayBuffer());
27
+ if (!bytes.byteLength) return new Response("Empty upload", { status: 400 });
28
+ return Response.json(await deps.analyzeArchive(bytes));
29
+ } catch (e) {
30
+ return errorResponse(e, "Could not read archive", 400);
31
+ }
32
+ }
33
+
34
+ /** Row/media counts for the selected datasets, shown before an export runs. */
35
+ export async function backupEstimateResponse(
36
+ request: Request,
37
+ deps: BackupDeps & {
38
+ countRows: (groups: string[], caller: BackupCaller) => Promise<unknown>;
39
+ },
40
+ ): Promise<Response> {
41
+ try {
42
+ const caller = await deps.requireSuperadmin(request);
43
+ const body = (await request.json()) as { groups?: string[] };
44
+ return Response.json(await deps.countRows(body.groups ?? [], caller));
45
+ } catch (e) {
46
+ return errorResponse(e, "Failed", 500);
47
+ }
48
+ }
49
+
50
+ /** Build and stream a backup archive. */
51
+ export async function backupExportResponse(
52
+ request: Request,
53
+ deps: BackupDeps & {
54
+ buildArchive: (
55
+ caller: BackupCaller,
56
+ groups: string[],
57
+ site: string,
58
+ options: { deferMediaToBrowser: boolean },
59
+ ) => Promise<ArrayBufferLike | Uint8Array>;
60
+ },
61
+ ): Promise<Response> {
62
+ try {
63
+ const caller = await deps.requireSuperadmin(request);
64
+ const body = (await request.json()) as {
65
+ groups?: string[];
66
+ label?: string;
67
+ deferMediaToBrowser?: boolean;
68
+ };
69
+ const groups = body.groups ?? [];
70
+ if (!groups.length) return new Response("No datasets selected", { status: 400 });
71
+
72
+ const site = new URL(request.url).host;
73
+ const zip = await deps.buildArchive(caller, groups, site, {
74
+ deferMediaToBrowser: body.deferMediaToBrowser === true,
75
+ });
76
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
77
+ const name = `${(body.label ?? "backup").replace(/[^a-z0-9_-]/gi, "")}-${stamp}.zip`;
78
+
79
+ return new Response(new Uint8Array(zip as ArrayBufferLike), {
80
+ status: 200,
81
+ headers: {
82
+ "content-type": "application/zip",
83
+ "content-disposition": `attachment; filename="${name}"`,
84
+ "cache-control": "no-store",
85
+ },
86
+ });
87
+ } catch (e) {
88
+ return errorResponse(e, "Export failed", 500);
89
+ }
90
+ }
91
+
92
+ export type ImportOptions = {
93
+ groups: string[];
94
+ mode: "merge" | "replace";
95
+ includeMedia: boolean;
96
+ rewriteMediaBase: string | null;
97
+ keepInjectionsEnabled: boolean;
98
+ };
99
+
100
+ /**
101
+ * Apply an uploaded archive. Options travel as query parameters so the body
102
+ * stays the raw zip: ?groups=content,forms&mode=merge&media=1
103
+ */
104
+ export async function backupImportResponse(
105
+ request: Request,
106
+ deps: BackupDeps & {
107
+ importArchive: (
108
+ caller: BackupCaller,
109
+ bytes: Uint8Array,
110
+ options: ImportOptions,
111
+ ) => Promise<unknown>;
112
+ /** Fallback media base used when `?rewrite=1` has no explicit base. */
113
+ defaultMediaBase?: () => string;
114
+ },
115
+ ): Promise<Response> {
116
+ try {
117
+ const caller = await deps.requireSuperadmin(request);
118
+ const url = new URL(request.url);
119
+ const groups = (url.searchParams.get("groups") ?? "").split(",").filter(Boolean);
120
+ const mode = url.searchParams.get("mode") === "replace" ? "replace" : "merge";
121
+ const includeMedia = url.searchParams.get("media") === "1";
122
+ if (!groups.length) return new Response("No datasets selected", { status: 400 });
123
+
124
+ // Optional: make restored content point at this installation's media
125
+ // instead of the backend the archive was exported from.
126
+ const rewriteMediaBase =
127
+ url.searchParams.get("rewrite") === "1"
128
+ ? url.searchParams.get("mediaBase")?.trim() ||
129
+ (deps.defaultMediaBase?.() ??
130
+ process.env["MEDIA_PUBLIC_URL"]?.replace(/\/$/, "") ??
131
+ "/media")
132
+ : null;
133
+
134
+ // Restored tracking/code injections stay disabled unless asked for.
135
+ const keepInjectionsEnabled = url.searchParams.get("keepInjections") === "1";
136
+
137
+ const bytes = new Uint8Array(await request.arrayBuffer());
138
+ if (!bytes.byteLength) return new Response("Empty upload", { status: 400 });
139
+
140
+ return Response.json(
141
+ await deps.importArchive(caller, bytes, {
142
+ groups,
143
+ mode,
144
+ includeMedia,
145
+ rewriteMediaBase,
146
+ keepInjectionsEnabled,
147
+ }),
148
+ );
149
+ } catch (e) {
150
+ return errorResponse(e, "Import failed", 500);
151
+ }
152
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Authenticated CMS write over plain HTTP.
3
+ *
4
+ * Delegates to the same `saveContentSection` server function the backoffice
5
+ * editor uses, so the bearer token, the staff role check and the row-level
6
+ * security rules all apply unchanged. No privileged database access happens
7
+ * here — the request runs as the signed-in user.
8
+ */
9
+ export async function cmsContentResponse(
10
+ request: Request,
11
+ save: (body: unknown) => Promise<unknown>,
12
+ ): Promise<Response> {
13
+ let body: unknown;
14
+ try {
15
+ body = await request.json();
16
+ } catch {
17
+ return Response.json({ ok: false, error: "Invalid JSON body." }, { status: 400 });
18
+ }
19
+ try {
20
+ return Response.json(await save(body));
21
+ } catch (err) {
22
+ const message = (err as Error).message || "Request failed";
23
+ const status = /unauthor|forbidden|sign in/i.test(message) ? 401 : 400;
24
+ return Response.json({ ok: false, error: message }, { status });
25
+ }
26
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Self-hosted font handler core.
3
+ *
4
+ * Filenames are content-hashed (`inter-latin-UcC73Fwr.woff2`), so responses
5
+ * carry the one-year immutable policy plus a matching `Expires`. WOFF2 is
6
+ * already compressed and never re-encoded, so no `Vary: Accept-Encoding`.
7
+ */
8
+ import { cachePolicyFor, expiresHeader } from "../../http/cache-policy";
9
+
10
+ export function fontResponse(options: {
11
+ request: Request;
12
+ method: "GET" | "HEAD";
13
+ splat: string | undefined;
14
+ fontBytes: (name: string) => ArrayBufferLike | Uint8Array | undefined;
15
+ }): Response {
16
+ const name = decodeURIComponent(String(options.splat ?? ""));
17
+ const bytes = name.includes("/") ? undefined : options.fontBytes(name);
18
+ if (!bytes) return new Response("Not found", { status: 404 });
19
+
20
+ const url = new URL(options.request.url);
21
+ const policy = cachePolicyFor(url.pathname, url.search);
22
+ const etag = `"${bytes.byteLength.toString(16)}-${name}"`;
23
+ const headers: Record<string, string> = {
24
+ "Content-Type": "font/woff2",
25
+ "Content-Length": String(bytes.byteLength),
26
+ "Cache-Control": policy.cacheControl,
27
+ ETag: etag,
28
+ };
29
+ const expires = expiresHeader(policy);
30
+ if (expires) headers["Expires"] = expires;
31
+
32
+ if (options.request.headers.get("if-none-match") === etag) {
33
+ return new Response(null, { status: 304, headers });
34
+ }
35
+ const body =
36
+ options.method === "HEAD" ? null : new Uint8Array(bytes as ArrayBufferLike).slice().buffer;
37
+ return new Response(body as ArrayBuffer | null, { status: 200, headers });
38
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Liveness/readiness handler core.
3
+ *
4
+ * A container that starts with a broken/missing backend configuration must fail
5
+ * its health gate so the reverse proxy does not send traffic to it.
6
+ */
7
+ export type HealthPayload = {
8
+ status: "ok" | "error";
9
+ backendConfigured: boolean;
10
+ version: string;
11
+ };
12
+
13
+ export async function healthResponse(options: {
14
+ /** Cheap backend reachability probe; throwing means "not configured". */
15
+ probe: () => Promise<unknown>;
16
+ /** Deployed image tag / version string. */
17
+ version?: string;
18
+ }): Promise<Response> {
19
+ const payload: HealthPayload = {
20
+ status: "ok",
21
+ backendConfigured: false,
22
+ version:
23
+ options.version ||
24
+ process.env["APP_VERSION"] ||
25
+ process.env["VITE_PUBLIC_APP_VERSION"] ||
26
+ "unknown",
27
+ };
28
+
29
+ try {
30
+ await options.probe();
31
+ payload.backendConfigured = true;
32
+ } catch {
33
+ payload.backendConfigured = false;
34
+ payload.status = "error";
35
+ }
36
+
37
+ return Response.json(payload, { status: payload.status === "ok" ? 200 : 503 });
38
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * robots.txt handler core.
3
+ *
4
+ * Generated at request time so the `Sitemap:` line always points at the site's
5
+ * own default domain — a static file in `public/` goes stale the moment the
6
+ * domain changes.
7
+ */
8
+ export type RobotsOptions = {
9
+ /** The incoming request URL, used as the host fallback. */
10
+ requestUrl: string;
11
+ /** Resolve the site's preferred (default) host, e.g. from the domains table. */
12
+ resolvePreferredHost?: () => Promise<string | undefined>;
13
+ /** Paths that generic crawlers must not index. */
14
+ disallow?: string[];
15
+ /** Crawlers explicitly allowed everything. */
16
+ allowedAgents?: string[];
17
+ /** `Cache-Control` for the response. */
18
+ cacheControl?: string;
19
+ };
20
+
21
+ export const DEFAULT_ALLOWED_AGENTS = [
22
+ "Googlebot",
23
+ "Bingbot",
24
+ "Twitterbot",
25
+ "facebookexternalhit",
26
+ ];
27
+
28
+ export async function robotsResponse(options: RobotsOptions): Promise<Response> {
29
+ let host = new URL(options.requestUrl).host;
30
+ if (options.resolvePreferredHost) {
31
+ try {
32
+ const preferred = await options.resolvePreferredHost();
33
+ if (preferred) host = preferred.replace(/^https?:\/\//, "").replace(/\/+$/, "");
34
+ } catch {
35
+ // Fall back to the request host if the domain source is unavailable.
36
+ }
37
+ }
38
+
39
+ const lines: string[] = [];
40
+ for (const agent of options.allowedAgents ?? DEFAULT_ALLOWED_AGENTS) {
41
+ lines.push(`User-agent: ${agent}`, "Allow: /", "");
42
+ }
43
+ lines.push("User-agent: *", "Allow: /");
44
+ for (const path of options.disallow ?? []) lines.push(`Disallow: ${path}`);
45
+ lines.push("", `Sitemap: https://${host}/sitemap.xml`, "");
46
+
47
+ return new Response(lines.join("\n"), {
48
+ headers: {
49
+ "Content-Type": "text/plain; charset=utf-8",
50
+ "Cache-Control": options.cacheControl ?? "public, max-age=3600",
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * sitemap.xml handler cores.
3
+ *
4
+ * `sitemapIndexXml` builds the index (one sitemap per configured domain);
5
+ * `sitemapUrlsetXml` builds a per-locale urlset with hreflang alternates.
6
+ */
7
+ export type SitemapIndexEntry = { url: string };
8
+
9
+ export type SitemapUrlEntry = {
10
+ pageKey: string;
11
+ url: string;
12
+ alternates: Array<{ locale: string; url: string }>;
13
+ changefreq?: string;
14
+ priority?: string;
15
+ };
16
+
17
+ const XML_HEADERS = {
18
+ "Content-Type": "application/xml",
19
+ "Cache-Control": "public, max-age=3600",
20
+ } as const;
21
+
22
+ export function sitemapIndexXml(entries: SitemapIndexEntry[]): string {
23
+ const sitemaps = entries.map((e) => ` <sitemap>\n <loc>${e.url}</loc>\n </sitemap>`);
24
+ return [
25
+ `<?xml version="1.0" encoding="UTF-8"?>`,
26
+ `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
27
+ ...sitemaps,
28
+ `</sitemapindex>`,
29
+ ].join("\n");
30
+ }
31
+
32
+ export function sitemapIndexResponse(entries: SitemapIndexEntry[]): Response {
33
+ return new Response(sitemapIndexXml(entries), { headers: { ...XML_HEADERS } });
34
+ }
35
+
36
+ export function sitemapUrlsetXml(entries: SitemapUrlEntry[], homeKey = "home"): string {
37
+ const urls = entries.map((e) => {
38
+ const alts = e.alternates
39
+ .map((a) => ` <xhtml:link rel="alternate" hreflang="${a.locale}" href="${a.url}" />`)
40
+ .join("\n");
41
+ return [
42
+ ` <url>`,
43
+ ` <loc>${e.url}</loc>`,
44
+ alts,
45
+ ` <changefreq>${e.changefreq ?? "weekly"}</changefreq>`,
46
+ ` <priority>${e.priority ?? (e.pageKey === homeKey ? "1.0" : "0.7")}</priority>`,
47
+ ` </url>`,
48
+ ]
49
+ .filter(Boolean)
50
+ .join("\n");
51
+ });
52
+
53
+ return [
54
+ `<?xml version="1.0" encoding="UTF-8"?>`,
55
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">`,
56
+ ...urls,
57
+ `</urlset>`,
58
+ ].join("\n");
59
+ }
60
+
61
+ export function sitemapUrlsetResponse(entries: SitemapUrlEntry[], homeKey = "home"): Response {
62
+ return new Response(sitemapUrlsetXml(entries, homeKey), { headers: { ...XML_HEADERS } });
63
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Public-site block renderers (site-agnostic).
3
+ *
4
+ * A block instance tree plus a block registry is all that is needed to render
5
+ * a published page or an unsaved draft. The registry is passed in, so a site
6
+ * only supplies its own block definitions.
7
+ */
8
+ import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
9
+
10
+ import type { BlockDef } from "../blocks/registry";
11
+ import type { BlockInstance, BlockProps } from "../blocks/types";
12
+
13
+ export type BlockRegistry = Record<string, BlockDef>;
14
+
15
+ export type RenderedPage = {
16
+ blocks: BlockInstance[];
17
+ globals: Record<string, BlockInstance[]>;
18
+ };
19
+
20
+ function RenderBlock({
21
+ block,
22
+ globals,
23
+ registry,
24
+ edit,
25
+ selectedId,
26
+ }: {
27
+ block: BlockInstance;
28
+ globals: Record<string, BlockInstance[]>;
29
+ registry: BlockRegistry;
30
+ edit?: boolean;
31
+ selectedId?: string | null;
32
+ }) {
33
+ if (block.visible === false) return null;
34
+
35
+ if (block.type === "globalRef") {
36
+ const sectionKey = String((block.props as BlockProps | undefined)?.sectionKey ?? "");
37
+ const inner = globals[sectionKey] ?? [];
38
+ const children = inner.map((b) => (
39
+ <RenderBlock
40
+ key={b.id}
41
+ block={b}
42
+ globals={globals}
43
+ registry={registry}
44
+ edit={edit}
45
+ selectedId={selectedId}
46
+ />
47
+ ));
48
+ if (!edit) return <>{children}</>;
49
+ return (
50
+ <div
51
+ data-block-id={block.id}
52
+ data-block-type="globalRef"
53
+ data-selected={selectedId === block.id ? "true" : undefined}
54
+ className="lovable-block"
55
+ >
56
+ {children}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ const def = registry[block.type];
62
+ if (!def) return null;
63
+ const Cmp = def.component;
64
+ const rendered = <Cmp props={(block.props ?? {}) as BlockProps} />;
65
+
66
+ if (!edit) return rendered;
67
+
68
+ return (
69
+ <div
70
+ data-block-id={block.id}
71
+ data-block-type={block.type}
72
+ data-selected={selectedId === block.id ? "true" : undefined}
73
+ className="lovable-block"
74
+ >
75
+ {rendered}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ /** Render an in-memory block tree — used by the builder preview and drafts. */
81
+ export function DraftPageRendererView({
82
+ blocks,
83
+ globals,
84
+ registry,
85
+ edit,
86
+ selectedId,
87
+ }: {
88
+ blocks: BlockInstance[];
89
+ globals: Record<string, BlockInstance[]>;
90
+ registry: BlockRegistry;
91
+ edit?: boolean;
92
+ selectedId?: string | null;
93
+ }) {
94
+ return (
95
+ <>
96
+ {blocks.map((b) => (
97
+ <RenderBlock
98
+ key={b.id}
99
+ block={b}
100
+ globals={globals}
101
+ registry={registry}
102
+ edit={edit}
103
+ selectedId={selectedId}
104
+ />
105
+ ))}
106
+ </>
107
+ );
108
+ }
109
+
110
+ /** Query options for one published page, keyed by page key. */
111
+ export function createPageQueryOptions(
112
+ fetchPublishedPage: (key: string) => Promise<RenderedPage | null>,
113
+ ) {
114
+ return (key: string) =>
115
+ queryOptions({
116
+ queryKey: ["cms", "page", key],
117
+ queryFn: () => fetchPublishedPage(key),
118
+ staleTime: 30_000,
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Build the published-page renderer for a site.
124
+ * `fetchPublishedPage` is the site's `getPublishedPage` server function.
125
+ */
126
+ export function createPageRenderer(
127
+ registry: BlockRegistry,
128
+ fetchPublishedPage: (key: string) => Promise<RenderedPage | null>,
129
+ ) {
130
+ const pageQueryOptions = createPageQueryOptions(fetchPublishedPage);
131
+
132
+ function PageRenderer({ pageKey }: { pageKey: string }) {
133
+ const { data } = useSuspenseQuery(pageQueryOptions(pageKey));
134
+ const page = data as RenderedPage | null;
135
+ if (!page) return null;
136
+ return (
137
+ <DraftPageRendererView blocks={page.blocks} globals={page.globals} registry={registry} />
138
+ );
139
+ }
140
+
141
+ function DraftPageRenderer(props: {
142
+ blocks: BlockInstance[];
143
+ globals: Record<string, BlockInstance[]>;
144
+ edit?: boolean;
145
+ selectedId?: string | null;
146
+ }) {
147
+ return <DraftPageRendererView {...props} registry={registry} />;
148
+ }
149
+
150
+ return { pageQueryOptions, PageRenderer, DraftPageRenderer };
151
+ }