@bygghammar/cms-platform 0.18.0 → 0.19.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -103,7 +103,11 @@
103
103
  "./backoffice/MediaLibraryView": "./src/backoffice/MediaLibraryView.tsx",
104
104
  "./backoffice/FormEditorView": "./src/backoffice/FormEditorView.tsx",
105
105
  "./backoffice/FormsIndexView": "./src/backoffice/FormsIndexView.tsx",
106
- "./backoffice/RedirectsView": "./src/backoffice/RedirectsView.tsx"
106
+ "./backoffice/RedirectsView": "./src/backoffice/RedirectsView.tsx",
107
+ "./server/guards": "./src/server/guards.ts",
108
+ "./server/redirects-core": "./src/server/redirects-core.ts",
109
+ "./server/page-seo-core": "./src/server/page-seo-core.ts",
110
+ "./server/media-cleanup-core": "./src/server/media-cleanup-core.ts"
107
111
  },
108
112
  "dependencies": {
109
113
  "fflate": "^0.8.3"
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Host-agnostic authorization guards for platform server functions.
3
+ *
4
+ * The host passes the request context of its own auth middleware: an
5
+ * authenticated Supabase-shaped client plus the signed-in user id. Every check
6
+ * runs as that user, so row-level security still applies — these helpers never
7
+ * receive or use privileged credentials.
8
+ */
9
+
10
+ /** Minimal shape of an authenticated request context a host must provide. */
11
+ export type PlatformAuthContext = {
12
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
13
+ supabase: { rpc: (fn: any, args: any) => Promise<{ data: any; error: any }> };
14
+ userId: string;
15
+ claims?: { email?: string | null } | null;
16
+ };
17
+
18
+ /** Email of the acting user, when the host's claims carry one. */
19
+ export function actorEmail(context: PlatformAuthContext): string | null {
20
+ return context.claims?.email ?? null;
21
+ }
22
+
23
+ /** Throws unless the caller passes the host's `is_staff` check. */
24
+ export async function assertStaff(context: PlatformAuthContext): Promise<void> {
25
+ const { data, error } = await context.supabase.rpc("is_staff", { _user_id: context.userId });
26
+ if (error) throw new Error(error.message);
27
+ if (!data) throw new Error("Forbidden");
28
+ }
29
+
30
+ /** Throws unless the caller holds `_role` according to the host's `has_role`. */
31
+ export async function assertRole(context: PlatformAuthContext, role: string): Promise<void> {
32
+ const { data, error } = await context.supabase.rpc("has_role", {
33
+ _user_id: context.userId,
34
+ _role: role,
35
+ });
36
+ if (error) throw new Error(error.message);
37
+ if (!data) throw new Error(`Forbidden — ${role} only`);
38
+ }
39
+
40
+ /** Convenience wrapper for the superadmin-only maintenance tools. */
41
+ export function assertSuperadmin(context: PlatformAuthContext): Promise<void> {
42
+ return assertRole(context, "superadmin");
43
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Host-agnostic media library maintenance: find byte-identical duplicates and
3
+ * legacy timestamp-prefixed filenames, then repair them safely.
4
+ *
5
+ * Nothing is ever hard-deleted: duplicates are moved into the media bin after
6
+ * every CMS reference has been repointed at the keeper, so a wrong merge stays
7
+ * fully recoverable. The host injects storage access, reference rewriting and
8
+ * the database side effects; this module owns the planning and the sequencing.
9
+ */
10
+ import { cleanFileName, splitExt, uniqueFileName } from "../media/filename";
11
+
12
+ export type DuplicateGroup = { hash: string; size: number; keep: string; remove: string[] };
13
+ export type RenamePlan = { from: string; to: string };
14
+
15
+ export type MediaCleanupPlan = {
16
+ duplicates: DuplicateGroup[];
17
+ renames: RenamePlan[];
18
+ duplicateBytes: number;
19
+ scanned: number;
20
+ };
21
+
22
+ export type MediaCleanupResult = {
23
+ merged: number;
24
+ renamed: number;
25
+ rewritten: number;
26
+ freedBytes: number;
27
+ errors: string[];
28
+ };
29
+
30
+ /** Storage surface the cleanup needs — satisfied by `getMediaStorage()`. */
31
+ export type CleanupStorage = {
32
+ list: () => Promise<{ name: string; size: number }[]>;
33
+ download: (path: string) => Promise<Uint8Array>;
34
+ move: (from: string, to: string) => Promise<unknown>;
35
+ publicUrl: (path: string) => string;
36
+ };
37
+
38
+ /** Database side effects the host performs with its own privileged client. */
39
+ export type CleanupHost = {
40
+ storage: CleanupStorage;
41
+ /** Repoint stored references from any of `oldUrls` to `newUrl`; returns hits. */
42
+ rewriteReferences: (oldUrls: string[], newUrl: string) => Promise<number>;
43
+ /** Record a merged duplicate in the recoverable bin under `trashPrefix`. */
44
+ trashDuplicate: (input: {
45
+ trashId: string;
46
+ path: string;
47
+ trashPrefix: string;
48
+ size: number;
49
+ mergedInto: string;
50
+ }) => Promise<void>;
51
+ /** Move metadata/focal rows keyed by storage path onto the new path. */
52
+ movePathRows: (from: string, to: string) => Promise<void>;
53
+ /** Best-effort audit entry for the whole run. */
54
+ logCleanup?: (result: MediaCleanupResult) => Promise<void>;
55
+ };
56
+
57
+ const TIMESTAMP_PREFIX = /^\d{10,16}-(?=.)/;
58
+
59
+ const dirOf = (path: string) => {
60
+ const i = path.lastIndexOf("/");
61
+ return i === -1 ? "" : path.slice(0, i);
62
+ };
63
+
64
+ const baseOf = (path: string) => {
65
+ const i = path.lastIndexOf("/");
66
+ return i === -1 ? path : path.slice(i + 1);
67
+ };
68
+
69
+ /** Prefer the cleanest, shortest, oldest name as the surviving file. */
70
+ export function pickKeeper(paths: string[]): string {
71
+ return [...paths].sort((a, b) => {
72
+ const ta = TIMESTAMP_PREFIX.test(baseOf(a)) ? 1 : 0;
73
+ const tb = TIMESTAMP_PREFIX.test(baseOf(b)) ? 1 : 0;
74
+ if (ta !== tb) return ta - tb;
75
+ if (a.length !== b.length) return a.length - b.length;
76
+ return a < b ? -1 : 1;
77
+ })[0];
78
+ }
79
+
80
+ async function sha256(bytes: Uint8Array) {
81
+ const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer as ArrayBuffer);
82
+ return Array.from(new Uint8Array(digest))
83
+ .map((b) => b.toString(16).padStart(2, "0"))
84
+ .join("");
85
+ }
86
+
87
+ /** Never touch the bin. */
88
+ const isManaged = (path: string) => !path.startsWith("trash/");
89
+
90
+ /** Plan the renames of legacy timestamp-prefixed names among surviving files. */
91
+ export function planRenames(survivors: Iterable<string>): RenamePlan[] {
92
+ const takenByDir = new Map<string, Set<string>>();
93
+ for (const path of survivors) {
94
+ const set = takenByDir.get(dirOf(path)) ?? new Set<string>();
95
+ set.add(baseOf(path));
96
+ takenByDir.set(dirOf(path), set);
97
+ }
98
+
99
+ const renames: RenamePlan[] = [];
100
+ for (const path of Array.from(survivors).sort()) {
101
+ const base = baseOf(path);
102
+ if (!TIMESTAMP_PREFIX.test(base)) continue;
103
+ const dir = dirOf(path);
104
+ const taken = takenByDir.get(dir)!;
105
+ const { ext } = splitExt(base);
106
+ const cleaned = cleanFileName(base, "file");
107
+ const candidate = uniqueFileName(cleaned || `file${ext}`, taken);
108
+ if (candidate === base) continue;
109
+ taken.delete(base);
110
+ taken.add(candidate);
111
+ renames.push({ from: path, to: dir ? `${dir}/${candidate}` : candidate });
112
+ }
113
+ return renames;
114
+ }
115
+
116
+ export async function planMediaCleanup(storage: CleanupStorage): Promise<MediaCleanupPlan> {
117
+ const files = (await storage.list()).filter((f) => isManaged(f.name));
118
+
119
+ // Hash only files that share a byte size with at least one other file.
120
+ const bySize = new Map<number, string[]>();
121
+ for (const f of files) {
122
+ const list = bySize.get(f.size) ?? [];
123
+ list.push(f.name);
124
+ bySize.set(f.size, list);
125
+ }
126
+
127
+ const duplicates: DuplicateGroup[] = [];
128
+ let duplicateBytes = 0;
129
+ for (const [size, names] of bySize) {
130
+ if (names.length < 2 || size === 0) continue;
131
+ const byHash = new Map<string, string[]>();
132
+ for (const name of names) {
133
+ try {
134
+ const hash = await sha256(await storage.download(name));
135
+ const list = byHash.get(hash) ?? [];
136
+ list.push(name);
137
+ byHash.set(hash, list);
138
+ } catch {
139
+ /* unreadable object — leave it alone */
140
+ }
141
+ }
142
+ for (const [hash, group] of byHash) {
143
+ if (group.length < 2) continue;
144
+ const keep = pickKeeper(group);
145
+ const remove = group.filter((p) => p !== keep);
146
+ duplicates.push({ hash, size, keep, remove });
147
+ duplicateBytes += size * remove.length;
148
+ }
149
+ }
150
+
151
+ const survivors = new Set(files.map((f) => f.name));
152
+ for (const group of duplicates) for (const p of group.remove) survivors.delete(p);
153
+
154
+ return { duplicates, renames: planRenames(survivors), duplicateBytes, scanned: files.length };
155
+ }
156
+
157
+ export async function applyMediaCleanupPlan(
158
+ plan: MediaCleanupPlan,
159
+ options: { duplicates: boolean; renames: boolean },
160
+ host: CleanupHost,
161
+ ): Promise<MediaCleanupResult> {
162
+ const { storage } = host;
163
+ const result: MediaCleanupResult = {
164
+ merged: 0,
165
+ renamed: 0,
166
+ rewritten: 0,
167
+ freedBytes: 0,
168
+ errors: [],
169
+ };
170
+
171
+ if (options.duplicates) {
172
+ for (const group of plan.duplicates) {
173
+ const keeperUrl = storage.publicUrl(group.keep);
174
+ for (const dup of group.remove) {
175
+ try {
176
+ result.rewritten += await host.rewriteReferences(
177
+ [storage.publicUrl(dup), dup],
178
+ keeperUrl,
179
+ );
180
+ const trashId = crypto.randomUUID();
181
+ const trashPrefix = `trash/${trashId}`;
182
+ await storage.move(dup, `${trashPrefix}/${dup}`);
183
+ await host.trashDuplicate({
184
+ trashId,
185
+ path: dup,
186
+ trashPrefix,
187
+ size: group.size,
188
+ mergedInto: group.keep,
189
+ });
190
+ result.merged += 1;
191
+ result.freedBytes += group.size;
192
+ } catch (error) {
193
+ result.errors.push(`${dup}: ${error instanceof Error ? error.message : "failed"}`);
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ if (options.renames) {
200
+ for (const rename of plan.renames) {
201
+ try {
202
+ await storage.move(rename.from, rename.to);
203
+ result.rewritten += await host.rewriteReferences(
204
+ [storage.publicUrl(rename.from), rename.from],
205
+ storage.publicUrl(rename.to),
206
+ );
207
+ await host.movePathRows(rename.from, rename.to);
208
+ result.renamed += 1;
209
+ } catch (error) {
210
+ result.errors.push(`${rename.from}: ${error instanceof Error ? error.message : "failed"}`);
211
+ }
212
+ }
213
+ }
214
+
215
+ try {
216
+ await host.logCleanup?.(result);
217
+ } catch {
218
+ /* audit is best-effort */
219
+ }
220
+
221
+ return result;
222
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Host-agnostic page SEO logic: slug conflict detection and the automatic
3
+ * 301 redirects created when a page's slug changes.
4
+ */
5
+ import { normalizePath } from "./redirects-core";
6
+ import type { PageSlugRegistry } from "./redirects-core";
7
+
8
+ export type SlugConflict = { locale: string; pageKey: string };
9
+
10
+ /**
11
+ * No two pages may resolve to the same URL in the same locale.
12
+ * `existing` is the stored override map, `pending` the edit being saved.
13
+ */
14
+ export function detectSlugConflictsCore(input: {
15
+ pages: PageSlugRegistry;
16
+ locales: readonly string[];
17
+ existing: Record<string, Record<string, string>>;
18
+ pageKey: string;
19
+ slugs: Record<string, string>;
20
+ }): SlugConflict[] {
21
+ const pendingByKey = new Map<string, Record<string, string>>(Object.entries(input.existing));
22
+ pendingByKey.set(input.pageKey, input.slugs);
23
+
24
+ const conflicts: SlugConflict[] = [];
25
+ for (const locale of input.locales) {
26
+ const seen = new Map<string, string>();
27
+ for (const key of Object.keys(input.pages)) {
28
+ const base = input.pages[key].slug[locale];
29
+ const override = pendingByKey.get(key)?.[locale];
30
+ const effective = override ?? base;
31
+ if (effective === undefined) continue;
32
+ const existing = seen.get(effective);
33
+ if (existing && existing !== key) conflicts.push({ locale, pageKey: key });
34
+ else seen.set(effective, key);
35
+ }
36
+ }
37
+ return conflicts;
38
+ }
39
+
40
+ export type SlugRedirectRow = {
41
+ from_path: string;
42
+ to_path: string;
43
+ status: number;
44
+ created_by: string;
45
+ };
46
+
47
+ /** Redirect rows needed so old URLs keep working after a slug change. */
48
+ export function slugRedirectRows(input: {
49
+ locales: readonly string[];
50
+ previousSlugs: Record<string, string> | null;
51
+ registrySlugs: Record<string, string | undefined> | undefined;
52
+ nextSlugs: Record<string, string>;
53
+ userId: string;
54
+ }): SlugRedirectRow[] {
55
+ const rows: SlugRedirectRow[] = [];
56
+ for (const locale of input.locales) {
57
+ const before = input.previousSlugs?.[locale] ?? input.registrySlugs?.[locale];
58
+ const after = input.nextSlugs[locale];
59
+ if (before === undefined || after === undefined || before === after) continue;
60
+ const fromPath = before === "" ? "/" : normalizePath(`/${before}`);
61
+ const toPath = after === "" ? "/" : normalizePath(`/${after}`);
62
+ if (fromPath === toPath) continue;
63
+ rows.push({ from_path: fromPath, to_path: toPath, status: 301, created_by: input.userId });
64
+ }
65
+ return rows;
66
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Host-agnostic redirect logic.
3
+ *
4
+ * The host owns the server-function wrappers, validation and the client; this
5
+ * module owns path normalization, the CRUD queries and public path resolution
6
+ * so every site behaves identically.
7
+ */
8
+
9
+ /** Structural subset of a Supabase-shaped client used by these cores. */
10
+ export type RedirectClient = {
11
+ from: (table: string) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
12
+ };
13
+
14
+ export type RedirectRow = {
15
+ id: string;
16
+ from_path: string;
17
+ to_path: string;
18
+ status: number;
19
+ created_at: string;
20
+ };
21
+
22
+ export type PathResolution =
23
+ | { kind: "page"; pageKey: string }
24
+ | { kind: "redirect"; to: string; status: number }
25
+ | { kind: "notfound" };
26
+
27
+ /** Normalize any incoming path to `/` or `/segment/segment`. */
28
+ export function normalizePath(input: string): string {
29
+ const raw = (input || "/").split("?")[0].split("#")[0];
30
+ const trimmed = raw.replace(/\/+$/, "");
31
+ if (!trimmed) return "/";
32
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
33
+ }
34
+
35
+ /** Normalize a redirect target, leaving absolute URLs untouched. */
36
+ export function normalizeTarget(input: string): string {
37
+ return /^https?:\/\//i.test(input) ? input : normalizePath(input);
38
+ }
39
+
40
+ export async function listRedirectsCore(client: RedirectClient): Promise<RedirectRow[]> {
41
+ const { data, error } = await client
42
+ .from("redirects")
43
+ .select("id, from_path, to_path, status, created_at")
44
+ .order("created_at", { ascending: false });
45
+ if (error) throw new Error(error.message);
46
+ return (data ?? []) as RedirectRow[];
47
+ }
48
+
49
+ export async function saveRedirectCore(
50
+ client: RedirectClient,
51
+ input: { id?: string; from_path: string; to_path: string; status: number },
52
+ userId: string,
53
+ ): Promise<{ ok: true }> {
54
+ const row = {
55
+ from_path: normalizePath(input.from_path),
56
+ to_path: normalizeTarget(input.to_path),
57
+ status: input.status,
58
+ };
59
+ if (row.from_path === row.to_path) throw new Error("Source and target are identical");
60
+ const { error } = input.id
61
+ ? await client.from("redirects").update(row).eq("id", input.id)
62
+ : await client.from("redirects").insert({ ...row, created_by: userId });
63
+ if (error) throw new Error(error.message);
64
+ return { ok: true };
65
+ }
66
+
67
+ export async function deleteRedirectCore(
68
+ client: RedirectClient,
69
+ id: string,
70
+ ): Promise<{ ok: true }> {
71
+ const { error } = await client.from("redirects").delete().eq("id", id);
72
+ if (error) throw new Error(error.message);
73
+ return { ok: true };
74
+ }
75
+
76
+ /** Slug map of a page in the host's static page registry. */
77
+ export type PageSlugRegistry = Record<string, { slug: Record<string, string | undefined> }>;
78
+
79
+ /**
80
+ * Resolve a URL path to a CMS page key, a redirect, or nothing.
81
+ * Slug overrides from `page_seo` win over the static registry; builder-created
82
+ * pages resolve by bare key through the `lookupPageByKey` callback.
83
+ */
84
+ export async function resolvePathCore(
85
+ path: string,
86
+ deps: {
87
+ client: RedirectClient;
88
+ pages: PageSlugRegistry;
89
+ locales: readonly string[];
90
+ lookupPageByKey: (key: string) => Promise<string | null>;
91
+ },
92
+ ): Promise<PathResolution> {
93
+ const target = normalizePath(path);
94
+ const bySlug = new Map<string, string>();
95
+ const put = (slug: string | undefined, key: string) => {
96
+ if (slug === undefined || slug === null) return;
97
+ const p = slug === "" ? "/" : `/${slug.replace(/^\/+|\/+$/g, "")}`;
98
+ if (!bySlug.has(p)) bySlug.set(p, key);
99
+ };
100
+
101
+ const { data: seoRows } = await deps.client.from("page_seo").select("page_key, slugs");
102
+ const overrides = new Map<string, Record<string, string>>();
103
+ for (const row of (seoRows ?? []) as { page_key: string; slugs: unknown }[]) {
104
+ overrides.set(row.page_key, (row.slugs as Record<string, string>) ?? {});
105
+ }
106
+ for (const [key, slugs] of overrides) {
107
+ for (const locale of deps.locales) put(slugs[locale], key);
108
+ }
109
+ for (const key of Object.keys(deps.pages)) {
110
+ if (overrides.has(key)) continue;
111
+ for (const locale of deps.locales) put(deps.pages[key].slug[locale], key);
112
+ }
113
+
114
+ let pageKey = bySlug.get(target);
115
+ if (!pageKey) {
116
+ const candidate = target.replace(/^\//, "");
117
+ if (/^[a-z0-9_-]+$/.test(candidate)) {
118
+ pageKey = (await deps.lookupPageByKey(candidate)) ?? undefined;
119
+ }
120
+ }
121
+ if (pageKey) return { kind: "page", pageKey };
122
+
123
+ const { data: hit } = await deps.client
124
+ .from("redirects")
125
+ .select("to_path, status")
126
+ .eq("from_path", target)
127
+ .maybeSingle();
128
+ if (hit) return { kind: "redirect", to: hit.to_path, status: hit.status ?? 301 };
129
+
130
+ return { kind: "notfound" };
131
+ }