@cardinali-studio/adminkit 2.2.11 → 2.2.12
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/dist/api.d.ts +43 -2
- package/dist/api.js +13 -3
- package/dist/app/App.js +10 -4
- package/dist/app/app.css +5 -0
- package/dist/app/lib/publish.js +4 -1
- package/dist/app/lib/settings.d.ts +2 -0
- package/dist/app/lib/settings.js +54 -8
- package/dist/app/routes/MediaSettingsEditor.d.ts +4 -0
- package/dist/app/routes/MediaSettingsEditor.js +4 -0
- package/dist/app/routes/SiteSettingsEditor.d.ts +3 -1
- package/dist/app/routes/SiteSettingsEditor.js +3 -2
- package/dist/app/routes/UploadDock.js +31 -9
- package/dist/app/site.d.ts +4 -0
- package/dist/deployment.js +14 -1
- package/dist/form/media/MediaFolders.d.ts +5 -1
- package/dist/form/media/MediaFolders.js +3 -2
- package/dist/form/media/image-conversion.d.ts +14 -0
- package/dist/form/media/image-conversion.js +163 -0
- package/dist/form/media/media-upload-queue.d.ts +31 -0
- package/dist/form/media/media-upload-queue.js +230 -0
- package/dist/form/media/useMediaLibrary.d.ts +3 -0
- package/dist/form/media/useMediaLibrary.js +72 -18
- package/dist/functions/_shared/assets.js +3 -2
- package/dist/functions/api/assets/folders.js +2 -1
- package/dist/functions/api/assets/settings.js +40 -0
- package/dist/functions/api/assets/upload.js +4 -1
- package/dist/functions/api/assets.js +4 -3
- package/dist/functions/api/site-access.js +78 -0
- package/dist/functions/index.js +4 -0
- package/dist/functions/site.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migrations/0004_site_access.sql +41 -0
- package/dist/persistence.d.ts +1 -0
- package/dist/persistence.js +22 -4
- package/dist/release.d.ts +3 -0
- package/dist/release.js +7 -0
- package/dist/site-access.d.ts +48 -0
- package/dist/site-access.js +180 -0
- package/dist/theme.css +1 -0
- package/package.json +4 -1
|
@@ -25,7 +25,8 @@ export async function onRequestGet({ request, env }) {
|
|
|
25
25
|
const subPrefixes = new Set();
|
|
26
26
|
const files = [];
|
|
27
27
|
for (const k of all) {
|
|
28
|
-
if (k.startsWith("_trash/") || k.startsWith("_pending-media/") ||
|
|
28
|
+
if (k.startsWith("_trash/") || k.startsWith("_pending-media/") ||
|
|
29
|
+
k.startsWith("_adminkit/") || pending.has(k)) continue;
|
|
29
30
|
const rel = k.slice(prefix.length);
|
|
30
31
|
const cut = rel.lastIndexOf("/");
|
|
31
32
|
if (cut >= 0) {
|
|
@@ -48,7 +49,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
48
49
|
// suo `.keep` (vedi assets/folder.js). Chi non ce l'ha mostra lo slug, come sempre.
|
|
49
50
|
const folders = await Promise.all(
|
|
50
51
|
(listed.delimitedPrefixes || [])
|
|
51
|
-
.filter((p) => p !== "_trash/" && p !== "_pending-media/")
|
|
52
|
+
.filter((p) => p !== "_trash/" && p !== "_pending-media/" && p !== "_adminkit/")
|
|
52
53
|
.map(async (p) => {
|
|
53
54
|
const keep = await env.MEDIA.head(p + ".keep");
|
|
54
55
|
return {
|
|
@@ -66,7 +67,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
66
67
|
// oggi. È un ripiego, non la strada normale: una volta ricaricate, il ramo non scatta più.
|
|
67
68
|
const raw = (listed.objects || [])
|
|
68
69
|
.filter((o) => o.key !== prefix && !o.key.endsWith("/.keep") && !o.key.endsWith("/") &&
|
|
69
|
-
!o.key.startsWith("_pending-media/") && !pending.has(o.key));
|
|
70
|
+
!o.key.startsWith("_pending-media/") && !o.key.startsWith("_adminkit/") && !pending.has(o.key));
|
|
70
71
|
|
|
71
72
|
const files = await Promise.all(raw.map(async (o) => {
|
|
72
73
|
const cm = o.customMetadata || {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { canonicalChecksum } from "../../persistence.js";
|
|
2
|
+
import { createSiteAccessVersion, D1SiteAccessRepository } from "../../site-access.js";
|
|
3
|
+
import { siteAccessId } from "../site.js";
|
|
4
|
+
import { json } from "../_shared/assets.js";
|
|
5
|
+
|
|
6
|
+
const MAX_BODY_BYTES = 2_000;
|
|
7
|
+
const id = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
|
|
9
|
+
function response(data, status = 200) {
|
|
10
|
+
const result = json(data, status);
|
|
11
|
+
result.headers.set("Cache-Control", "no-store");
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function editor(data, env) {
|
|
16
|
+
const payload = data?.cloudflareAccess?.JWT?.payload;
|
|
17
|
+
if (payload?.sub) return { subject: String(payload.sub), label: String(payload.email || payload.name || "Admin").slice(0, 200) };
|
|
18
|
+
if (env.ADMIN_LOCAL === "1") return { subject: "local-preview", label: "Anteprima locale" };
|
|
19
|
+
throw new Error("unauthenticated");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function required(env) {
|
|
23
|
+
const siteId = siteAccessId();
|
|
24
|
+
if (!env.CONTENT_DB || !siteId) throw new Error("site_access_unavailable");
|
|
25
|
+
return { siteId, repository: new D1SiteAccessRepository(env.CONTENT_DB) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function publicState(state) {
|
|
29
|
+
const clean = (value) => value ? { versionId: value.versionId, enabled: value.enabled, formatVersion: value.formatVersion } : null;
|
|
30
|
+
return { active: clean(state.active), draft: clean(state.draft), effective: clean(state.draft ?? state.active) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeError(error) {
|
|
34
|
+
const code = error?.message;
|
|
35
|
+
const status = code === "unauthenticated" ? 401 : code === "invalid_request" || code === "invalid_site_access_password" ? 400
|
|
36
|
+
: code === "stale_site_access" ? 409 : 503;
|
|
37
|
+
const message = code === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare."
|
|
38
|
+
: code === "invalid_site_access_password" ? "La password deve contenere almeno 8 caratteri."
|
|
39
|
+
: code === "stale_site_access" ? "La protezione è stata modificata altrove: ricarica la pagina."
|
|
40
|
+
: status === 400 ? "Richiesta non valida." : "La gestione della password non è disponibile.";
|
|
41
|
+
return response({ ok: false, code: status >= 500 ? "site_access_unavailable" : code, error: message }, status);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function onRequestGet({ env, data }) {
|
|
45
|
+
try {
|
|
46
|
+
editor(data, env);
|
|
47
|
+
const { repository } = required(env);
|
|
48
|
+
await repository.initialize(Date.now());
|
|
49
|
+
return response({ ok: true, state: publicState(await repository.state()) });
|
|
50
|
+
} catch (error) { return safeError(error); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function onRequestPost({ request, env, data }) {
|
|
54
|
+
try {
|
|
55
|
+
const origin = request.headers.get("Origin");
|
|
56
|
+
if (!origin || new URL(origin).origin !== new URL(request.url).origin) throw new Error("invalid_request");
|
|
57
|
+
if (Number(request.headers.get("Content-Length") || 0) > MAX_BODY_BYTES) throw new Error("invalid_request");
|
|
58
|
+
const raw = await request.text();
|
|
59
|
+
if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) throw new Error("invalid_request");
|
|
60
|
+
const body = JSON.parse(raw);
|
|
61
|
+
const author = editor(data, env);
|
|
62
|
+
const { siteId, repository } = required(env);
|
|
63
|
+
await repository.initialize(Date.now());
|
|
64
|
+
if (body?.action === "discard") {
|
|
65
|
+
if (!id.test(body.expectedDraftVersionId || "")) throw new Error("invalid_request");
|
|
66
|
+
await repository.discard(body.expectedDraftVersionId, Date.now());
|
|
67
|
+
return response({ ok: true, discarded: true, state: publicState(await repository.state()) });
|
|
68
|
+
}
|
|
69
|
+
if (body?.action !== "save" || typeof body.enabled !== "boolean"
|
|
70
|
+
|| (body.expectedDraftVersionId !== null && !id.test(body.expectedDraftVersionId || ""))) throw new Error("invalid_request");
|
|
71
|
+
const versionId = crypto.randomUUID();
|
|
72
|
+
const access = await createSiteAccessVersion({ versionId, siteId, enabled: body.enabled, password: body.password, keyring: env.SITE_ACCESS_KEYRING });
|
|
73
|
+
const requestChecksum = await canonicalChecksum({ action: "site-access.save", versionId, enabled: access.enabled, descriptorChecksum: access.descriptorChecksum });
|
|
74
|
+
await repository.saveDraft({ access, expectedDraftVersionId: body.expectedDraftVersionId, requestChecksum,
|
|
75
|
+
authorSubject: author.subject, authorLabel: author.label, now: Date.now() });
|
|
76
|
+
return response({ ok: true, saved: true, state: publicState(await repository.state()) });
|
|
77
|
+
} catch (error) { return safeError(error); }
|
|
78
|
+
}
|
package/dist/functions/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import * as assetsFolders from "./api/assets/folders.js";
|
|
|
32
32
|
import * as assetsMove from "./api/assets/move.js";
|
|
33
33
|
import * as assetsMedia from "./api/assets/media.js";
|
|
34
34
|
import * as assetsPreview from "./api/assets/preview.js";
|
|
35
|
+
import * as assetsSettings from "./api/assets/settings.js";
|
|
35
36
|
import * as assetsTrash from "./api/assets/trash.js";
|
|
36
37
|
import * as assetsTrashDelete from "./api/assets/trash/delete.js";
|
|
37
38
|
import * as assetsTrashEmpty from "./api/assets/trash/empty.js";
|
|
@@ -50,6 +51,7 @@ import * as settings from "./api/settings.js";
|
|
|
50
51
|
import * as singleton from "./api/singleton.js";
|
|
51
52
|
import * as storage from "./api/storage.js";
|
|
52
53
|
import * as content from "./api/content.js";
|
|
54
|
+
import * as siteAccess from "./api/site-access.js";
|
|
53
55
|
|
|
54
56
|
export { adminAccess } from "./_middleware.js";
|
|
55
57
|
export { setServerConfig } from "./site.js";
|
|
@@ -69,6 +71,7 @@ const ROUTES = {
|
|
|
69
71
|
"assets/move": { mod: null, h: assetsMove },
|
|
70
72
|
"assets/media": { mod: null, h: assetsMedia },
|
|
71
73
|
"assets/preview": { mod: null, h: assetsPreview },
|
|
74
|
+
"assets/settings": { mod: null, h: assetsSettings },
|
|
72
75
|
"assets/trash": { mod: null, h: assetsTrash },
|
|
73
76
|
"assets/trash/delete": { mod: null, h: assetsTrashDelete },
|
|
74
77
|
"assets/trash/empty": { mod: null, h: assetsTrashEmpty },
|
|
@@ -90,6 +93,7 @@ const ROUTES = {
|
|
|
90
93
|
"content/document": { mod: null, h: content },
|
|
91
94
|
"content/save": { mod: null, h: content },
|
|
92
95
|
"content/discard": { mod: null, h: content },
|
|
96
|
+
"content/site-access": { mod: null, h: siteAccess },
|
|
93
97
|
};
|
|
94
98
|
|
|
95
99
|
const PREFIX = "/admin/api/";
|
package/dist/functions/site.js
CHANGED
|
@@ -58,6 +58,10 @@ export function contentPersistenceMode() {
|
|
|
58
58
|
return current?.contentPersistence === "d1" ? "d1" : "github";
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function siteAccessId() {
|
|
62
|
+
return typeof current?.siteAccess?.siteId === "string" && current.siteAccess.siteId ? current.siteAccess.siteId : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
61
65
|
/** D1 owns persistence, never a site's editorial meaning. A site explicitly
|
|
62
66
|
* supplies both functions before its D1 mode can accept a write: one validates
|
|
63
67
|
* (and may normalize) the complete document and the other produces the
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
PRAGMA foreign_keys = ON;
|
|
2
|
+
|
|
3
|
+
CREATE TABLE site_access_versions (
|
|
4
|
+
version_id TEXT PRIMARY KEY,
|
|
5
|
+
state TEXT NOT NULL CHECK (state IN ('draft','active','superseded','expired','erased')),
|
|
6
|
+
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
|
7
|
+
format_version INTEGER NOT NULL CHECK (format_version = 1),
|
|
8
|
+
key_id TEXT,
|
|
9
|
+
nonce TEXT,
|
|
10
|
+
ciphertext TEXT,
|
|
11
|
+
request_checksum TEXT NOT NULL UNIQUE CHECK (length(request_checksum) = 64 AND request_checksum NOT GLOB '*[^0-9a-f]*'),
|
|
12
|
+
created_by_subject TEXT NOT NULL,
|
|
13
|
+
created_by_label TEXT NOT NULL,
|
|
14
|
+
created_at INTEGER NOT NULL,
|
|
15
|
+
activated_at INTEGER,
|
|
16
|
+
superseded_at INTEGER,
|
|
17
|
+
rollback_expires_at INTEGER,
|
|
18
|
+
erased_at INTEGER,
|
|
19
|
+
CHECK ((enabled = 0 AND key_id IS NULL AND nonce IS NULL AND ciphertext IS NULL)
|
|
20
|
+
OR (enabled = 1 AND key_id IS NOT NULL AND nonce IS NOT NULL AND ciphertext IS NOT NULL))
|
|
21
|
+
);
|
|
22
|
+
CREATE UNIQUE INDEX site_access_one_draft ON site_access_versions(state) WHERE state = 'draft';
|
|
23
|
+
CREATE UNIQUE INDEX site_access_one_active ON site_access_versions(state) WHERE state = 'active';
|
|
24
|
+
CREATE TRIGGER site_access_versions_immutable
|
|
25
|
+
BEFORE UPDATE OF version_id, enabled, format_version, key_id, nonce, ciphertext, request_checksum,
|
|
26
|
+
created_by_subject, created_by_label, created_at ON site_access_versions
|
|
27
|
+
BEGIN SELECT RAISE(ABORT, 'site access version fields are immutable'); END;
|
|
28
|
+
|
|
29
|
+
CREATE TABLE site_access_state (
|
|
30
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
31
|
+
active_version_id TEXT REFERENCES site_access_versions(version_id) ON DELETE RESTRICT,
|
|
32
|
+
draft_version_id TEXT REFERENCES site_access_versions(version_id) ON DELETE RESTRICT,
|
|
33
|
+
updated_at INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE content_release_site_access (
|
|
37
|
+
release_id TEXT PRIMARY KEY REFERENCES content_releases(release_id) ON DELETE RESTRICT,
|
|
38
|
+
version_id TEXT NOT NULL REFERENCES site_access_versions(version_id) ON DELETE RESTRICT
|
|
39
|
+
);
|
|
40
|
+
CREATE TRIGGER content_release_site_access_immutable BEFORE UPDATE ON content_release_site_access
|
|
41
|
+
BEGIN SELECT RAISE(ABORT, 'release site access binding is immutable'); END;
|
package/dist/persistence.d.ts
CHANGED
|
@@ -119,6 +119,7 @@ export type ReleaseInput = {
|
|
|
119
119
|
* manifest. The SQL guard rejects any candidate still containing a source
|
|
120
120
|
* URL, so a caller cannot make cleanup race a public reference. */
|
|
121
121
|
mediaOperationIds?: readonly string[];
|
|
122
|
+
siteAccessVersionId?: string | null;
|
|
122
123
|
};
|
|
123
124
|
export type ReleaseDeploymentInput = ReleaseInput & {
|
|
124
125
|
attempt: {
|
package/dist/persistence.js
CHANGED
|
@@ -135,20 +135,31 @@ export class D1ContentRepository {
|
|
|
135
135
|
release.schema_package_version AS schemaPackageVersion, release.materializer_version AS materializerVersion,
|
|
136
136
|
release.manifest_checksum AS manifestChecksum, entry.document_id AS documentId, entry.revision_id AS revisionId,
|
|
137
137
|
document.kind, document.locale, revision.schema_version AS schemaVersion, revision.is_tombstone AS tombstone,
|
|
138
|
-
revision.payload_json AS payloadJson, revision.payload_checksum AS payloadChecksum
|
|
138
|
+
revision.payload_json AS payloadJson, revision.payload_checksum AS payloadChecksum,
|
|
139
|
+
access.version_id AS accessVersionId, access.enabled AS accessEnabled, access.format_version AS accessFormatVersion,
|
|
140
|
+
access.key_id AS accessKeyId, access.nonce AS accessNonce, access.ciphertext AS accessCiphertext
|
|
139
141
|
FROM content_releases release
|
|
140
142
|
JOIN content_release_entries entry ON entry.release_id = release.release_id
|
|
141
143
|
JOIN content_documents document ON document.document_id = entry.document_id
|
|
142
144
|
JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
|
|
145
|
+
LEFT JOIN content_release_site_access release_access ON release_access.release_id = release.release_id
|
|
146
|
+
LEFT JOIN site_access_versions access ON access.version_id = release_access.version_id
|
|
143
147
|
WHERE release.release_id = ? AND release.status IN ('publishing', 'published')
|
|
144
148
|
ORDER BY entry.document_id`).bind(releaseId).all()).results ?? [];
|
|
145
149
|
if (!rows.length)
|
|
146
150
|
return null;
|
|
147
151
|
const [first] = rows;
|
|
152
|
+
let siteAccess = null;
|
|
153
|
+
if (first.accessVersionId && first.accessFormatVersion === 1) {
|
|
154
|
+
const covered = { versionId: first.accessVersionId, enabled: Boolean(first.accessEnabled), formatVersion: 1,
|
|
155
|
+
keyId: first.accessKeyId, nonce: first.accessNonce, ciphertext: first.accessCiphertext };
|
|
156
|
+
siteAccess = { ...covered, descriptorChecksum: await canonicalChecksum(covered) };
|
|
157
|
+
}
|
|
148
158
|
return {
|
|
149
159
|
releaseId: first.releaseId, status: first.status, schemaPackageVersion: first.schemaPackageVersion,
|
|
150
160
|
materializerVersion: first.materializerVersion, manifestChecksum: first.manifestChecksum,
|
|
151
161
|
entries: rows.map(({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadJson, payloadChecksum }) => ({ documentId, revisionId, kind, locale, schemaVersion, tombstone: Boolean(tombstone), payloadJson, payloadChecksum })),
|
|
162
|
+
siteAccess,
|
|
152
163
|
};
|
|
153
164
|
}
|
|
154
165
|
async save(input) {
|
|
@@ -320,16 +331,18 @@ export class D1ContentRepository {
|
|
|
320
331
|
const manifest = new Map(inherited.map((entry) => [entry.document_id, entry.revision_id]));
|
|
321
332
|
const selected = [...input.entries];
|
|
322
333
|
const mediaOperationIds = [...new Set(input.mediaOperationIds ?? [])];
|
|
323
|
-
if (!selected.length && !mediaOperationIds.length)
|
|
334
|
+
if (!selected.length && !mediaOperationIds.length && !input.siteAccessVersionId)
|
|
324
335
|
throw new Error("invalid_document");
|
|
325
336
|
for (const entry of selected)
|
|
326
337
|
manifest.set(entry.documentId, entry.revisionId);
|
|
327
338
|
const selectionGuard = selected.length ? selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ") : "1 = 1";
|
|
339
|
+
const accessGuard = input.siteAccessVersionId ? `AND EXISTS (SELECT 1 FROM site_access_versions version JOIN site_access_state access_state ON access_state.singleton = 1
|
|
340
|
+
WHERE version.version_id = ? AND version.state IN ('draft','active') AND version.version_id IN (access_state.draft_version_id, access_state.active_version_id))` : "";
|
|
328
341
|
const candidate = this.db.prepare(`INSERT INTO content_releases (release_id, base_release_id, request_checksum, status, active_slot, manifest_checksum, schema_package_version, materializer_version, created_by_subject, created_at)
|
|
329
342
|
SELECT ?, current_release_id, ?, 'publishing', 1, ?, ?, ?, ?, ? FROM content_site_state
|
|
330
343
|
WHERE singleton = 1 AND current_release_id IS ? AND NOT EXISTS (SELECT 1 FROM content_releases WHERE active_slot = 1)
|
|
331
|
-
AND NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1) AND ${selectionGuard}`)
|
|
332
|
-
.bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]));
|
|
344
|
+
AND NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1) AND ${selectionGuard} ${accessGuard}`)
|
|
345
|
+
.bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]), ...(input.siteAccessVersionId ? [input.siteAccessVersionId] : []));
|
|
333
346
|
const statements = [];
|
|
334
347
|
if (mediaOperationIds.length) {
|
|
335
348
|
const revisionIds = [...manifest.values()];
|
|
@@ -352,6 +365,11 @@ export class D1ContentRepository {
|
|
|
352
365
|
for (const [documentId, revisionId] of manifest)
|
|
353
366
|
statements.push(this.db.prepare("INSERT INTO content_release_entries (release_id, document_id, revision_id) SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)")
|
|
354
367
|
.bind(input.releaseId, documentId, revisionId, documentId, revisionId));
|
|
368
|
+
if (input.siteAccessVersionId)
|
|
369
|
+
statements.push(this.db.prepare(`INSERT INTO content_release_site_access (release_id, version_id)
|
|
370
|
+
SELECT ?, ? WHERE EXISTS (SELECT 1 FROM site_access_versions version JOIN site_access_state access_state ON access_state.singleton = 1
|
|
371
|
+
WHERE version.version_id = ? AND version.state IN ('draft','active') AND version.version_id IN (access_state.draft_version_id, access_state.active_version_id))`)
|
|
372
|
+
.bind(input.releaseId, input.siteAccessVersionId, input.siteAccessVersionId));
|
|
355
373
|
for (const operationId of mediaOperationIds) {
|
|
356
374
|
/* `instr` is intentionally evaluated against the frozen release entries,
|
|
357
375
|
* not drafts or a mutable current pointer. A URL is JSON-escaped neither
|
package/dist/release.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type JsonValue } from "./persistence.js";
|
|
2
|
+
import type { SiteAccessReleaseDescriptor } from "./site-access.js";
|
|
2
3
|
export type ReleaseExportEntry = {
|
|
3
4
|
documentId: string;
|
|
4
5
|
revisionId: string;
|
|
@@ -15,6 +16,7 @@ export type ImmutableReleaseExport = {
|
|
|
15
16
|
materializerVersion: string;
|
|
16
17
|
manifestChecksum: string;
|
|
17
18
|
entries: readonly ReleaseExportEntry[];
|
|
19
|
+
siteAccess?: SiteAccessReleaseDescriptor | null;
|
|
18
20
|
};
|
|
19
21
|
/** Raw D1 snapshot. The repository returns this exact release only; converting
|
|
20
22
|
* it to an export revalidates every payload/checksum before a CI consumer sees
|
|
@@ -28,6 +30,7 @@ export type StoredReleaseExport = {
|
|
|
28
30
|
entries: readonly (Omit<ReleaseExportEntry, "payload"> & {
|
|
29
31
|
payloadJson: string | null;
|
|
30
32
|
})[];
|
|
33
|
+
siteAccess?: SiteAccessReleaseDescriptor | null;
|
|
31
34
|
};
|
|
32
35
|
/** A release export is an immutable, sorted full snapshot. It is never a
|
|
33
36
|
* convenience view of current drafts, so a CI attempt can reproduce exactly
|
package/dist/release.js
CHANGED
|
@@ -17,10 +17,16 @@ export async function createReleaseExport(input) {
|
|
|
17
17
|
if (entry.payloadChecksum !== await canonicalChecksum(entry.payload))
|
|
18
18
|
throw new Error("invalid_release_export");
|
|
19
19
|
}
|
|
20
|
+
if (input.siteAccess) {
|
|
21
|
+
const { descriptorChecksum, ...covered } = input.siteAccess;
|
|
22
|
+
if (descriptorChecksum !== await canonicalChecksum(covered))
|
|
23
|
+
throw new Error("invalid_release_export");
|
|
24
|
+
}
|
|
20
25
|
const manifestChecksum = await canonicalChecksum({
|
|
21
26
|
schemaPackageVersion: input.schemaPackageVersion,
|
|
22
27
|
materializerVersion: input.materializerVersion,
|
|
23
28
|
entries: entries.map(({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadChecksum }) => ({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadChecksum })),
|
|
29
|
+
siteAccess: input.siteAccess ? { versionId: input.siteAccess.versionId, descriptorChecksum: input.siteAccess.descriptorChecksum } : null,
|
|
24
30
|
});
|
|
25
31
|
return { ...input, entries, manifestChecksum };
|
|
26
32
|
}
|
|
@@ -34,6 +40,7 @@ export async function releaseExportFromStored(snapshot) {
|
|
|
34
40
|
schemaVersion: entry.schemaVersion, tombstone: entry.tombstone,
|
|
35
41
|
payload: entry.payloadJson === null ? null : JSON.parse(entry.payloadJson), payloadChecksum: entry.payloadChecksum,
|
|
36
42
|
})),
|
|
43
|
+
...(snapshot.siteAccess ? { siteAccess: snapshot.siteAccess } : {}),
|
|
37
44
|
});
|
|
38
45
|
if (release.manifestChecksum !== snapshot.manifestChecksum)
|
|
39
46
|
throw new Error("invalid_release_export");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type D1DatabaseLike } from "./persistence.js";
|
|
2
|
+
export declare const siteAccessPbkdf2Iterations = 100000;
|
|
3
|
+
export type SiteAccessReleaseDescriptor = Readonly<{
|
|
4
|
+
versionId: string;
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
formatVersion: 1;
|
|
7
|
+
keyId: string | null;
|
|
8
|
+
nonce: string | null;
|
|
9
|
+
ciphertext: string | null;
|
|
10
|
+
descriptorChecksum: string;
|
|
11
|
+
}>;
|
|
12
|
+
export type SiteAccessState = Readonly<{
|
|
13
|
+
active: SiteAccessReleaseDescriptor | null;
|
|
14
|
+
draft: SiteAccessReleaseDescriptor | null;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function createSiteAccessVersion(input: {
|
|
17
|
+
versionId: string;
|
|
18
|
+
siteId: string;
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
password?: string;
|
|
21
|
+
keyring?: unknown;
|
|
22
|
+
}): Promise<SiteAccessReleaseDescriptor>;
|
|
23
|
+
export declare function verifySiteAccessPassword(input: {
|
|
24
|
+
siteId: string;
|
|
25
|
+
access: SiteAccessReleaseDescriptor;
|
|
26
|
+
keyring: unknown;
|
|
27
|
+
password: string;
|
|
28
|
+
}): Promise<boolean>;
|
|
29
|
+
export declare function siteAccessSessionToken(input: {
|
|
30
|
+
siteId: string;
|
|
31
|
+
access: SiteAccessReleaseDescriptor;
|
|
32
|
+
keyring: unknown;
|
|
33
|
+
}): Promise<string>;
|
|
34
|
+
export declare class D1SiteAccessRepository {
|
|
35
|
+
private readonly db;
|
|
36
|
+
constructor(db: D1DatabaseLike);
|
|
37
|
+
initialize(now: number): Promise<void>;
|
|
38
|
+
state(): Promise<SiteAccessState>;
|
|
39
|
+
saveDraft(input: {
|
|
40
|
+
access: SiteAccessReleaseDescriptor;
|
|
41
|
+
expectedDraftVersionId: string | null;
|
|
42
|
+
requestChecksum: string;
|
|
43
|
+
authorSubject: string;
|
|
44
|
+
authorLabel: string;
|
|
45
|
+
now: number;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
discard(expectedDraftVersionId: string, now: number): Promise<void>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { canonicalChecksum, canonicalJson } from "./persistence.js";
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
const decoder = new TextDecoder();
|
|
4
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
5
|
+
const keyIdPattern = /^[A-Za-z0-9._-]{1,80}$/;
|
|
6
|
+
export const siteAccessPbkdf2Iterations = 100_000;
|
|
7
|
+
function asBuffer(bytes) {
|
|
8
|
+
return Uint8Array.from(bytes).buffer;
|
|
9
|
+
}
|
|
10
|
+
function bytesToBase64url(bytes) {
|
|
11
|
+
let binary = "";
|
|
12
|
+
for (const byte of bytes)
|
|
13
|
+
binary += String.fromCharCode(byte);
|
|
14
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
15
|
+
}
|
|
16
|
+
function base64urlToBytes(value) {
|
|
17
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value || ""))
|
|
18
|
+
throw new Error("site_access_unavailable");
|
|
19
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4);
|
|
20
|
+
try {
|
|
21
|
+
const binary = atob(padded);
|
|
22
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error("site_access_unavailable");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function parseKeyring(raw) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error("site_access_unavailable");
|
|
35
|
+
}
|
|
36
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
37
|
+
throw new Error("site_access_unavailable");
|
|
38
|
+
const value = parsed;
|
|
39
|
+
if (typeof value.current !== "string" || !keyIdPattern.test(value.current) || !value.keys || typeof value.keys !== "object" || Array.isArray(value.keys)) {
|
|
40
|
+
throw new Error("site_access_unavailable");
|
|
41
|
+
}
|
|
42
|
+
const keys = {};
|
|
43
|
+
for (const [keyId, encoded] of Object.entries(value.keys)) {
|
|
44
|
+
if (!keyIdPattern.test(keyId) || typeof encoded !== "string")
|
|
45
|
+
throw new Error("site_access_unavailable");
|
|
46
|
+
const bytes = base64urlToBytes(encoded);
|
|
47
|
+
if (bytes.byteLength !== 32)
|
|
48
|
+
throw new Error("site_access_unavailable");
|
|
49
|
+
keys[keyId] = bytes;
|
|
50
|
+
}
|
|
51
|
+
if (!keys[value.current])
|
|
52
|
+
throw new Error("site_access_unavailable");
|
|
53
|
+
return { current: value.current, keys };
|
|
54
|
+
}
|
|
55
|
+
async function hkdfKey(root, siteId, keyId, purpose, algorithm, usages) {
|
|
56
|
+
const material = await crypto.subtle.importKey("raw", asBuffer(root), "HKDF", false, ["deriveKey"]);
|
|
57
|
+
return crypto.subtle.deriveKey({
|
|
58
|
+
name: "HKDF", hash: "SHA-256",
|
|
59
|
+
salt: encoder.encode("adminkit.site-access.v1"),
|
|
60
|
+
info: encoder.encode(`${purpose}|${siteId}|${keyId}`),
|
|
61
|
+
}, material, algorithm, false, usages);
|
|
62
|
+
}
|
|
63
|
+
function aad(siteId, versionId) {
|
|
64
|
+
return encoder.encode(`adminkit.site-access.v1|${siteId}|${versionId}`);
|
|
65
|
+
}
|
|
66
|
+
async function descriptor(input) {
|
|
67
|
+
if (!uuid.test(input.versionId) || input.formatVersion !== 1)
|
|
68
|
+
throw new Error("invalid_site_access");
|
|
69
|
+
if (input.enabled !== Boolean(input.keyId && input.nonce && input.ciphertext))
|
|
70
|
+
throw new Error("invalid_site_access");
|
|
71
|
+
const descriptorChecksum = await canonicalChecksum(input);
|
|
72
|
+
return { ...input, descriptorChecksum };
|
|
73
|
+
}
|
|
74
|
+
export async function createSiteAccessVersion(input) {
|
|
75
|
+
if (!input.enabled)
|
|
76
|
+
return descriptor({ versionId: input.versionId, enabled: false, formatVersion: 1, keyId: null, nonce: null, ciphertext: null });
|
|
77
|
+
if (typeof input.password !== "string" || input.password.length < 8 || input.password.length > 200)
|
|
78
|
+
throw new Error("invalid_site_access_password");
|
|
79
|
+
const keyring = parseKeyring(input.keyring);
|
|
80
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
81
|
+
const passwordKey = await crypto.subtle.importKey("raw", encoder.encode(input.password), "PBKDF2", false, ["deriveBits"]);
|
|
82
|
+
const hash = new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: asBuffer(salt), iterations: siteAccessPbkdf2Iterations }, passwordKey, 256));
|
|
83
|
+
const plaintext = encoder.encode(canonicalJson({ version: 1, kdf: "PBKDF2-SHA256", iterations: siteAccessPbkdf2Iterations, salt: bytesToBase64url(salt), hash: bytesToBase64url(hash) }));
|
|
84
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
85
|
+
const encryptionKey = await hkdfKey(keyring.keys[keyring.current], input.siteId, keyring.current, "verifier", { name: "AES-GCM", length: 256 }, ["encrypt"]);
|
|
86
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(aad(input.siteId, input.versionId)), tagLength: 128 }, encryptionKey, plaintext));
|
|
87
|
+
return descriptor({ versionId: input.versionId, enabled: true, formatVersion: 1, keyId: keyring.current, nonce: bytesToBase64url(nonce), ciphertext: bytesToBase64url(ciphertext) });
|
|
88
|
+
}
|
|
89
|
+
async function decryptVerifier(input) {
|
|
90
|
+
const keyring = parseKeyring(input.keyring);
|
|
91
|
+
const keyId = input.access.keyId || "";
|
|
92
|
+
const root = keyring.keys[keyId];
|
|
93
|
+
if (!root || !input.access.nonce || !input.access.ciphertext)
|
|
94
|
+
throw new Error("site_access_unavailable");
|
|
95
|
+
try {
|
|
96
|
+
const key = await hkdfKey(root, input.siteId, keyId, "verifier", { name: "AES-GCM", length: 256 }, ["decrypt"]);
|
|
97
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: asBuffer(base64urlToBytes(input.access.nonce)), additionalData: asBuffer(aad(input.siteId, input.access.versionId)), tagLength: 128 }, key, asBuffer(base64urlToBytes(input.access.ciphertext)));
|
|
98
|
+
const value = JSON.parse(decoder.decode(plaintext));
|
|
99
|
+
if (value?.version !== 1 || value?.kdf !== "PBKDF2-SHA256" || value?.iterations !== siteAccessPbkdf2Iterations)
|
|
100
|
+
throw new Error("site_access_unavailable");
|
|
101
|
+
return { salt: base64urlToBytes(value.salt), hash: base64urlToBytes(value.hash), keyring, keyId };
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error("site_access_unavailable");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function constantTimeBytes(left, right) {
|
|
108
|
+
let difference = left.byteLength ^ right.byteLength;
|
|
109
|
+
for (let index = 0; index < Math.max(left.byteLength, right.byteLength); index += 1)
|
|
110
|
+
difference |= (left[index] || 0) ^ (right[index] || 0);
|
|
111
|
+
return difference === 0;
|
|
112
|
+
}
|
|
113
|
+
export async function verifySiteAccessPassword(input) {
|
|
114
|
+
if (!input.access.enabled)
|
|
115
|
+
return false;
|
|
116
|
+
const verifier = await decryptVerifier(input);
|
|
117
|
+
const passwordKey = await crypto.subtle.importKey("raw", encoder.encode(input.password), "PBKDF2", false, ["deriveBits"]);
|
|
118
|
+
const candidate = new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: asBuffer(verifier.salt), iterations: siteAccessPbkdf2Iterations }, passwordKey, 256));
|
|
119
|
+
return constantTimeBytes(candidate, verifier.hash);
|
|
120
|
+
}
|
|
121
|
+
export async function siteAccessSessionToken(input) {
|
|
122
|
+
if (!input.access.enabled || !input.access.keyId)
|
|
123
|
+
throw new Error("site_access_unavailable");
|
|
124
|
+
const keyring = parseKeyring(input.keyring);
|
|
125
|
+
const root = keyring.keys[input.access.keyId];
|
|
126
|
+
if (!root)
|
|
127
|
+
throw new Error("site_access_unavailable");
|
|
128
|
+
const key = await hkdfKey(root, input.siteId, input.access.keyId, "session", { name: "HMAC", hash: "SHA-256" }, ["sign"]);
|
|
129
|
+
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(`session|${input.access.versionId}`)));
|
|
130
|
+
return `${input.access.versionId}.${bytesToBase64url(signature)}`;
|
|
131
|
+
}
|
|
132
|
+
async function rowDescriptor(row) {
|
|
133
|
+
return row ? descriptor({ versionId: row.versionId, enabled: Boolean(row.enabled), formatVersion: row.formatVersion, keyId: row.keyId, nonce: row.nonce, ciphertext: row.ciphertext }) : null;
|
|
134
|
+
}
|
|
135
|
+
export class D1SiteAccessRepository {
|
|
136
|
+
db;
|
|
137
|
+
constructor(db) {
|
|
138
|
+
this.db = db;
|
|
139
|
+
}
|
|
140
|
+
async initialize(now) {
|
|
141
|
+
await this.db.prepare("INSERT INTO site_access_state (singleton, active_version_id, draft_version_id, updated_at) VALUES (1, NULL, NULL, ?) ON CONFLICT(singleton) DO NOTHING").bind(now).run();
|
|
142
|
+
}
|
|
143
|
+
async state() {
|
|
144
|
+
const rows = (await this.db.prepare(`SELECT version.version_id AS versionId, version.enabled, version.format_version AS formatVersion,
|
|
145
|
+
version.key_id AS keyId, version.nonce, version.ciphertext, version.state
|
|
146
|
+
FROM site_access_versions version JOIN site_access_state state ON state.singleton = 1
|
|
147
|
+
WHERE version.version_id IN (state.active_version_id, state.draft_version_id)`).all()).results ?? [];
|
|
148
|
+
return {
|
|
149
|
+
active: await rowDescriptor(rows.find((row) => row.state === "active") ?? null),
|
|
150
|
+
draft: await rowDescriptor(rows.find((row) => row.state === "draft") ?? null),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async saveDraft(input) {
|
|
154
|
+
const current = await this.db.prepare("SELECT draft_version_id AS draftVersionId FROM site_access_state WHERE singleton = 1").first();
|
|
155
|
+
if (!current || current.draftVersionId !== input.expectedDraftVersionId)
|
|
156
|
+
throw new Error("stale_site_access");
|
|
157
|
+
const statements = [];
|
|
158
|
+
if (input.expectedDraftVersionId)
|
|
159
|
+
statements.push(this.db.prepare(`UPDATE site_access_versions SET state = 'erased', erased_at = ?
|
|
160
|
+
WHERE version_id = ? AND state = 'draft'`).bind(input.now, input.expectedDraftVersionId));
|
|
161
|
+
statements.push(this.db.prepare(`INSERT INTO site_access_versions
|
|
162
|
+
(version_id, state, enabled, format_version, key_id, nonce, ciphertext, request_checksum, created_by_subject, created_by_label, created_at)
|
|
163
|
+
SELECT ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ? FROM site_access_state
|
|
164
|
+
WHERE singleton = 1 AND draft_version_id IS ?`)
|
|
165
|
+
.bind(input.access.versionId, Number(input.access.enabled), input.access.formatVersion, input.access.keyId, input.access.nonce, input.access.ciphertext, input.requestChecksum, input.authorSubject, input.authorLabel, input.now, input.expectedDraftVersionId));
|
|
166
|
+
statements.push(this.db.prepare("UPDATE site_access_state SET draft_version_id = ?, updated_at = ? WHERE singleton = 1 AND draft_version_id IS ?")
|
|
167
|
+
.bind(input.access.versionId, input.now, input.expectedDraftVersionId));
|
|
168
|
+
const results = await this.db.batch(statements);
|
|
169
|
+
if (results.at(-2)?.meta?.changes !== 1 || results.at(-1)?.meta?.changes !== 1)
|
|
170
|
+
throw new Error("stale_site_access");
|
|
171
|
+
}
|
|
172
|
+
async discard(expectedDraftVersionId, now) {
|
|
173
|
+
const results = await this.db.batch([
|
|
174
|
+
this.db.prepare("UPDATE site_access_versions SET state = 'erased', erased_at = ? WHERE version_id = ? AND state = 'draft'").bind(now, expectedDraftVersionId),
|
|
175
|
+
this.db.prepare("UPDATE site_access_state SET draft_version_id = NULL, updated_at = ? WHERE singleton = 1 AND draft_version_id = ?").bind(now, expectedDraftVersionId),
|
|
176
|
+
]);
|
|
177
|
+
if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1)
|
|
178
|
+
throw new Error("stale_site_access");
|
|
179
|
+
}
|
|
180
|
+
}
|
package/dist/theme.css
CHANGED
|
@@ -291,6 +291,7 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
|
|
|
291
291
|
.mf-root-drop.is-over { border-color: var(--ak-accent); color: var(--ak-accent); background: var(--ak-panel); }
|
|
292
292
|
/* Il cestino chiude la colonna, staccato: è un posto dove si va, non una cartella fra le altre. */
|
|
293
293
|
.mf-tail { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--ak-line); }
|
|
294
|
+
.mf-settings-tail { margin-top: 8px; }
|
|
294
295
|
.mf-ico { flex: none; color: var(--ak-ink-quiet); }
|
|
295
296
|
.mf-item.is-on .mf-ico { color: var(--ak-accent); }
|
|
296
297
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardinali-studio/adminkit",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -99,5 +99,8 @@
|
|
|
99
99
|
"typescript": "^5.9.3",
|
|
100
100
|
"wrangler": "4.114.0",
|
|
101
101
|
"zod": "^4.4.3"
|
|
102
|
+
},
|
|
103
|
+
"dependencies": {
|
|
104
|
+
"heic-to": "^1.3.0"
|
|
102
105
|
}
|
|
103
106
|
}
|