@bygghammar/cms-platform 0.30.1 → 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/CHANGELOG.md +7 -0
- package/package.json +8 -2
- package/src/backoffice/SharedContentListView.tsx +103 -0
- package/src/backoffice/seo/SeoListView.tsx +104 -0
- package/src/server/admin-core.ts +383 -0
- package/src/server/audit-core.ts +54 -0
- package/src/server/media-library-core.ts +453 -0
- package/src/server/review-image-validate.ts +103 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.31.0
|
|
4
|
+
|
|
5
|
+
- Added `backoffice/SharedContentListView` — the shared-content library screen
|
|
6
|
+
with host-supplied section registry, override chips and preview thumbnails.
|
|
7
|
+
- Added `backoffice/seo/SeoListView` — the SEO index (`SeoListView`) and page
|
|
8
|
+
shell (`SeoDetailView`) around the host's page SEO panel.
|
|
9
|
+
|
|
3
10
|
## 0.30.0
|
|
4
11
|
|
|
5
12
|
- Added `backoffice/PagesListView` — the page-builder index with public URLs per
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "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,
|
|
@@ -135,7 +135,13 @@
|
|
|
135
135
|
"./backoffice/GlobalSectionBuilderView": "./src/backoffice/GlobalSectionBuilderView.tsx",
|
|
136
136
|
"./backoffice/ReviewsView": "./src/backoffice/ReviewsView.tsx",
|
|
137
137
|
"./backoffice/PagesListView": "./src/backoffice/PagesListView.tsx",
|
|
138
|
-
"./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx"
|
|
138
|
+
"./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx",
|
|
139
|
+
"./backoffice/SharedContentListView": "./src/backoffice/SharedContentListView.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"
|
|
139
145
|
},
|
|
140
146
|
"dependencies": {
|
|
141
147
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic "Shared content" library screen.
|
|
3
|
+
*
|
|
4
|
+
* Lists the site-wide content records (one per section key). The host supplies
|
|
5
|
+
* the section registry, override state, thumbnails and navigation, so every
|
|
6
|
+
* site gets the same screen without the package knowing its content model.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, type ReactNode } from "react";
|
|
9
|
+
import { Check, ChevronRight, Eye } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
import { PreviewLightbox } from "./PreviewLightbox";
|
|
12
|
+
|
|
13
|
+
export type SharedContentSection = {
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SharedContentListDeps = {
|
|
20
|
+
sections: readonly SharedContentSection[];
|
|
21
|
+
/** True when this key has a database override (shows the "edited" chip). */
|
|
22
|
+
hasOverride: (key: string) => boolean;
|
|
23
|
+
/** Navigate to the editor for one section key. */
|
|
24
|
+
openEditor: (key: string) => void;
|
|
25
|
+
/** Preview URL for one section key, e.g. `/preview/section/${key}`. */
|
|
26
|
+
previewSrc: (key: string) => string;
|
|
27
|
+
/** Thumbnail for one section key; host-rendered so it can read its own data. */
|
|
28
|
+
renderThumb: (key: string) => ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function SharedContentListView({ deps }: { deps: SharedContentListDeps }): ReactNode {
|
|
32
|
+
const { sections, hasOverride, openEditor, previewSrc, renderThumb } = deps;
|
|
33
|
+
const [preview, setPreview] = useState<{ key: string; label: string } | null>(null);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="p-8 md:p-12 w-full">
|
|
37
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Shared content</div>
|
|
38
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Shared content</h1>
|
|
39
|
+
<p className="mt-4 text-ink/65 max-w-2xl">
|
|
40
|
+
Site-wide content records — one per key. Any page using the matching block reads this
|
|
41
|
+
content, so editing here updates every page at once. For global blocks (reusable groups of
|
|
42
|
+
blocks) with their own layout, use Global blocks instead. Thumbnails refresh when you save.
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
<ul className="mt-10 divide-y divide-ink/10 border-y border-ink/10">
|
|
46
|
+
{sections.map((section) => (
|
|
47
|
+
<li key={section.key}>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => openEditor(section.key)}
|
|
51
|
+
className="flex w-full items-center gap-4 py-5 hover:bg-white transition-colors px-4 -mx-4 text-left"
|
|
52
|
+
>
|
|
53
|
+
<span
|
|
54
|
+
role="button"
|
|
55
|
+
tabIndex={0}
|
|
56
|
+
title="Preview section"
|
|
57
|
+
onClick={(e) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
setPreview({ key: section.key, label: section.label });
|
|
61
|
+
}}
|
|
62
|
+
onKeyDown={(e) => {
|
|
63
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
setPreview({ key: section.key, label: section.label });
|
|
67
|
+
}}
|
|
68
|
+
className="group relative shrink-0"
|
|
69
|
+
>
|
|
70
|
+
{renderThumb(section.key)}
|
|
71
|
+
<span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
|
|
72
|
+
<Eye className="size-4" />
|
|
73
|
+
</span>
|
|
74
|
+
</span>
|
|
75
|
+
<span className="flex-1 min-w-0">
|
|
76
|
+
<span className="flex items-center gap-3">
|
|
77
|
+
<span className="font-serif text-lg text-ink">{section.label}</span>
|
|
78
|
+
{hasOverride(section.key) && (
|
|
79
|
+
<span className="inline-flex items-center gap-1 text-[0.65rem] uppercase tracking-[0.18em] text-sea">
|
|
80
|
+
<Check className="size-3" />
|
|
81
|
+
edited
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</span>
|
|
85
|
+
{section.description && (
|
|
86
|
+
<span className="mt-1 block text-sm text-ink/60">{section.description}</span>
|
|
87
|
+
)}
|
|
88
|
+
</span>
|
|
89
|
+
<ChevronRight className="size-5 text-ink/40" />
|
|
90
|
+
</button>
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
|
|
95
|
+
<PreviewLightbox
|
|
96
|
+
open={preview != null}
|
|
97
|
+
onClose={() => setPreview(null)}
|
|
98
|
+
src={preview ? previewSrc(preview.key) : ""}
|
|
99
|
+
title={preview?.label ?? ""}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic SEO index and page shell.
|
|
3
|
+
*
|
|
4
|
+
* The host resolves titles, slugs and override state from its own page registry
|
|
5
|
+
* plus CMS overrides; these components own only presentation and navigation.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export type SeoListRow = {
|
|
11
|
+
/** Page key, e.g. `index` or a CMS-only page key. */
|
|
12
|
+
key: string;
|
|
13
|
+
/** Title shown in the default locale. */
|
|
14
|
+
title: string;
|
|
15
|
+
/** Public path in the default locale, e.g. `/` or `/kontakt`. */
|
|
16
|
+
path: string;
|
|
17
|
+
hasOverride: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SeoListDeps = {
|
|
21
|
+
rows: readonly SeoListRow[];
|
|
22
|
+
/** Navigate to the SEO editor for one page key. */
|
|
23
|
+
openEditor: (key: string) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function SeoListView({ deps }: { deps: SeoListDeps }): ReactNode {
|
|
27
|
+
const { rows, openEditor } = deps;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="p-8 md:p-12 w-full">
|
|
31
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">SEO</div>
|
|
32
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">SEO & slugs</h1>
|
|
33
|
+
|
|
34
|
+
<p className="mt-4 text-ink/65 max-w-xl">
|
|
35
|
+
Edit title, description, social previews and URL slug per language. Changes override the
|
|
36
|
+
defaults from the codebase and are used directly by canonical, hreflang and sitemap.
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<ul className="mt-10 divide-y divide-ink/10 border-y border-ink/10">
|
|
40
|
+
{rows.map((row) => (
|
|
41
|
+
<li key={row.key}>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={() => openEditor(row.key)}
|
|
45
|
+
className="flex w-full items-center gap-4 py-5 hover:bg-white transition-colors px-4 -mx-4 text-left"
|
|
46
|
+
>
|
|
47
|
+
<span className="flex-1 min-w-0">
|
|
48
|
+
<span className="flex items-center gap-3">
|
|
49
|
+
<span className="font-serif text-lg text-ink">{row.title}</span>
|
|
50
|
+
{row.hasOverride && (
|
|
51
|
+
<span className="inline-flex items-center gap-1 text-[0.65rem] uppercase tracking-[0.18em] text-sea">
|
|
52
|
+
<Check className="size-3" />
|
|
53
|
+
edited
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
<span className="mt-1 block text-sm text-ink/60">
|
|
58
|
+
<span className="font-mono">{row.key}</span> · {row.path}
|
|
59
|
+
</span>
|
|
60
|
+
</span>
|
|
61
|
+
<ChevronRight className="size-5 text-ink/40" />
|
|
62
|
+
</button>
|
|
63
|
+
</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type SeoDetailDeps = {
|
|
71
|
+
pageKey: string;
|
|
72
|
+
/** Navigate back to the SEO index. */
|
|
73
|
+
onBack: () => void;
|
|
74
|
+
/** The host's page SEO panel for this page key. */
|
|
75
|
+
children: ReactNode;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function SeoDetailView({ deps }: { deps: SeoDetailDeps }): ReactNode {
|
|
79
|
+
const { pageKey, onBack, children } = deps;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="p-8 md:p-12 w-full">
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={onBack}
|
|
86
|
+
className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink"
|
|
87
|
+
>
|
|
88
|
+
<ChevronLeft className="size-4" />
|
|
89
|
+
All SEO pages
|
|
90
|
+
</button>
|
|
91
|
+
|
|
92
|
+
<div className="mt-6 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
93
|
+
Page · {pageKey}
|
|
94
|
+
</div>
|
|
95
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Page SEO</h1>
|
|
96
|
+
<p className="mt-3 text-ink/65 max-w-xl">
|
|
97
|
+
Defaults come from the codebase. Changes are saved per language and used by canonical,
|
|
98
|
+
hreflang, Open Graph and Twitter cards.
|
|
99
|
+
</p>
|
|
100
|
+
|
|
101
|
+
<div className="mt-8">{children}</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|