@bygghammar/cms-platform 0.1.0 → 0.2.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/README.md +74 -5
- package/package.json +20 -3
- package/src/backup/browser-media.ts +205 -0
- package/src/backup/index.ts +3 -0
- package/src/backup/safety.ts +241 -0
- package/src/backup/verification.ts +80 -0
- package/src/host/index.tsx +16 -0
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ future sites). **Code only** — no content, no data, no secrets, no theme.
|
|
|
9
9
|
- `src/blocks/icons.ts` — the icon set blocks may reference
|
|
10
10
|
- `src/blocks/block-props-context.tsx` — block prop plumbing for inline editing
|
|
11
11
|
- `src/i18n/*` — the localization core (locales, types, resolver, context)
|
|
12
|
+
- `src/backup/*` — archive/media safety, browser-side large-media export and
|
|
13
|
+
provider-neutral verification helpers
|
|
12
14
|
- `src/theme.css` — the design-token contract + neutral starter theme
|
|
13
15
|
|
|
14
16
|
## What must never live here
|
|
@@ -17,6 +19,27 @@ future sites). **Code only** — no content, no data, no secrets, no theme.
|
|
|
17
19
|
- page content, SEO copy, media, CMS page JSON
|
|
18
20
|
- Supabase generated types, auth users, secrets, API keys
|
|
19
21
|
- route files and per-site deployment config
|
|
22
|
+
- site table lists, import order, database clients, authentication and RLS
|
|
23
|
+
policies; each host supplies those adapters to the shared backup core
|
|
24
|
+
|
|
25
|
+
## Backup core
|
|
26
|
+
|
|
27
|
+
The package owns the CMS-wide, provider-neutral parts of backup and restore:
|
|
28
|
+
|
|
29
|
+
- media signature/path validation and secret sanitising
|
|
30
|
+
- streaming large media files into an archive in the authenticated browser
|
|
31
|
+
- archive progress and manifest contracts
|
|
32
|
+
- row-count, user/role and media verification helpers
|
|
33
|
+
|
|
34
|
+
The browser media exporter accepts a host storage adapter with only `list()`
|
|
35
|
+
and `download()` methods plus the host's expected archive format/schema
|
|
36
|
+
versions. Keep the dynamic import behind an `import.meta.env.SSR` guard in SSR
|
|
37
|
+
applications so the browser-only ZIP code cannot enter the optimized server
|
|
38
|
+
chunk graph.
|
|
39
|
+
|
|
40
|
+
The consuming site remains responsible for its dataset groups, table import
|
|
41
|
+
order, Supabase clients, superadmin authorization, HTTP routes, audit records
|
|
42
|
+
and database migrations. The package never contains site data or credentials.
|
|
20
43
|
|
|
21
44
|
## Per-site wiring
|
|
22
45
|
|
|
@@ -28,9 +51,55 @@ import { registerTranslations } from "@bygghammar/cms-platform/i18n";
|
|
|
28
51
|
registerTranslations({ generated: TRANSLATIONS, overrides: TRANSLATION_OVERRIDES });
|
|
29
52
|
```
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
Host components must participate in a value the application actually uses.
|
|
55
|
+
Do not rely only on `import "./register-host"` because an optimized build may
|
|
56
|
+
remove that module when the application's `package.json` declares
|
|
57
|
+
`"sideEffects": false`.
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { initializeHostComponents, type HostComponents } from "@bygghammar/cms-platform/host";
|
|
61
|
+
|
|
62
|
+
const HOST_COMPONENTS = {
|
|
63
|
+
FocalImage,
|
|
64
|
+
LazyVideo,
|
|
65
|
+
FocusPicker,
|
|
66
|
+
RichTextEditor,
|
|
67
|
+
mediaLibraryQueryOptions,
|
|
68
|
+
useMediaUpload,
|
|
69
|
+
} satisfies Partial<HostComponents>;
|
|
70
|
+
|
|
71
|
+
export const BLOCK_REGISTRY = initializeHostComponents(HOST_COMPONENTS, () =>
|
|
72
|
+
createBlockRegistry(),
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Keep any existing registration module in the consuming application's
|
|
77
|
+
`sideEffects` allowlist as defence in depth until every entry point uses the
|
|
78
|
+
explicit contract. CSS must also remain in that allowlist.
|
|
79
|
+
|
|
80
|
+
## Publishing
|
|
81
|
+
|
|
82
|
+
This directory is the maintained source for the public npm package. Releases
|
|
83
|
+
use npm Trusted Publishing from GitHub Actions; do not publish from a laptop or
|
|
84
|
+
create an npm token.
|
|
85
|
+
|
|
86
|
+
1. Merge the reviewed version bump and wait for Checks to pass on `main`.
|
|
87
|
+
2. Run **Actions → Publish CMS platform → Run workflow** from `main` and enter
|
|
88
|
+
the exact version from this package.
|
|
89
|
+
3. Verify the new public version before updating consuming CMS lockfiles.
|
|
90
|
+
|
|
91
|
+
Never place an npm token or `.npmrc` in the repository. Consumers install an
|
|
92
|
+
explicit released version such as `@bygghammar/cms-platform@^0.1.1`.
|
|
93
|
+
|
|
94
|
+
### Release checks and troubleshooting
|
|
95
|
+
|
|
96
|
+
The workflow type-checks the package and runs
|
|
97
|
+
`tests/cms-platform-host-contract.test.tsx` before requesting npm's OIDC
|
|
98
|
+
publishing credential. Keep Vitest's include glob compatible with both
|
|
99
|
+
`*.test.ts` and `*.test.tsx`; otherwise the release stops with “No test files
|
|
100
|
+
found” before npm is contacted.
|
|
32
101
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
registry
|
|
36
|
-
|
|
102
|
+
If a release run fails, read its failed step before rerunning it. A failed run
|
|
103
|
+
does not publish a partial package. After a successful run, verify the public
|
|
104
|
+
registry reports the requested version, then update each consuming application
|
|
105
|
+
and its frozen lockfile in a separate pull request.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
"src",
|
|
14
14
|
"README.md"
|
|
15
15
|
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/byggis79/devsba.git",
|
|
19
|
+
"directory": "packages/cms-platform"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
16
24
|
"exports": {
|
|
17
25
|
".": "./src/index.ts",
|
|
18
26
|
"./i18n": "./src/i18n/index.ts",
|
|
@@ -26,14 +34,23 @@
|
|
|
26
34
|
"./ui/Lightbox": "./src/ui/Lightbox.tsx",
|
|
27
35
|
"./util/scroll-lock": "./src/util/scroll-lock.ts",
|
|
28
36
|
"./html/sanitize-html": "./src/html/sanitize-html.ts",
|
|
37
|
+
"./backup": "./src/backup/index.ts",
|
|
38
|
+
"./backup/safety": "./src/backup/safety.ts",
|
|
39
|
+
"./backup/browser-media": "./src/backup/browser-media.ts",
|
|
40
|
+
"./backup/verification": "./src/backup/verification.ts",
|
|
29
41
|
"./backoffice/inspectors/fields": "./src/backoffice/inspectors/fields.tsx",
|
|
30
42
|
"./theme.css": "./src/theme.css",
|
|
31
43
|
"./backoffice/inspectors/blocks": "./src/backoffice/inspectors/blocks.tsx",
|
|
32
44
|
"./blocks/registry": "./src/blocks/registry.tsx"
|
|
33
45
|
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"fflate": "^0.8.3"
|
|
48
|
+
},
|
|
34
49
|
"peerDependencies": {
|
|
50
|
+
"@tanstack/react-query": ">=5",
|
|
35
51
|
"lucide-react": ">=0.5.0",
|
|
36
52
|
"react": ">=19",
|
|
37
|
-
"react-dom": ">=19"
|
|
53
|
+
"react-dom": ">=19",
|
|
54
|
+
"sonner": ">=2"
|
|
38
55
|
}
|
|
39
|
-
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { strFromU8, strToU8, unzipSync, Zip, ZipDeflate, ZipPassThrough } from "fflate";
|
|
2
|
+
|
|
3
|
+
import { hasAllowedMediaExtension, isSafeArchivePath, verifyMediaFile } from "./safety";
|
|
4
|
+
|
|
5
|
+
export type BackupArchiveManifest = {
|
|
6
|
+
format: number | string;
|
|
7
|
+
schemaVersion: number | string;
|
|
8
|
+
groups: string[];
|
|
9
|
+
mediaFiles: number;
|
|
10
|
+
mediaBytes: number;
|
|
11
|
+
quarantinedMedia?: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BackupStorageFile = {
|
|
15
|
+
name: string;
|
|
16
|
+
size: number;
|
|
17
|
+
contentType?: string | null;
|
|
18
|
+
updatedAt?: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BrowserBackupStorage = {
|
|
22
|
+
list(): Promise<BackupStorageFile[]>;
|
|
23
|
+
download(path: string): Promise<Uint8Array>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type MediaExportProgress = {
|
|
27
|
+
phase: "listing" | "downloading" | "finalizing";
|
|
28
|
+
completedFiles: number;
|
|
29
|
+
totalFiles: number;
|
|
30
|
+
completedBytes: number;
|
|
31
|
+
totalBytes: number;
|
|
32
|
+
currentFile?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type BrowserMediaExportOptions = {
|
|
36
|
+
baseArchive: Uint8Array;
|
|
37
|
+
groups: string[];
|
|
38
|
+
storage: BrowserBackupStorage;
|
|
39
|
+
expectedFormat: number | string;
|
|
40
|
+
expectedSchemaVersion: number | string;
|
|
41
|
+
onProgress?: (progress: MediaExportProgress) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function readManifest<M extends BackupArchiveManifest>(
|
|
45
|
+
files: Record<string, Uint8Array>,
|
|
46
|
+
expectedFormat: number | string,
|
|
47
|
+
expectedSchemaVersion: number | string,
|
|
48
|
+
): M {
|
|
49
|
+
const raw = files["manifest.json"];
|
|
50
|
+
if (!raw) throw new Error("The server metadata archive has no manifest.");
|
|
51
|
+
|
|
52
|
+
let manifest: M;
|
|
53
|
+
try {
|
|
54
|
+
manifest = JSON.parse(strFromU8(raw)) as M;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error("The server metadata archive has an invalid manifest.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
manifest.format !== expectedFormat ||
|
|
61
|
+
manifest.schemaVersion !== expectedSchemaVersion ||
|
|
62
|
+
!Array.isArray(manifest.groups)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error("The server metadata archive is not compatible with this version.");
|
|
65
|
+
}
|
|
66
|
+
return manifest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function addEntry(zip: Zip, name: string, data: Uint8Array, compress: boolean): void {
|
|
70
|
+
const entry = compress ? new ZipDeflate(name, { level: 6 }) : new ZipPassThrough(name);
|
|
71
|
+
zip.add(entry);
|
|
72
|
+
entry.push(data, true);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function candidateMedia(files: BackupStorageFile[], quarantined: string[]): BackupStorageFile[] {
|
|
76
|
+
return files.filter((file) => {
|
|
77
|
+
if (!hasAllowedMediaExtension(file.name) || !isSafeArchivePath(file.name)) {
|
|
78
|
+
quarantined.push(file.name);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Add large media to a small server-generated metadata archive in the
|
|
87
|
+
* authenticated browser. Objects are downloaded and verified one at a time,
|
|
88
|
+
* avoiding request timeouts and all-media-in-server-memory failures.
|
|
89
|
+
*/
|
|
90
|
+
export async function buildBrowserMediaArchive<M extends BackupArchiveManifest>({
|
|
91
|
+
baseArchive,
|
|
92
|
+
groups,
|
|
93
|
+
storage,
|
|
94
|
+
expectedFormat,
|
|
95
|
+
expectedSchemaVersion,
|
|
96
|
+
onProgress,
|
|
97
|
+
}: BrowserMediaExportOptions): Promise<{ blob: Blob; manifest: M }> {
|
|
98
|
+
const baseFiles = unzipSync(baseArchive);
|
|
99
|
+
const baseManifest = readManifest<M>(baseFiles, expectedFormat, expectedSchemaVersion);
|
|
100
|
+
const quarantined = [...(baseManifest.quarantinedMedia ?? [])];
|
|
101
|
+
|
|
102
|
+
onProgress?.({
|
|
103
|
+
phase: "listing",
|
|
104
|
+
completedFiles: 0,
|
|
105
|
+
totalFiles: 0,
|
|
106
|
+
completedBytes: 0,
|
|
107
|
+
totalBytes: 0,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const listed = await storage.list();
|
|
111
|
+
const candidates = candidateMedia(listed, quarantined);
|
|
112
|
+
const totalBytes = candidates.reduce((sum, file) => sum + Math.max(0, file.size), 0);
|
|
113
|
+
let completedFiles = 0;
|
|
114
|
+
let completedBytes = 0;
|
|
115
|
+
let archivedFiles = 0;
|
|
116
|
+
let archivedBytes = 0;
|
|
117
|
+
|
|
118
|
+
const chunks: BlobPart[] = [];
|
|
119
|
+
let resolveZip!: () => void;
|
|
120
|
+
let rejectZip!: (error: Error) => void;
|
|
121
|
+
const zipFinished = new Promise<void>((resolve, reject) => {
|
|
122
|
+
resolveZip = resolve;
|
|
123
|
+
rejectZip = reject;
|
|
124
|
+
});
|
|
125
|
+
const zip = new Zip((error, data, final) => {
|
|
126
|
+
if (error) {
|
|
127
|
+
rejectZip(error instanceof Error ? error : new Error(String(error)));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (data?.byteLength) chunks.push(data as unknown as BlobPart);
|
|
131
|
+
if (final) resolveZip();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
for (const [name, data] of Object.entries(baseFiles)) {
|
|
136
|
+
if (name === "manifest.json" || name.startsWith("media/")) continue;
|
|
137
|
+
if (!isSafeArchivePath(name)) throw new Error(`Unsafe metadata archive entry: ${name}`);
|
|
138
|
+
addEntry(zip, name, data, name.endsWith(".json"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
onProgress?.({
|
|
142
|
+
phase: "downloading",
|
|
143
|
+
completedFiles,
|
|
144
|
+
totalFiles: candidates.length,
|
|
145
|
+
completedBytes,
|
|
146
|
+
totalBytes,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
for (const file of candidates) {
|
|
150
|
+
let data: Uint8Array;
|
|
151
|
+
try {
|
|
152
|
+
data = await storage.download(file.name);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Could not download media file “${file.name}”: ${
|
|
156
|
+
error instanceof Error ? error.message : "unknown storage error"
|
|
157
|
+
}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const verdict = verifyMediaFile(file.name, data);
|
|
162
|
+
if (verdict.ok) {
|
|
163
|
+
addEntry(zip, `media/${file.name}`, data, false);
|
|
164
|
+
archivedFiles += 1;
|
|
165
|
+
archivedBytes += data.byteLength;
|
|
166
|
+
} else {
|
|
167
|
+
quarantined.push(`${file.name} (${verdict.reason})`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
completedFiles += 1;
|
|
171
|
+
completedBytes += file.size > 0 ? file.size : data.byteLength;
|
|
172
|
+
onProgress?.({
|
|
173
|
+
phase: "downloading",
|
|
174
|
+
completedFiles,
|
|
175
|
+
totalFiles: candidates.length,
|
|
176
|
+
completedBytes,
|
|
177
|
+
totalBytes,
|
|
178
|
+
currentFile: file.name,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const manifest = {
|
|
183
|
+
...baseManifest,
|
|
184
|
+
groups: [...groups],
|
|
185
|
+
mediaFiles: archivedFiles,
|
|
186
|
+
mediaBytes: archivedBytes,
|
|
187
|
+
quarantinedMedia: quarantined,
|
|
188
|
+
} as M;
|
|
189
|
+
addEntry(zip, "manifest.json", strToU8(JSON.stringify(manifest, null, 2)), true);
|
|
190
|
+
onProgress?.({
|
|
191
|
+
phase: "finalizing",
|
|
192
|
+
completedFiles,
|
|
193
|
+
totalFiles: candidates.length,
|
|
194
|
+
completedBytes,
|
|
195
|
+
totalBytes,
|
|
196
|
+
});
|
|
197
|
+
zip.end();
|
|
198
|
+
await zipFinished;
|
|
199
|
+
|
|
200
|
+
return { blob: new Blob(chunks, { type: "application/zip" }), manifest };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
zip.terminate();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral backup safety helpers. Nothing in this module opens a
|
|
3
|
+
* connection or reads the filesystem, so hosts can use it in the browser,
|
|
4
|
+
* server functions and restore tools.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const ALLOWED_MEDIA_EXTENSIONS = [
|
|
8
|
+
".webp",
|
|
9
|
+
".jpg",
|
|
10
|
+
".jpeg",
|
|
11
|
+
".png",
|
|
12
|
+
".gif",
|
|
13
|
+
".avif",
|
|
14
|
+
".mp4",
|
|
15
|
+
".webm",
|
|
16
|
+
".mov",
|
|
17
|
+
".pdf",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export const MAX_MEDIA_ENTRY_BYTES = 250 * 1024 * 1024;
|
|
21
|
+
export const MAX_TOTAL_UNCOMPRESSED_BYTES = 8 * 1024 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
export function mediaExtension(name: string): string {
|
|
24
|
+
const dot = name.lastIndexOf(".");
|
|
25
|
+
return dot < 0 ? "" : name.slice(dot).toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hasAllowedMediaExtension(name: string): boolean {
|
|
29
|
+
return (ALLOWED_MEDIA_EXTENSIONS as readonly string[]).includes(mediaExtension(name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const isAllowedMediaFile = hasAllowedMediaExtension;
|
|
33
|
+
|
|
34
|
+
function startsWith(bytes: Uint8Array, signature: number[], offset = 0): boolean {
|
|
35
|
+
if (bytes.length < offset + signature.length) return false;
|
|
36
|
+
for (let i = 0; i < signature.length; i += 1) {
|
|
37
|
+
if (bytes[offset + i] !== signature[i]) return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ascii(bytes: Uint8Array, from: number, length: number): string {
|
|
43
|
+
let out = "";
|
|
44
|
+
for (let i = from; i < Math.min(bytes.length, from + length); i += 1) {
|
|
45
|
+
out += String.fromCharCode(bytes[i]!);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function looksLikeMarkup(bytes: Uint8Array): boolean {
|
|
51
|
+
let index = startsWith(bytes, [0xef, 0xbb, 0xbf]) ? 3 : 0;
|
|
52
|
+
while (index < bytes.length && [0x09, 0x0a, 0x0d, 0x20].includes(bytes[index]!)) index += 1;
|
|
53
|
+
const head = ascii(bytes, index, 512).toLowerCase();
|
|
54
|
+
if (!head.startsWith("<")) return false;
|
|
55
|
+
return (
|
|
56
|
+
head.startsWith("<!doctype") ||
|
|
57
|
+
head.startsWith("<html") ||
|
|
58
|
+
head.startsWith("<head") ||
|
|
59
|
+
head.startsWith("<body") ||
|
|
60
|
+
head.startsWith("<script") ||
|
|
61
|
+
head.startsWith("<svg") ||
|
|
62
|
+
head.startsWith("<?xml") ||
|
|
63
|
+
head.startsWith("<!--")
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sniffMediaType(bytes: Uint8Array): string | null {
|
|
68
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
|
|
69
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
|
|
70
|
+
if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a") return "image/gif";
|
|
71
|
+
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") return "image/webp";
|
|
72
|
+
if (ascii(bytes, 0, 4) === "%PDF") return "application/pdf";
|
|
73
|
+
if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "video/webm";
|
|
74
|
+
if (ascii(bytes, 4, 4) === "ftyp") {
|
|
75
|
+
const brand = ascii(bytes, 8, 4).toLowerCase();
|
|
76
|
+
if (brand.startsWith("avif") || brand.startsWith("avis")) return "image/avif";
|
|
77
|
+
if (brand.startsWith("qt")) return "video/quicktime";
|
|
78
|
+
return "video/mp4";
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const TYPE_EXTENSIONS: Record<string, string[]> = {
|
|
84
|
+
"image/jpeg": [".jpg", ".jpeg"],
|
|
85
|
+
"image/png": [".png"],
|
|
86
|
+
"image/gif": [".gif"],
|
|
87
|
+
"image/webp": [".webp"],
|
|
88
|
+
"image/avif": [".avif"],
|
|
89
|
+
"application/pdf": [".pdf"],
|
|
90
|
+
"video/webm": [".webm"],
|
|
91
|
+
"video/mp4": [".mp4", ".mov", ".webm"],
|
|
92
|
+
"video/quicktime": [".mov", ".mp4"],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type MediaVerdict = { ok: true; type: string } | { ok: false; reason: string };
|
|
96
|
+
|
|
97
|
+
export function verifyMediaFile(name: string, bytes: Uint8Array): MediaVerdict {
|
|
98
|
+
const extension = mediaExtension(name);
|
|
99
|
+
if (!extension) return { ok: false, reason: "no file extension" };
|
|
100
|
+
if (!hasAllowedMediaExtension(name)) {
|
|
101
|
+
return { ok: false, reason: `extension ${extension} is not media` };
|
|
102
|
+
}
|
|
103
|
+
if (!bytes.byteLength) return { ok: false, reason: "empty file" };
|
|
104
|
+
if (bytes.byteLength > MAX_MEDIA_ENTRY_BYTES) return { ok: false, reason: "file too large" };
|
|
105
|
+
if (looksLikeMarkup(bytes)) return { ok: false, reason: "content is markup, not media" };
|
|
106
|
+
const type = sniffMediaType(bytes);
|
|
107
|
+
if (!type) return { ok: false, reason: "unrecognised file signature" };
|
|
108
|
+
if (!(TYPE_EXTENSIONS[type] ?? []).includes(extension)) {
|
|
109
|
+
return { ok: false, reason: `content is ${type} but named ${extension}` };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, type };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isSafeArchivePath(name: string): boolean {
|
|
115
|
+
if (!name || name.length > 1024) return false;
|
|
116
|
+
if (name.startsWith("/") || name.startsWith("\\")) return false;
|
|
117
|
+
if (/^[a-zA-Z]:[\\/]/.test(name)) return false;
|
|
118
|
+
if (name.includes("\\") || name.includes("\0")) return false;
|
|
119
|
+
// eslint-disable-next-line no-control-regex
|
|
120
|
+
if (/[\u0000-\u001f]/.test(name)) return false;
|
|
121
|
+
if (name.split("/").some((segment) => segment === ".." || segment === "." || segment === "")) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
128
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
129
|
+
const prototype = Object.getPrototypeOf(value) as object | null;
|
|
130
|
+
return prototype === Object.prototype || prototype === null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
134
|
+
let binary = "";
|
|
135
|
+
const chunkSize = 0x8000;
|
|
136
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
137
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
|
138
|
+
}
|
|
139
|
+
return btoa(binary);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function toJsonSafe<T>(value: T): T {
|
|
143
|
+
const walk = (input: unknown): unknown => {
|
|
144
|
+
if (input === null || input === undefined) return input;
|
|
145
|
+
if (input instanceof Date) return input.toISOString();
|
|
146
|
+
if (typeof input === "bigint") return input.toString();
|
|
147
|
+
if (Array.isArray(input)) return input.map(walk);
|
|
148
|
+
if (typeof input === "object") {
|
|
149
|
+
if (input instanceof ArrayBuffer) return bytesToBase64(new Uint8Array(input));
|
|
150
|
+
if (ArrayBuffer.isView(input)) {
|
|
151
|
+
return bytesToBase64(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
|
|
152
|
+
}
|
|
153
|
+
if (!isPlainObject(input)) {
|
|
154
|
+
const maybeJson = input as { toJSON?: () => unknown };
|
|
155
|
+
if (typeof maybeJson.toJSON === "function") return walk(maybeJson.toJSON());
|
|
156
|
+
return String(input);
|
|
157
|
+
}
|
|
158
|
+
const out: Record<string, unknown> = {};
|
|
159
|
+
for (const [key, item] of Object.entries(input)) out[key] = walk(item);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
if (typeof input === "number" && !Number.isFinite(input)) return null;
|
|
163
|
+
return input;
|
|
164
|
+
};
|
|
165
|
+
return walk(value) as T;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function nonJsonSafePaths(value: unknown, prefix = ""): string[] {
|
|
169
|
+
const out: string[] = [];
|
|
170
|
+
const walk = (input: unknown, path: string): void => {
|
|
171
|
+
if (input === null || input === undefined) return;
|
|
172
|
+
if (input instanceof Date || typeof input === "bigint") {
|
|
173
|
+
out.push(path || "(root)");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(input)) {
|
|
177
|
+
input.forEach((item, index) => walk(item, `${path}[${index}]`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (typeof input === "object") {
|
|
181
|
+
if (!isPlainObject(input)) {
|
|
182
|
+
out.push(path || "(root)");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
for (const [key, item] of Object.entries(input)) walk(item, path ? `${path}.${key}` : key);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
walk(value, prefix);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const SECRET_KEY_RE =
|
|
193
|
+
/(password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|credential|client[_-]?secret|access[_-]?key|authorization)/i;
|
|
194
|
+
const SECRET_NAME_KEY_RE = /(secret_name|secret_ref_name|_secret_ref|ref_name|key_id|_name)$/i;
|
|
195
|
+
|
|
196
|
+
export function sanitizeSecrets<T>(value: T): T {
|
|
197
|
+
const walk = (input: unknown): unknown => {
|
|
198
|
+
if (input instanceof Date) return input.toISOString();
|
|
199
|
+
if (Array.isArray(input)) return input.map(walk);
|
|
200
|
+
if (input && typeof input === "object") {
|
|
201
|
+
if (!isPlainObject(input)) return toJsonSafe(input);
|
|
202
|
+
const out: Record<string, unknown> = {};
|
|
203
|
+
for (const [key, item] of Object.entries(input)) {
|
|
204
|
+
out[key] =
|
|
205
|
+
SECRET_KEY_RE.test(key) && !SECRET_NAME_KEY_RE.test(key) && item != null
|
|
206
|
+
? null
|
|
207
|
+
: walk(item);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
return toJsonSafe(input);
|
|
212
|
+
};
|
|
213
|
+
return walk(value) as T;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const HOSTED_MEDIA_URL_RE =
|
|
217
|
+
/https?:\/\/[a-z0-9-]+\.supabase\.co\/storage\/v1\/object\/public\/media\//gi;
|
|
218
|
+
|
|
219
|
+
export function rewriteMediaUrls<T>(value: T, base: string): T {
|
|
220
|
+
const prefix = base.endsWith("/") ? base : `${base}/`;
|
|
221
|
+
const walk = (input: unknown): unknown => {
|
|
222
|
+
if (typeof input === "string") return input.replace(HOSTED_MEDIA_URL_RE, prefix);
|
|
223
|
+
if (Array.isArray(input)) return input.map(walk);
|
|
224
|
+
if (input && typeof input === "object") {
|
|
225
|
+
const out: Record<string, unknown> = {};
|
|
226
|
+
for (const [key, item] of Object.entries(input as Record<string, unknown>)) {
|
|
227
|
+
out[key] = walk(item);
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
return input;
|
|
232
|
+
};
|
|
233
|
+
return walk(value) as T;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function neutralizeInjection<T extends Record<string, unknown>>(
|
|
237
|
+
row: T,
|
|
238
|
+
keepEnabled: boolean,
|
|
239
|
+
): T {
|
|
240
|
+
return keepEnabled ? row : { ...row, enabled: false };
|
|
241
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { HOSTED_MEDIA_URL_RE } from "./safety";
|
|
2
|
+
|
|
3
|
+
export type UserRow = { id: string; email: string | null };
|
|
4
|
+
export type RoleRow = { user_id: string; role: string };
|
|
5
|
+
|
|
6
|
+
export function compareCounts(
|
|
7
|
+
expected: Record<string, number>,
|
|
8
|
+
actual: Record<string, number>,
|
|
9
|
+
insertOnly: string[] = [],
|
|
10
|
+
): string[] {
|
|
11
|
+
const problems: string[] = [];
|
|
12
|
+
for (const [table, wanted] of Object.entries(expected)) {
|
|
13
|
+
const found = actual[table];
|
|
14
|
+
if (found == null) problems.push(`${table}: table missing in target`);
|
|
15
|
+
else if (found !== wanted && !(insertOnly.includes(table) && found > wanted)) {
|
|
16
|
+
problems.push(`${table}: expected ${wanted} rows, found ${found}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return problems;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function compareUsersAndRoles(
|
|
23
|
+
expectedUsers: UserRow[],
|
|
24
|
+
expectedRoles: RoleRow[],
|
|
25
|
+
actualUsers: UserRow[],
|
|
26
|
+
actualRoles: RoleRow[],
|
|
27
|
+
): string[] {
|
|
28
|
+
const problems: string[] = [];
|
|
29
|
+
const actualById = new Map(actualUsers.map((user) => [user.id, user]));
|
|
30
|
+
for (const user of expectedUsers) {
|
|
31
|
+
const found = actualById.get(user.id);
|
|
32
|
+
if (!found) problems.push(`user ${user.id} is missing`);
|
|
33
|
+
else if ((found.email ?? null) !== (user.email ?? null)) {
|
|
34
|
+
problems.push(`user ${user.id} has a different email`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const key = (role: RoleRow) => `${role.user_id}:${role.role}`;
|
|
38
|
+
const actualRoleSet = new Set(actualRoles.map(key));
|
|
39
|
+
for (const role of expectedRoles) {
|
|
40
|
+
if (!actualRoleSet.has(key(role))) {
|
|
41
|
+
problems.push(`role ${role.role} for ${role.user_id} is missing`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const role of actualRoles.filter((item) => !actualById.has(item.user_id))) {
|
|
45
|
+
problems.push(`role ${role.role} references unknown user ${role.user_id}`);
|
|
46
|
+
}
|
|
47
|
+
return problems;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function findHostedMediaUrls(rows: { table: string; json: string }[]): string[] {
|
|
51
|
+
const problems: string[] = [];
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const expression = new RegExp(HOSTED_MEDIA_URL_RE.source, "gi");
|
|
54
|
+
const match = expression.exec(row.json);
|
|
55
|
+
if (match) problems.push(`${row.table}: still references ${match[0]}`);
|
|
56
|
+
}
|
|
57
|
+
return problems;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function findMissingMedia(expected: string[], present: Set<string>): string[] {
|
|
61
|
+
return expected
|
|
62
|
+
.filter((path) => !present.has(path))
|
|
63
|
+
.map((path) => `media file missing on disk: ${path}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type VerifyReport = { checks: { name: string; problems: string[] }[] };
|
|
67
|
+
|
|
68
|
+
export function reportOk(report: VerifyReport): boolean {
|
|
69
|
+
return report.checks.every((check) => check.problems.length === 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatReport(report: VerifyReport): string {
|
|
73
|
+
const lines: string[] = [];
|
|
74
|
+
for (const check of report.checks) {
|
|
75
|
+
lines.push(`[${check.problems.length === 0 ? "PASS" : "FAIL"}] ${check.name}`);
|
|
76
|
+
for (const problem of check.problems) lines.push(` - ${problem}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push(reportOk(report) ? "\nVerification passed." : "\nVerification FAILED.");
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
package/src/host/index.tsx
CHANGED
|
@@ -89,6 +89,22 @@ export function registerHostComponents(components: Partial<HostComponents>): voi
|
|
|
89
89
|
hosts = { ...hosts, ...components };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Register a site's host components as part of constructing a value the site
|
|
94
|
+
* actually exports or renders.
|
|
95
|
+
*
|
|
96
|
+
* Prefer this over a side-effect-only import. Applications whose package
|
|
97
|
+
* metadata declares `sideEffects: false` may otherwise lose their registration
|
|
98
|
+
* module during an optimized build even though development still works.
|
|
99
|
+
*/
|
|
100
|
+
export function initializeHostComponents<T>(
|
|
101
|
+
components: Partial<HostComponents>,
|
|
102
|
+
initialize: () => T,
|
|
103
|
+
): T {
|
|
104
|
+
registerHostComponents(components);
|
|
105
|
+
return initialize();
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
function missing(name: keyof HostComponents): never {
|
|
93
109
|
throw new Error(
|
|
94
110
|
`@bygghammar/cms-platform: host "${name}" is not registered. Call registerHostComponents() during site startup.`,
|