@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.
Files changed (41) hide show
  1. package/dist/api.d.ts +43 -2
  2. package/dist/api.js +13 -3
  3. package/dist/app/App.js +10 -4
  4. package/dist/app/app.css +5 -0
  5. package/dist/app/lib/publish.js +4 -1
  6. package/dist/app/lib/settings.d.ts +2 -0
  7. package/dist/app/lib/settings.js +54 -8
  8. package/dist/app/routes/MediaSettingsEditor.d.ts +4 -0
  9. package/dist/app/routes/MediaSettingsEditor.js +4 -0
  10. package/dist/app/routes/SiteSettingsEditor.d.ts +3 -1
  11. package/dist/app/routes/SiteSettingsEditor.js +3 -2
  12. package/dist/app/routes/UploadDock.js +31 -9
  13. package/dist/app/site.d.ts +4 -0
  14. package/dist/deployment.js +14 -1
  15. package/dist/form/media/MediaFolders.d.ts +5 -1
  16. package/dist/form/media/MediaFolders.js +3 -2
  17. package/dist/form/media/image-conversion.d.ts +14 -0
  18. package/dist/form/media/image-conversion.js +163 -0
  19. package/dist/form/media/media-upload-queue.d.ts +31 -0
  20. package/dist/form/media/media-upload-queue.js +230 -0
  21. package/dist/form/media/useMediaLibrary.d.ts +3 -0
  22. package/dist/form/media/useMediaLibrary.js +72 -18
  23. package/dist/functions/_shared/assets.js +3 -2
  24. package/dist/functions/api/assets/folders.js +2 -1
  25. package/dist/functions/api/assets/settings.js +40 -0
  26. package/dist/functions/api/assets/upload.js +4 -1
  27. package/dist/functions/api/assets.js +4 -3
  28. package/dist/functions/api/site-access.js +78 -0
  29. package/dist/functions/index.js +4 -0
  30. package/dist/functions/site.js +4 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/migrations/0004_site_access.sql +41 -0
  34. package/dist/persistence.d.ts +1 -0
  35. package/dist/persistence.js +22 -4
  36. package/dist/release.d.ts +3 -0
  37. package/dist/release.js +7 -0
  38. package/dist/site-access.d.ts +48 -0
  39. package/dist/site-access.js +180 -0
  40. package/dist/theme.css +1 -0
  41. 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/") || pending.has(k)) continue;
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
+ }
@@ -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/";
@@ -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
@@ -14,3 +14,4 @@ export * from "./conflict.js";
14
14
  export * from "./release.js";
15
15
  export * from "./deployment.js";
16
16
  export * from "./media.js";
17
+ export * from "./site-access.js";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export * from "./conflict.js";
14
14
  export * from "./release.js";
15
15
  export * from "./deployment.js";
16
16
  export * from "./media.js";
17
+ export * from "./site-access.js";
@@ -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;
@@ -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: {
@@ -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.11",
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
  }