@bygghammar/cms-platform 0.31.0 → 0.32.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
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,
@@ -137,7 +137,11 @@
137
137
  "./backoffice/PagesListView": "./src/backoffice/PagesListView.tsx",
138
138
  "./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx",
139
139
  "./backoffice/SharedContentListView": "./src/backoffice/SharedContentListView.tsx",
140
- "./backoffice/seo/SeoListView": "./src/backoffice/seo/SeoListView.tsx"
140
+ "./backoffice/seo/SeoListView": "./src/backoffice/seo/SeoListView.tsx",
141
+ "./server/audit-core": "./src/server/audit-core.ts",
142
+ "./server/review-image-validate": "./src/server/review-image-validate.ts",
143
+ "./server/media-library-core": "./src/server/media-library-core.ts",
144
+ "./server/admin-core": "./src/server/admin-core.ts"
141
145
  },
142
146
  "dependencies": {
143
147
  "fflate": "^0.8.3"
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Host-agnostic administration server logic: users, roles, log browsing, email
3
+ * settings and email providers.
4
+ *
5
+ * Authorization always runs through the host's own `is_staff` / `has_role`
6
+ * database functions against the authenticated client, so row-level security
7
+ * applies as the signed-in user. Privileged operations (creating auth users,
8
+ * writing roles, storing provider config) take the privileged client the host
9
+ * supplies, and only after the role check above has passed.
10
+ *
11
+ * Every mutation writes an audit entry. Sending mail is injected by the host so
12
+ * this module stays free of transport code.
13
+ */
14
+ import type { SupabaseClient } from "@supabase/supabase-js";
15
+
16
+ import { writeAudit } from "./audit-core";
17
+ import { actorEmail, assertRole, assertStaff, type PlatformAuthContext } from "./guards";
18
+
19
+ /* eslint-disable @typescript-eslint/no-explicit-any */
20
+
21
+ export const ALL_PLATFORM_ROLES = ["superadmin", "admin", "editor", "seo", "user"] as const;
22
+ export type PlatformRole = (typeof ALL_PLATFORM_ROLES)[number];
23
+
24
+ export const PROVIDER_KINDS = ["smtp", "ses", "lovable", "mailgun", "http"] as const;
25
+ export type ProviderKind = (typeof PROVIDER_KINDS)[number];
26
+
27
+ export type AdminDeps = {
28
+ context: PlatformAuthContext & { supabase: SupabaseClient };
29
+ admin: () => Promise<SupabaseClient>;
30
+ };
31
+
32
+ export type AdminUser = {
33
+ id: string;
34
+ email: string;
35
+ created_at: string;
36
+ last_sign_in_at: string | null;
37
+ email_confirmed_at: string | null;
38
+ roles: PlatformRole[];
39
+ };
40
+
41
+ const anyClient = (client: SupabaseClient) => client as unknown as any;
42
+
43
+ async function audit(
44
+ deps: AdminDeps,
45
+ action: string,
46
+ entity: string,
47
+ entityId?: string,
48
+ meta: Record<string, unknown> = {},
49
+ ) {
50
+ const admin = await deps.admin();
51
+ await writeAudit(admin as never, deps.context, {
52
+ action,
53
+ entity,
54
+ entityId: entityId ?? null,
55
+ meta,
56
+ });
57
+ }
58
+
59
+ /* ============================= USERS ============================= */
60
+
61
+ /** Accounts with their roles. Staff may read; only superadmins may mutate. */
62
+ export async function listUsersCore(deps: AdminDeps): Promise<AdminUser[]> {
63
+ await assertStaff(deps.context);
64
+ const admin = await deps.admin();
65
+ const { data, error } = await admin.auth.admin.listUsers({ page: 1, perPage: 200 });
66
+ if (error) throw new Error(error.message);
67
+
68
+ const ids = data.users.map((u) => u.id);
69
+ const { data: roleRows } = await anyClient(admin)
70
+ .from("user_roles")
71
+ .select("user_id, role")
72
+ .in("user_id", ids.length ? ids : ["00000000-0000-0000-0000-000000000000"]);
73
+
74
+ const rolesByUser = new Map<string, PlatformRole[]>();
75
+ for (const r of ((roleRows ?? []) as any[]) ?? []) {
76
+ const arr = rolesByUser.get(r.user_id) ?? [];
77
+ arr.push(r.role as PlatformRole);
78
+ rolesByUser.set(r.user_id, arr);
79
+ }
80
+
81
+ return data.users.map((u) => ({
82
+ id: u.id,
83
+ email: u.email ?? "",
84
+ created_at: u.created_at,
85
+ last_sign_in_at: u.last_sign_in_at ?? null,
86
+ email_confirmed_at: u.email_confirmed_at ?? null,
87
+ roles: rolesByUser.get(u.id) ?? [],
88
+ }));
89
+ }
90
+
91
+ export async function createUserCore(
92
+ deps: AdminDeps,
93
+ input: { email: string; password?: string; roles?: PlatformRole[] },
94
+ ): Promise<{ id: string }> {
95
+ await assertRole(deps.context, "superadmin");
96
+ const admin = await deps.admin();
97
+ const roles = input.roles ?? [];
98
+ const { data: created, error } = await admin.auth.admin.createUser({
99
+ email: input.email,
100
+ ...(input.password ? { password: input.password } : {}),
101
+ email_confirm: true,
102
+ });
103
+ if (error) throw new Error(error.message);
104
+ const uid = created.user!.id;
105
+ if (roles.length) {
106
+ await anyClient(admin)
107
+ .from("user_roles")
108
+ .insert(roles.map((role) => ({ user_id: uid, role })));
109
+ }
110
+ await audit(deps, "user.create", "auth.users", uid, { email: input.email, roles });
111
+ return { id: uid };
112
+ }
113
+
114
+ export async function deleteUserCore(
115
+ deps: AdminDeps,
116
+ input: { userId: string },
117
+ ): Promise<{ ok: true }> {
118
+ await assertRole(deps.context, "superadmin");
119
+ if (input.userId === deps.context.userId) throw new Error("Cannot delete yourself");
120
+ const admin = await deps.admin();
121
+ const { error } = await admin.auth.admin.deleteUser(input.userId);
122
+ if (error) throw new Error(error.message);
123
+ await audit(deps, "user.delete", "auth.users", input.userId);
124
+ return { ok: true };
125
+ }
126
+
127
+ export async function setUserPasswordCore(
128
+ deps: AdminDeps,
129
+ input: { userId: string; password: string },
130
+ ): Promise<{ ok: true }> {
131
+ await assertRole(deps.context, "superadmin");
132
+ const admin = await deps.admin();
133
+ const { error } = await admin.auth.admin.updateUserById(input.userId, {
134
+ password: input.password,
135
+ });
136
+ if (error) throw new Error(error.message);
137
+ await audit(deps, "user.password_reset", "auth.users", input.userId);
138
+ return { ok: true };
139
+ }
140
+
141
+ export async function sendPasswordResetEmailCore(
142
+ deps: AdminDeps,
143
+ input: { email: string },
144
+ ): Promise<{ ok: true }> {
145
+ await assertRole(deps.context, "superadmin");
146
+ const admin = await deps.admin();
147
+ const { error } = await admin.auth.admin.generateLink({
148
+ type: "recovery",
149
+ email: input.email,
150
+ });
151
+ if (error) throw new Error(error.message);
152
+ await audit(deps, "user.send_reset", "auth.users", undefined, { email: input.email });
153
+ return { ok: true };
154
+ }
155
+
156
+ /* ============================= ROLES ============================= */
157
+
158
+ /** Replace a user's role set. A superadmin can never strip their own access. */
159
+ export async function setUserRolesCore(
160
+ deps: AdminDeps,
161
+ input: { userId: string; roles: PlatformRole[] },
162
+ ): Promise<{ ok: true }> {
163
+ await assertRole(deps.context, "superadmin");
164
+ if (input.userId === deps.context.userId && !input.roles.includes("superadmin")) {
165
+ throw new Error("Cannot remove your own superadmin role");
166
+ }
167
+ const admin = await deps.admin();
168
+ await anyClient(admin).from("user_roles").delete().eq("user_id", input.userId);
169
+ if (input.roles.length) {
170
+ const rows = Array.from(new Set(input.roles)).map((role) => ({
171
+ user_id: input.userId,
172
+ role,
173
+ }));
174
+ const { error } = await anyClient(admin).from("user_roles").insert(rows);
175
+ if (error) throw new Error(error.message);
176
+ }
177
+ await audit(deps, "user.set_roles", "user_roles", input.userId, { roles: input.roles });
178
+ return { ok: true };
179
+ }
180
+
181
+ /** Roles of the signed-in viewer; read as the user, so no privileged client. */
182
+ export async function getMyRolesCore(deps: AdminDeps): Promise<PlatformRole[]> {
183
+ const { data, error } = await anyClient(deps.context.supabase)
184
+ .from("user_roles")
185
+ .select("role")
186
+ .eq("user_id", deps.context.userId);
187
+ if (error) throw new Error(error.message);
188
+ return ((data ?? []) as any[]).map((r) => r.role as PlatformRole);
189
+ }
190
+
191
+ /* ========================= LOG BROWSING ========================= */
192
+
193
+ async function listLog(deps: AdminDeps, table: string, limit: number) {
194
+ await assertStaff(deps.context);
195
+ const { data, error } = await anyClient(deps.context.supabase)
196
+ .from(table)
197
+ .select("*")
198
+ .order("created_at", { ascending: false })
199
+ .limit(limit);
200
+ if (error) throw new Error(error.message);
201
+ return (data ?? []) as Record<string, unknown>[];
202
+ }
203
+
204
+ export function listAuditLogsCore(deps: AdminDeps, input?: { limit?: number }) {
205
+ return listLog(deps, "audit_logs", input?.limit ?? 200);
206
+ }
207
+
208
+ export function listEmailLogsCore(deps: AdminDeps, input?: { limit?: number }) {
209
+ return listLog(deps, "email_logs", input?.limit ?? 200);
210
+ }
211
+
212
+ /* ======================== EMAIL SETTINGS ======================== */
213
+
214
+ export async function getEmailSettingsCore(deps: AdminDeps) {
215
+ await assertStaff(deps.context);
216
+ const { data, error } = await anyClient(deps.context.supabase)
217
+ .from("email_settings")
218
+ .select("*")
219
+ .eq("id", true)
220
+ .maybeSingle();
221
+ if (error) throw new Error(error.message);
222
+ return data as Record<string, unknown> | null;
223
+ }
224
+
225
+ export async function saveEmailSettingsCore(
226
+ deps: AdminDeps,
227
+ input: Record<string, unknown>,
228
+ ): Promise<{ ok: true }> {
229
+ await assertRole(deps.context, "superadmin");
230
+ const admin = await deps.admin();
231
+ const { error } = await anyClient(admin)
232
+ .from("email_settings")
233
+ .upsert({ id: true, ...input, updated_by: deps.context.userId }, { onConflict: "id" });
234
+ if (error) throw new Error(error.message);
235
+ await audit(deps, "email_settings.update", "email_settings", "singleton");
236
+ return { ok: true };
237
+ }
238
+
239
+ /* ======================= EMAIL PROVIDERS ======================= */
240
+
241
+ export async function listEmailProvidersCore(deps: AdminDeps) {
242
+ await assertStaff(deps.context);
243
+ const { data, error } = await anyClient(deps.context.supabase)
244
+ .from("email_providers")
245
+ .select("*")
246
+ .order("sort_order", { ascending: true })
247
+ .order("created_at", { ascending: true });
248
+ if (error) throw new Error(error.message);
249
+ return (data ?? []) as Record<string, unknown>[];
250
+ }
251
+
252
+ export async function upsertEmailProviderCore(
253
+ deps: AdminDeps,
254
+ input: Record<string, unknown> & { id?: string; name: string; kind: ProviderKind },
255
+ ) {
256
+ await assertRole(deps.context, "superadmin");
257
+ const admin = await deps.admin();
258
+ const row = { ...input, updated_by: deps.context.userId };
259
+ const table = anyClient(admin).from("email_providers");
260
+ const query = input.id
261
+ ? table.update(row).eq("id", input.id).select("*").single()
262
+ : table.insert(row).select("*").single();
263
+ const { data: saved, error } = await query;
264
+ if (error) throw new Error(error.message);
265
+ await audit(
266
+ deps,
267
+ input.id ? "email_provider.update" : "email_provider.create",
268
+ "email_providers",
269
+ (saved as any).id,
270
+ { name: input.name, kind: input.kind },
271
+ );
272
+ return saved as Record<string, unknown>;
273
+ }
274
+
275
+ export async function deleteEmailProviderCore(
276
+ deps: AdminDeps,
277
+ input: { id: string },
278
+ ): Promise<{ ok: true }> {
279
+ await assertRole(deps.context, "superadmin");
280
+ const admin = await deps.admin();
281
+ const { error } = await anyClient(admin).from("email_providers").delete().eq("id", input.id);
282
+ if (error) throw new Error(error.message);
283
+ await audit(deps, "email_provider.delete", "email_providers", input.id);
284
+ return { ok: true };
285
+ }
286
+
287
+ /** Transport the host injects, so this module carries no SMTP/API client. */
288
+ export type ProviderSender = (
289
+ provider: {
290
+ id: string;
291
+ name: string;
292
+ kind: string;
293
+ from_name: string | null;
294
+ from_email: string;
295
+ reply_to: string | null;
296
+ config: Record<string, unknown>;
297
+ secret_ref_name: string | null;
298
+ },
299
+ message: {
300
+ to: string;
301
+ subject: string;
302
+ html: string;
303
+ text: string;
304
+ fromName: string | null;
305
+ fromEmail: string;
306
+ replyTo: string | null;
307
+ },
308
+ ) => Promise<{ ok: true } | { ok: false; error: string }>;
309
+
310
+ /**
311
+ * Send a probe message through one provider and log the outcome either way, so
312
+ * a misconfigured provider is visible in the email log rather than silent.
313
+ */
314
+ export async function sendTestEmailCore(
315
+ deps: AdminDeps,
316
+ input: { provider_id: string; to: string },
317
+ send: ProviderSender,
318
+ ): Promise<{ ok: true }> {
319
+ await assertRole(deps.context, "superadmin");
320
+ const admin = await deps.admin();
321
+
322
+ const { data: provider, error: pErr } = await anyClient(admin)
323
+ .from("email_providers")
324
+ .select("*")
325
+ .eq("id", input.provider_id)
326
+ .single();
327
+ if (pErr || !provider) throw new Error(pErr?.message ?? "Provider not found");
328
+
329
+ const { data: settings } = await anyClient(admin)
330
+ .from("email_settings")
331
+ .select("*")
332
+ .eq("id", true)
333
+ .maybeSingle();
334
+
335
+ const fromEmail = provider.from_email ?? settings?.primary_from_email ?? null;
336
+ const fromName = provider.from_name ?? settings?.primary_from_name ?? null;
337
+ const replyTo = provider.reply_to ?? settings?.reply_to ?? null;
338
+ const subject = `Test email from ${provider.name}`;
339
+ const text = `This is a test email sent from the backoffice through the provider "${provider.name}" (${provider.kind}).`;
340
+ const html = `<p>${text}</p><p style="color:#666;font-size:12px">Sent ${new Date().toISOString()}</p>`;
341
+
342
+ const result = fromEmail
343
+ ? await send(
344
+ {
345
+ id: provider.id,
346
+ name: provider.name,
347
+ kind: provider.kind,
348
+ from_name: fromName,
349
+ from_email: fromEmail,
350
+ reply_to: replyTo,
351
+ config: (provider.config ?? {}) as Record<string, unknown>,
352
+ secret_ref_name: provider.secret_ref_name ?? null,
353
+ },
354
+ { to: input.to, subject, html, text, fromName, fromEmail, replyTo },
355
+ )
356
+ : ({ ok: false, error: "No from address configured for this provider or in the email settings." } as const);
357
+
358
+ await anyClient(admin)
359
+ .from("email_logs")
360
+ .insert({
361
+ to_address: input.to,
362
+ from_address: fromEmail,
363
+ reply_to: replyTo,
364
+ subject,
365
+ body_preview: text.slice(0, 200),
366
+ provider: provider.name,
367
+ status: result.ok ? "sent" : "failed",
368
+ error: result.ok ? null : result.error,
369
+ meta: { kind: provider.kind, provider_id: provider.id, test: true },
370
+ });
371
+
372
+ await audit(deps, "email_provider.test_send", "email_providers", provider.id, {
373
+ to: input.to,
374
+ ok: result.ok,
375
+ error: result.ok ? null : result.error,
376
+ });
377
+
378
+ if (!result.ok) throw new Error(result.error);
379
+ return { ok: true };
380
+ }
381
+
382
+ /** Actor email helper re-exported so hosts don't need the guards module too. */
383
+ export { actorEmail };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Host-agnostic audit trail writer.
3
+ *
4
+ * Every platform core records what an editor did in the append-only
5
+ * `audit_logs` table. The host passes a privileged client because the trail must
6
+ * be written even when the acting user cannot read it back, and the entry always
7
+ * carries the acting user's id and email so the log stays attributable.
8
+ *
9
+ * Auditing is deliberately best-effort: a failed log entry never fails the
10
+ * operation the editor asked for.
11
+ */
12
+ import type { PlatformAuthContext } from "./guards";
13
+ import { actorEmail } from "./guards";
14
+
15
+ /* eslint-disable @typescript-eslint/no-explicit-any */
16
+
17
+ /** Minimal privileged-client shape the audit writer needs. */
18
+ export type AuditClient = {
19
+ from: (table: string) => { insert: (row: any) => PromiseLike<{ error: any }> };
20
+ };
21
+
22
+ export type AuditEntry = {
23
+ action: string;
24
+ entity?: string | null;
25
+ entityId?: string | null;
26
+ meta?: Record<string, unknown>;
27
+ };
28
+
29
+ /** Write one audit entry. Never throws — logging must not break the action. */
30
+ export async function writeAudit(
31
+ admin: AuditClient,
32
+ context: PlatformAuthContext,
33
+ entry: AuditEntry,
34
+ ): Promise<void> {
35
+ try {
36
+ const { error } = await admin.from("audit_logs").insert({
37
+ actor_id: context.userId,
38
+ actor_email: actorEmail(context),
39
+ action: entry.action,
40
+ entity: entry.entity ?? null,
41
+ entity_id: entry.entityId ?? null,
42
+ meta: (entry.meta ?? {}) as unknown,
43
+ });
44
+ if (error) console.warn("audit failed", error.message);
45
+ } catch (err) {
46
+ console.warn("audit failed", err);
47
+ }
48
+ }
49
+
50
+ /** Curried writer for a single entity kind, e.g. `media` or `page`. */
51
+ export function auditWriter(admin: AuditClient, context: PlatformAuthContext, entity: string) {
52
+ return (action: string, entityId: string, meta: Record<string, unknown> = {}) =>
53
+ writeAudit(admin, context, { action, entity, entityId, meta });
54
+ }
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Host-agnostic media library server logic.
3
+ *
4
+ * Owns listing, soft delete into a recoverable bin, restore, purge, uploads and
5
+ * the bulk-overwrite helpers. The host supplies two clients — the authenticated
6
+ * one for reads (so row-level security applies as the signed-in user) and a
7
+ * privileged one for the storage moves and bin bookkeeping — and nothing else.
8
+ *
9
+ * Nothing here hard-deletes an asset except the explicit purge paths, which the
10
+ * host must gate on a superadmin check before calling.
11
+ */
12
+ import type { SupabaseClient } from "@supabase/supabase-js";
13
+
14
+ import { getMediaStorage } from "../media/storage";
15
+ import { isSecondaryVariant, variantSiblings } from "../media/srcset";
16
+ import { uniqueFileName } from "../media/filename";
17
+ import { auditWriter } from "./audit-core";
18
+ import type { PlatformAuthContext } from "./guards";
19
+ import {
20
+ REVIEW_WIDTHS,
21
+ sanitizeReviewSlug,
22
+ validateReviewVariant,
23
+ } from "./review-image-validate";
24
+
25
+ /* eslint-disable @typescript-eslint/no-explicit-any */
26
+
27
+ /** What every core call needs from the host. */
28
+ export type MediaLibraryDeps = {
29
+ /** Request context of the host's auth middleware (RLS as the user). */
30
+ context: PlatformAuthContext & { supabase: SupabaseClient };
31
+ /** Privileged client, used only for storage moves and bin rows. */
32
+ admin: () => Promise<SupabaseClient>;
33
+ };
34
+
35
+ export type MediaItem = {
36
+ name: string;
37
+ path: string;
38
+ size: number;
39
+ contentType: string | null;
40
+ updatedAt: string | null;
41
+ url: string;
42
+ uploadedBy?: string | null;
43
+ uploadedAt?: string | null;
44
+ };
45
+
46
+ export type TrashItem = {
47
+ id: string;
48
+ originalPath: string;
49
+ paths: string[];
50
+ trashPrefix: string;
51
+ size: number;
52
+ contentType: string | null;
53
+ deletedAt: string;
54
+ deletedByEmail: string | null;
55
+ usageCount: number;
56
+ url: string;
57
+ };
58
+
59
+ export type UploadMediaFile = {
60
+ name: string;
61
+ contentType: string;
62
+ bytes: number[];
63
+ };
64
+
65
+ const anyClient = (client: SupabaseClient) => client as unknown as any;
66
+
67
+ /** All root-level file names currently in the library. */
68
+ export async function listMediaNames(deps: MediaLibraryDeps): Promise<string[]> {
69
+ const storage = getMediaStorage(deps.context.supabase);
70
+ return (await storage.list()).map((f) => f.name);
71
+ }
72
+
73
+ /**
74
+ * Who uploaded what, taken from the append-only audit trail.
75
+ * Older assets predate upload auditing and simply have no entry.
76
+ */
77
+ async function uploaderIndex(
78
+ deps: MediaLibraryDeps,
79
+ ): Promise<Record<string, { email: string | null; at: string }>> {
80
+ const map: Record<string, { email: string | null; at: string }> = {};
81
+ try {
82
+ const { data, error } = await anyClient(deps.context.supabase)
83
+ .from("audit_logs")
84
+ .select("actor_email, action, meta, created_at")
85
+ .eq("entity", "media")
86
+ .in("action", ["media.upload", "media.import"])
87
+ .order("created_at", { ascending: true })
88
+ .limit(5000);
89
+ if (error) return map;
90
+ for (const row of (data ?? []) as any[]) {
91
+ const paths: unknown = (row.meta as Record<string, unknown> | null)?.["paths"];
92
+ const list = Array.isArray(paths) ? paths : [];
93
+ for (const p of list) {
94
+ if (typeof p !== "string") continue;
95
+ map[p] = { email: row.actor_email ?? null, at: row.created_at };
96
+ }
97
+ }
98
+ } catch {
99
+ /* audit lookup is best-effort */
100
+ }
101
+ return map;
102
+ }
103
+
104
+ async function audit(
105
+ deps: MediaLibraryDeps,
106
+ action: string,
107
+ entityId: string,
108
+ meta: Record<string, unknown>,
109
+ ) {
110
+ const admin = await deps.admin();
111
+ await auditWriter(admin as never, deps.context, "media")(action, entityId, meta);
112
+ }
113
+
114
+ /** Library listing with the smaller responsive variants folded away. */
115
+ export async function listMediaCore(deps: MediaLibraryDeps): Promise<MediaItem[]> {
116
+ const storage = getMediaStorage(deps.context.supabase);
117
+ const files = await storage.list();
118
+ const names = new Set(files.map((f) => f.name));
119
+ const visible = files.filter((f) => {
120
+ if (isSecondaryVariant(f.name)) return false;
121
+ const m = /^(.*)-1920\.webp$/i.exec(f.name);
122
+ if (m && names.has(`${m[1]}-2560.webp`)) return false;
123
+ return true;
124
+ });
125
+ const uploaders = await uploaderIndex(deps);
126
+ return visible.map((f) => {
127
+ const record =
128
+ uploaders[f.name] ??
129
+ variantSiblings(f.name)
130
+ .map((s) => uploaders[s])
131
+ .find(Boolean) ??
132
+ null;
133
+ return {
134
+ name: f.name,
135
+ path: f.name,
136
+ size: f.size,
137
+ contentType: f.contentType,
138
+ updatedAt: f.updatedAt,
139
+ url: storage.publicUrl(f.name),
140
+ uploadedBy: record?.email ?? null,
141
+ uploadedAt: record?.at ?? f.updatedAt,
142
+ };
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Soft delete: move the primary file and all responsive variants into a hidden
148
+ * `trash/<id>/…` prefix and record the deletion. Nothing is erased here.
149
+ */
150
+ export async function deleteMediaCore(
151
+ deps: MediaLibraryDeps,
152
+ input: { path: string },
153
+ ): Promise<{ ok: true; trashId: string }> {
154
+ const admin = await deps.admin();
155
+ const storage = getMediaStorage(admin);
156
+ const trashId = crypto.randomUUID();
157
+ const prefix = `trash/${trashId}`;
158
+
159
+ const candidates = [input.path, ...variantSiblings(input.path)];
160
+ const names = new Set(await listMediaNames(deps));
161
+ const targets = candidates.filter((p) => names.has(p));
162
+ if (targets.length === 0) throw new Error("File not found");
163
+
164
+ const listed = await storage.list();
165
+ const primaryMeta = listed.find((f) => f.name === input.path);
166
+
167
+ const moved: string[] = [];
168
+ try {
169
+ for (const p of targets) {
170
+ await storage.move(p, `${prefix}/${p}`);
171
+ moved.push(p);
172
+ }
173
+ } catch (err) {
174
+ for (const p of moved) {
175
+ await storage.move(`${prefix}/${p}`, p).catch(() => {});
176
+ }
177
+ throw err;
178
+ }
179
+
180
+ const { error: insErr } = await anyClient(admin)
181
+ .from("media_trash")
182
+ .insert({
183
+ id: trashId,
184
+ original_path: input.path,
185
+ paths: moved,
186
+ trash_prefix: prefix,
187
+ size_bytes: primaryMeta?.size ?? 0,
188
+ content_type: primaryMeta?.contentType ?? null,
189
+ deleted_by: deps.context.userId,
190
+ deleted_by_email: deps.context.claims?.email ?? null,
191
+ });
192
+ if (insErr) {
193
+ for (const p of moved) {
194
+ await storage.move(`${prefix}/${p}`, p).catch(() => {});
195
+ }
196
+ throw new Error(insErr.message);
197
+ }
198
+
199
+ await audit(deps, "media.trash", input.path, { files: moved.length });
200
+ return { ok: true, trashId };
201
+ }
202
+
203
+ /** Bin listing — staff may read it; only superadmins may purge. */
204
+ export async function listTrashCore(deps: MediaLibraryDeps): Promise<TrashItem[]> {
205
+ const storage = getMediaStorage(deps.context.supabase);
206
+ const { data, error } = await anyClient(deps.context.supabase)
207
+ .from("media_trash")
208
+ .select("*")
209
+ .order("deleted_at", { ascending: false })
210
+ .limit(500);
211
+ if (error) throw new Error(error.message);
212
+ return ((data ?? []) as any[]).map((r) => ({
213
+ id: r.id,
214
+ originalPath: r.original_path,
215
+ paths: (r.paths ?? []) as string[],
216
+ trashPrefix: r.trash_prefix,
217
+ size: Number(r.size_bytes ?? 0),
218
+ contentType: r.content_type,
219
+ deletedAt: r.deleted_at,
220
+ deletedByEmail: r.deleted_by_email,
221
+ usageCount: Array.isArray(r.usage_snapshot) ? r.usage_snapshot.length : 0,
222
+ url: storage.publicUrl(`${r.trash_prefix}/${r.original_path}`),
223
+ }));
224
+ }
225
+
226
+ /** Restore every variant back to its original path, unless a name collision exists. */
227
+ export async function restoreMediaCore(
228
+ deps: MediaLibraryDeps,
229
+ input: { id: string },
230
+ ): Promise<{ ok: true }> {
231
+ const admin = await deps.admin();
232
+ const storage = getMediaStorage(admin);
233
+ const { data: row, error } = await anyClient(admin)
234
+ .from("media_trash")
235
+ .select("*")
236
+ .eq("id", input.id)
237
+ .maybeSingle();
238
+ if (error) throw new Error(error.message);
239
+ if (!row) throw new Error("Bin entry not found");
240
+
241
+ const paths = (row.paths ?? []) as string[];
242
+ const prefix = row.trash_prefix as string;
243
+
244
+ const names = new Set(await listMediaNames(deps));
245
+ const clash = paths.find((p) => names.has(p));
246
+ if (clash) {
247
+ throw new Error(
248
+ `Cannot restore — a file named "${clash}" already exists in the library. Rename or remove it first.`,
249
+ );
250
+ }
251
+
252
+ const restored: string[] = [];
253
+ try {
254
+ for (const p of paths) {
255
+ await storage.move(`${prefix}/${p}`, p);
256
+ restored.push(p);
257
+ }
258
+ } catch (err) {
259
+ for (const p of restored) {
260
+ await storage.move(p, `${prefix}/${p}`).catch(() => {});
261
+ }
262
+ throw err;
263
+ }
264
+
265
+ await anyClient(admin).from("media_trash").delete().eq("id", input.id);
266
+ await audit(deps, "media.restore", row.original_path, { files: restored.length });
267
+ return { ok: true };
268
+ }
269
+
270
+ /** Permanent delete of one bin entry. Host must have asserted superadmin. */
271
+ export async function purgeMediaCore(
272
+ deps: MediaLibraryDeps,
273
+ input: { id: string },
274
+ ): Promise<{ ok: true }> {
275
+ const admin = await deps.admin();
276
+ const storage = getMediaStorage(admin);
277
+ const { data: row, error } = await anyClient(admin)
278
+ .from("media_trash")
279
+ .select("*")
280
+ .eq("id", input.id)
281
+ .maybeSingle();
282
+ if (error) throw new Error(error.message);
283
+ if (!row) throw new Error("Bin entry not found");
284
+
285
+ const paths = (row.paths ?? []) as string[];
286
+ const prefix = row.trash_prefix as string;
287
+ await storage.remove(paths.map((p) => `${prefix}/${p}`));
288
+
289
+ const { error: delErr } = await anyClient(admin)
290
+ .from("media_trash")
291
+ .delete()
292
+ .eq("id", input.id);
293
+ if (delErr) throw new Error(delErr.message);
294
+
295
+ await audit(deps, "media.purge", row.original_path, { files: paths.length });
296
+ return { ok: true };
297
+ }
298
+
299
+ /** Empty the whole bin. Host must have asserted superadmin. */
300
+ export async function emptyTrashCore(
301
+ deps: MediaLibraryDeps,
302
+ ): Promise<{ ok: true; purged: number }> {
303
+ const admin = await deps.admin();
304
+ const storage = getMediaStorage(admin);
305
+ const { data: rows, error } = await anyClient(admin).from("media_trash").select("*");
306
+ if (error) throw new Error(error.message);
307
+ let purged = 0;
308
+ for (const row of (rows ?? []) as any[]) {
309
+ const paths = (row.paths ?? []) as string[];
310
+ const prefix = row.trash_prefix as string;
311
+ await storage.remove(paths.map((p) => `${prefix}/${p}`));
312
+ await anyClient(admin).from("media_trash").delete().eq("id", row.id);
313
+ purged++;
314
+ }
315
+ await audit(deps, "media.empty_trash", "bin", { entries: purged });
316
+ return { ok: true, purged };
317
+ }
318
+
319
+ /** Public URL for one stored path. */
320
+ export function signMediaUrlCore(
321
+ deps: MediaLibraryDeps,
322
+ input: { path: string },
323
+ ): { url: string } {
324
+ return { url: getMediaStorage(deps.context.supabase).publicUrl(input.path) };
325
+ }
326
+
327
+ /**
328
+ * Upload one or more already-optimized files. A name clash retries with a
329
+ * `-2`, `-3`, … suffix inserted before the responsive width, so a whole bundle
330
+ * keeps one consistent base name (`hero-2-1920.webp`).
331
+ */
332
+ export async function uploadMediaBundleCore(
333
+ deps: MediaLibraryDeps,
334
+ input: { files: UploadMediaFile[]; upsert?: boolean },
335
+ ): Promise<{ urls: string[] }> {
336
+ const storage = getMediaStorage(deps.context.supabase);
337
+ const upsert = input.upsert ?? false;
338
+
339
+ const withSuffix = (name: string, n: number) => {
340
+ if (n <= 1) return name;
341
+ const m = /^(.*?)(-(?:480|768|1200|1920|2560))?(\.[^.]+)$/.exec(name);
342
+ if (!m) return `${name}-${n}`;
343
+ return `${m[1]}-${n}${m[2] ?? ""}${m[3]}`;
344
+ };
345
+
346
+ let uploaded: string[] = [];
347
+ for (let attempt = 1; attempt <= 20; attempt++) {
348
+ uploaded = [];
349
+ try {
350
+ for (const f of input.files) {
351
+ const name = withSuffix(f.name, attempt);
352
+ await storage.upload(name, new Uint8Array(f.bytes), f.contentType, { upsert });
353
+ uploaded.push(name);
354
+ }
355
+ break;
356
+ } catch (err) {
357
+ await storage.remove(uploaded).catch(() => {});
358
+ uploaded = [];
359
+ const msg = err instanceof Error ? err.message : String(err);
360
+ const exists = /already exists|Duplicate|409/i.test(msg);
361
+ if (upsert || !exists || attempt === 20) throw err;
362
+ }
363
+ }
364
+
365
+ await audit(deps, "media.upload", uploaded[0] ?? "upload", {
366
+ paths: uploaded,
367
+ count: uploaded.length,
368
+ });
369
+ return { urls: uploaded.map((n) => storage.publicUrl(n)) };
370
+ }
371
+
372
+ /**
373
+ * Upload a review (testimonial) image.
374
+ *
375
+ * The browser crops and re-encodes to WebP at the four responsive widths; every
376
+ * variant is independently validated here (magic number, real dimensions,
377
+ * aspect, compression ratio, byte ceiling) before anything is stored.
378
+ */
379
+ export async function uploadReviewImageCore(
380
+ deps: MediaLibraryDeps,
381
+ input: { slug: string; variants: { width: number; data: string }[] },
382
+ ): Promise<{ url: string; path: string; bytes: number }> {
383
+ const slug = sanitizeReviewSlug(input.slug);
384
+ const provided = new Set(input.variants.map((v) => v.width));
385
+ for (const w of REVIEW_WIDTHS) {
386
+ if (!provided.has(w)) throw new Error(`Missing the ${w}px image variant.`);
387
+ }
388
+
389
+ const validated = input.variants.map((v) => ({
390
+ width: v.width,
391
+ file: validateReviewVariant(v.width, v.data),
392
+ }));
393
+
394
+ const storage = getMediaStorage(deps.context.supabase);
395
+
396
+ // Keep the uploaded filename (SEO); de-duplicate with a numeric suffix only.
397
+ const names = await listMediaNames(deps);
398
+ const takenBases = new Set<string>(
399
+ names.map((n) => n.replace(/-(?:480|768|1200|1920)\.webp$/i, "")),
400
+ );
401
+ const finalSlug = uniqueFileName(slug, takenBases);
402
+
403
+ const uploaded: string[] = [];
404
+ try {
405
+ for (const v of validated) {
406
+ const p = `${finalSlug}-${v.width}.webp`;
407
+ await storage.upload(p, v.file.bytes, "image/webp", { upsert: false });
408
+ uploaded.push(p);
409
+ }
410
+ } catch (err) {
411
+ if (uploaded.length) await storage.remove(uploaded).catch(() => {});
412
+ throw err;
413
+ }
414
+
415
+ const primaryPath = `${finalSlug}-1920.webp`;
416
+ const totalBytes = validated.reduce((n, v) => n + v.file.bytes.length, 0);
417
+ await audit(deps, "media.upload", primaryPath, { paths: uploaded });
418
+ return { url: storage.publicUrl(primaryPath), path: primaryPath, bytes: totalBytes };
419
+ }
420
+
421
+ /**
422
+ * Given cleaned base names, report which already exist and with which paths, so
423
+ * a bulk overwrite never adds a new asset by accident.
424
+ */
425
+ export async function prepareMediaOverwriteCore(
426
+ deps: MediaLibraryDeps,
427
+ input: { bases: string[] },
428
+ ): Promise<Record<string, { exists: boolean; paths: string[] }>> {
429
+ const names = await listMediaNames(deps);
430
+ const result: Record<string, { exists: boolean; paths: string[] }> = {};
431
+ for (const base of input.bases) {
432
+ const prefix = `${base}-`;
433
+ const paths = names.filter(
434
+ (n) => n === base || n.startsWith(prefix) || n.startsWith(`${base}.`),
435
+ );
436
+ result[base] = { exists: paths.length > 0, paths };
437
+ }
438
+ return result;
439
+ }
440
+
441
+ /** Record a bulk overwrite run in the audit log. */
442
+ export async function logMediaOverwriteCore(
443
+ deps: MediaLibraryDeps,
444
+ input: { paths: string[]; skipped: string[]; failed: string[] },
445
+ ): Promise<{ ok: true }> {
446
+ await audit(deps, "media.bulk_overwrite", "bulk", {
447
+ overwritten: input.paths,
448
+ skipped: input.skipped,
449
+ failed: input.failed,
450
+ count: input.paths.length,
451
+ });
452
+ return { ok: true };
453
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Server-side validation for uploaded review images.
3
+ *
4
+ * The backoffice resizes/compresses review images in the browser, but the server
5
+ * never trusts that: every variant is decoded here, sniffed for a real WebP magic
6
+ * number, measured, and checked against the canonical review constraints
7
+ * (expected width, sane aspect bounds, height ceiling, byte ceiling) before
8
+ * anything is written to storage. Images keep their original aspect ratio;
9
+ * cropping is done at display time around the saved focus point.
10
+ */
11
+
12
+ export const REVIEW_WIDTHS = [480, 768, 1200, 1920] as const;
13
+ export const REVIEW_ASPECT = 16 / 10;
14
+ /** Accepted aspect range for stored review images (portrait .. panorama). */
15
+ const MIN_ASPECT = 0.2;
16
+ const MAX_ASPECT = 5;
17
+ const MAX_HEIGHT = 2400;
18
+ /** Roughly 0.35 bytes per pixel — generous for WebP q0.8, tight enough to catch junk. */
19
+ const MAX_BYTES_PER_PIXEL = 0.35;
20
+ const MIN_BYTES = 256;
21
+ export const MAX_VARIANT_BYTES = 2 * 1024 * 1024;
22
+
23
+ export type ValidatedVariant = {
24
+ width: number;
25
+ height: number;
26
+ bytes: Uint8Array;
27
+ };
28
+
29
+ export function decodeBase64(data: string): Uint8Array {
30
+ const clean = data.includes(",") ? data.slice(data.indexOf(",") + 1) : data;
31
+ if (!/^[A-Za-z0-9+/=\s]+$/.test(clean)) throw new Error("Malformed image payload.");
32
+ const binary = atob(clean.replace(/\s+/g, ""));
33
+ const out = new Uint8Array(binary.length);
34
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
35
+ return out;
36
+ }
37
+
38
+ /** Reads intrinsic dimensions from a WebP container (VP8 / VP8L / VP8X). */
39
+ export function webpDimensions(bytes: Uint8Array): { width: number; height: number } | null {
40
+ if (bytes.length < 30) return null;
41
+ const tag = (o: number, s: string) =>
42
+ s.split("").every((c, i) => bytes[o + i] === c.charCodeAt(0));
43
+ if (!tag(0, "RIFF") || !tag(8, "WEBP")) return null;
44
+ const format = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
45
+ if (format === "VP8 ") {
46
+ // Lossy: 3-byte frame tag + 3-byte sync code, then 14-bit width/height.
47
+ if (!(bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a)) return null;
48
+ const width = (bytes[26] | (bytes[27] << 8)) & 0x3fff;
49
+ const height = (bytes[28] | (bytes[29] << 8)) & 0x3fff;
50
+ return { width, height };
51
+ }
52
+ if (format === "VP8L") {
53
+ if (bytes[20] !== 0x2f) return null;
54
+ const b = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24);
55
+ return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 };
56
+ }
57
+ if (format === "VP8X") {
58
+ const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16));
59
+ const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16));
60
+ return { width, height };
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function validateReviewVariant(width: number, raw: string): ValidatedVariant {
66
+ if (!(REVIEW_WIDTHS as readonly number[]).includes(width)) {
67
+ throw new Error(`Unexpected image variant width: ${width}.`);
68
+ }
69
+ const bytes = decodeBase64(raw);
70
+ if (bytes.length < MIN_BYTES) throw new Error("Image variant is empty or corrupt.");
71
+ if (bytes.length > MAX_VARIANT_BYTES) {
72
+ throw new Error(`Image variant ${width}px exceeds the 2 MB limit after compression.`);
73
+ }
74
+ const dims = webpDimensions(bytes);
75
+ if (!dims) throw new Error("Image variant is not a valid WebP file.");
76
+ if (dims.width > width) {
77
+ throw new Error(`Image variant ${width}px is larger than allowed (${dims.width}px).`);
78
+ }
79
+ if (dims.height > MAX_HEIGHT) {
80
+ throw new Error(`Image variant ${width}px is too tall (${dims.height}px).`);
81
+ }
82
+ const aspect = dims.width / dims.height;
83
+ if (aspect < MIN_ASPECT || aspect > MAX_ASPECT) {
84
+ throw new Error("Unsupported image proportions.");
85
+ }
86
+ if (bytes.length > dims.width * dims.height * MAX_BYTES_PER_PIXEL) {
87
+ throw new Error(`Image variant ${width}px is not compressed enough.`);
88
+ }
89
+ return { width: dims.width, height: dims.height, bytes };
90
+ }
91
+
92
+ export function sanitizeReviewSlug(slug: string): string {
93
+ const cleaned = slug
94
+ .toLowerCase()
95
+ .normalize("NFD")
96
+ .replace(/[\u0300-\u036f]/g, "")
97
+ .replace(/[^a-z0-9-]+/g, "-")
98
+ .replace(/^-+|-+$/g, "")
99
+ .slice(0, 64);
100
+ if (!cleaned) throw new Error("Invalid file name.");
101
+ // Keep the uploaded filename as-is (SEO) — no prefixes, no timestamps.
102
+ return cleaned;
103
+ }