@cardinali-studio/adminkit 2.2.10 → 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 +58 -2
- package/dist/api.js +39 -5
- package/dist/app/App.js +10 -4
- package/dist/app/app.css +5 -0
- package/dist/app/lib/publish.js +9 -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/MediaGrid.js +10 -1
- 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 +7 -0
- package/dist/form/media/useMediaLibrary.js +123 -22
- package/dist/functions/_shared/assets.js +3 -2
- package/dist/functions/_shared/d1-media.js +317 -0
- package/dist/functions/api/assets/delete.js +19 -1
- package/dist/functions/api/assets/folders.js +5 -2
- package/dist/functions/api/assets/media.js +30 -0
- package/dist/functions/api/assets/move.js +22 -6
- package/dist/functions/api/assets/settings.js +40 -0
- package/dist/functions/api/assets/upload.js +4 -1
- package/dist/functions/api/assets/usage.js +5 -1
- package/dist/functions/api/assets.js +7 -4
- package/dist/functions/api/content-ci.js +8 -1
- package/dist/functions/api/site-access.js +78 -0
- package/dist/functions/index.js +6 -0
- package/dist/functions/site.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/media.d.ts +52 -0
- package/dist/media.js +103 -1
- package/dist/migrations/0004_site_access.sql +41 -0
- package/dist/persistence.d.ts +10 -0
- package/dist/persistence.js +129 -8
- 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 +13 -0
- package/package.json +4 -1
|
@@ -14,6 +14,8 @@ import { mediaUrl } from "../../_shared/refs.js";
|
|
|
14
14
|
import { rewriteMediaReferences } from "../../_shared/media-content.js";
|
|
15
15
|
import { getBase } from "../../_shared/github.js";
|
|
16
16
|
import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
|
|
17
|
+
import { contentPersistenceMode } from "../../site.js";
|
|
18
|
+
import { d1MoveMedia } from "../../_shared/d1-media.js";
|
|
17
19
|
|
|
18
20
|
function mediaKey(url) {
|
|
19
21
|
if (typeof url !== "string" || !url.startsWith("/media/")) return null;
|
|
@@ -128,8 +130,27 @@ async function confirmRewrittenCommit(env, commitSha) {
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
export async function onRequestPost({ request, env }) {
|
|
133
|
+
export async function onRequestPost({ request, env, data }) {
|
|
132
134
|
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
135
|
+
let body;
|
|
136
|
+
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
137
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
138
|
+
return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (contentPersistenceMode() === "d1") {
|
|
142
|
+
/* The former two-step Git protocol must never become a second editorial
|
|
143
|
+
* writer after cutover. A stale browser's applyRefs call is harmless: the
|
|
144
|
+
* first D1 command already created the revisions and journal intent. */
|
|
145
|
+
if (Object.hasOwn(body, "applyRefs")) return json({ ok: true, moved: 0, pagesUpdated: [], failed: [], d1: true });
|
|
146
|
+
try {
|
|
147
|
+
const result = await d1MoveMedia({ env, data, body });
|
|
148
|
+
return json({ ok: true, ...result, pagesUpdated: result.documents.map((document) => document.kind), failed: [], warning: "Le sorgenti restano disponibili fino alla pubblicazione." });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
|
|
151
|
+
return json({ ok: false, code, error: code === "media_source_busy" ? "Questo Media è già in un'operazione in corso." : "Non riesco a registrare l'operazione Media D1." }, code === "unauthenticated" ? 401 : 409);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
133
154
|
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
|
134
155
|
return json({
|
|
135
156
|
ok: false,
|
|
@@ -137,11 +158,6 @@ export async function onRequestPost({ request, env }) {
|
|
|
137
158
|
error: "Non posso spostare media senza aggiornare i riferimenti nel sito.",
|
|
138
159
|
}, 503);
|
|
139
160
|
}
|
|
140
|
-
let body;
|
|
141
|
-
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
142
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
143
|
-
return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
|
|
144
|
-
}
|
|
145
161
|
|
|
146
162
|
/* Seconda fase: le copie esistono già. Prima le verifichiamo TUTTE, poi aggiorniamo il repo,
|
|
147
163
|
* infine cancelliamo gli originali. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* GET/POST /admin/api/assets/settings — preferenze condivise della libreria Media.
|
|
2
|
+
*
|
|
3
|
+
* Vivono nello stesso bucket delle immagini: sono per sito, visibili da qualunque browser e
|
|
4
|
+
* non richiedono un commit/build per cambiare un comportamento del solo pannello.
|
|
5
|
+
*/
|
|
6
|
+
import { json } from "../../_shared/assets.js";
|
|
7
|
+
|
|
8
|
+
const KEY = "_adminkit/settings.json";
|
|
9
|
+
const defaults = { autoWebp: true };
|
|
10
|
+
|
|
11
|
+
async function read(env) {
|
|
12
|
+
const object = await env.MEDIA.get(KEY);
|
|
13
|
+
if (!object) return defaults;
|
|
14
|
+
try {
|
|
15
|
+
const stored = await object.json();
|
|
16
|
+
return { autoWebp: stored?.autoWebp !== false };
|
|
17
|
+
} catch {
|
|
18
|
+
return defaults;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function onRequestGet({ env }) {
|
|
23
|
+
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
24
|
+
return json({ ok: true, settings: await read(env) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function onRequestPost({ request, env }) {
|
|
28
|
+
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
29
|
+
let body;
|
|
30
|
+
try { body = await request.json(); }
|
|
31
|
+
catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
32
|
+
if (typeof body?.autoWebp !== "boolean") {
|
|
33
|
+
return json({ ok: false, error: "autoWebp deve essere booleano" }, 400);
|
|
34
|
+
}
|
|
35
|
+
const settings = { autoWebp: body.autoWebp };
|
|
36
|
+
await env.MEDIA.put(KEY, JSON.stringify(settings), {
|
|
37
|
+
httpMetadata: { contentType: "application/json" },
|
|
38
|
+
});
|
|
39
|
+
return json({ ok: true, settings });
|
|
40
|
+
}
|
|
@@ -22,7 +22,10 @@ export async function onRequestPost({ request, env }) {
|
|
|
22
22
|
|
|
23
23
|
// data di scatto (EXIF) per l'ordinamento "Data scatto"
|
|
24
24
|
const custom = {};
|
|
25
|
-
|
|
25
|
+
const suppliedCapturedAt = request.headers.get("X-Captured-At") || "";
|
|
26
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(suppliedCapturedAt)) {
|
|
27
|
+
custom.capturedAt = suppliedCapturedAt;
|
|
28
|
+
} else if (ctype === "image/jpeg") {
|
|
26
29
|
const cap = extractCapturedAt(body);
|
|
27
30
|
if (cap) custom.capturedAt = cap;
|
|
28
31
|
}
|
|
@@ -7,10 +7,11 @@ import { mediaUrl } from "../../_shared/refs.js";
|
|
|
7
7
|
import {
|
|
8
8
|
readMediaSources, summarizeMediaUsage, planMediaRewrite,
|
|
9
9
|
} from "../../_shared/media-content.js";
|
|
10
|
+
import { contentPersistenceMode } from "../../site.js";
|
|
11
|
+
import { d1MediaUsage } from "../../_shared/d1-media.js";
|
|
10
12
|
|
|
11
13
|
export async function onRequestPost({ request, env }) {
|
|
12
14
|
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
13
|
-
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
|
|
14
15
|
let body;
|
|
15
16
|
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
16
17
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -27,6 +28,9 @@ export async function onRequestPost({ request, env }) {
|
|
|
27
28
|
return json({ ok: true, usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] });
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
if (contentPersistenceMode() === "d1") return json({ ok: true, ...(await d1MediaUsage(env.CONTENT_DB, keyList)) });
|
|
32
|
+
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
|
|
33
|
+
|
|
30
34
|
const { sources } = await readMediaSources(env);
|
|
31
35
|
const summary = summarizeMediaUsage(sources, keyList, mediaUrl);
|
|
32
36
|
const plan = planMediaRewrite(sources, { removeUrls: keyList.map(mediaUrl) });
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { isPrefix, json, listKeysUnder } from "../_shared/assets.js";
|
|
5
5
|
import { sizeFromBucket } from "../_shared/imagesize.js";
|
|
6
6
|
import { pendingSourceKeys } from "../_shared/pending-media.js";
|
|
7
|
+
import { contentPersistenceMode } from "../site.js";
|
|
8
|
+
import { d1PendingSourceKeys } from "../_shared/d1-media.js";
|
|
7
9
|
|
|
8
10
|
const IMG = /\.(webp|jpe?g|png|avif|gif)$/i;
|
|
9
11
|
|
|
@@ -12,7 +14,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
12
14
|
const url = new URL(request.url);
|
|
13
15
|
let prefix = (url.searchParams.get("prefix") || "").trim();
|
|
14
16
|
if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
|
|
15
|
-
const pending = await pendingSourceKeys(env.MEDIA);
|
|
17
|
+
const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
|
|
16
18
|
|
|
17
19
|
/* `recursive=1` guarda TUTTO quello che c'è sotto, a qualunque profondità: serve a chi deve
|
|
18
20
|
* spostare o cancellare una cartella intera, che altrimenti si porterebbe dietro solo il primo
|
|
@@ -23,7 +25,8 @@ export async function onRequestGet({ request, env }) {
|
|
|
23
25
|
const subPrefixes = new Set();
|
|
24
26
|
const files = [];
|
|
25
27
|
for (const k of all) {
|
|
26
|
-
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;
|
|
27
30
|
const rel = k.slice(prefix.length);
|
|
28
31
|
const cut = rel.lastIndexOf("/");
|
|
29
32
|
if (cut >= 0) {
|
|
@@ -46,7 +49,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
46
49
|
// suo `.keep` (vedi assets/folder.js). Chi non ce l'ha mostra lo slug, come sempre.
|
|
47
50
|
const folders = await Promise.all(
|
|
48
51
|
(listed.delimitedPrefixes || [])
|
|
49
|
-
.filter((p) => p !== "_trash/" && p !== "_pending-media/")
|
|
52
|
+
.filter((p) => p !== "_trash/" && p !== "_pending-media/" && p !== "_adminkit/")
|
|
50
53
|
.map(async (p) => {
|
|
51
54
|
const keep = await env.MEDIA.head(p + ".keep");
|
|
52
55
|
return {
|
|
@@ -64,7 +67,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
64
67
|
// oggi. È un ripiego, non la strada normale: una volta ricaricate, il ramo non scatta più.
|
|
65
68
|
const raw = (listed.objects || [])
|
|
66
69
|
.filter((o) => o.key !== prefix && !o.key.endsWith("/.keep") && !o.key.endsWith("/") &&
|
|
67
|
-
!o.key.startsWith("_pending-media/") && !pending.has(o.key));
|
|
70
|
+
!o.key.startsWith("_pending-media/") && !o.key.startsWith("_adminkit/") && !pending.has(o.key));
|
|
68
71
|
|
|
69
72
|
const files = await Promise.all(raw.map(async (o) => {
|
|
70
73
|
const cm = o.customMetadata || {};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isTerminalDeploymentPhase,
|
|
6
6
|
} from "../../deployment.js";
|
|
7
7
|
import { deploymentFingerprint, releaseExportFromStored } from "../../release.js";
|
|
8
|
+
import { finalizeD1MediaRelease } from "../_shared/d1-media.js";
|
|
8
9
|
|
|
9
10
|
const MAX_BODY_BYTES = 8_000;
|
|
10
11
|
const claimLifetimeMs = 20 * 60 * 1_000;
|
|
@@ -147,7 +148,13 @@ export async function handleInternalContentCi({ request, env }) {
|
|
|
147
148
|
}
|
|
148
149
|
if (path === "/internal/deployment/finalize" && request.method === "POST") {
|
|
149
150
|
await attempts.finalize({ ...identity, intentKind: attempt.intentKind, tokenChecksum: checksum, now });
|
|
150
|
-
|
|
151
|
+
/* The release is already authoritative once this returns. Media cleanup
|
|
152
|
+
* is deliberately best-effort and journaled: a warning must not pretend
|
|
153
|
+
* to roll back a verified public deployment. */
|
|
154
|
+
const media = attempt.intentKind === "content"
|
|
155
|
+
? await finalizeD1MediaRelease({ env, releaseId: identity.releaseId, now }).catch(() => ({ completed: 0, failed: 0, warning: true }))
|
|
156
|
+
: undefined;
|
|
157
|
+
return response({ ok: true, attempt: { ...identity, phase: "succeeded" }, ...(media ? { media } : {}) });
|
|
151
158
|
}
|
|
152
159
|
if (path === "/internal/deployment/fail" && request.method === "POST") {
|
|
153
160
|
const detail = failure[input.code];
|
|
@@ -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
|
@@ -30,7 +30,9 @@ import * as assetsDelete from "./api/assets/delete.js";
|
|
|
30
30
|
import * as assetsFolder from "./api/assets/folder.js";
|
|
31
31
|
import * as assetsFolders from "./api/assets/folders.js";
|
|
32
32
|
import * as assetsMove from "./api/assets/move.js";
|
|
33
|
+
import * as assetsMedia from "./api/assets/media.js";
|
|
33
34
|
import * as assetsPreview from "./api/assets/preview.js";
|
|
35
|
+
import * as assetsSettings from "./api/assets/settings.js";
|
|
34
36
|
import * as assetsTrash from "./api/assets/trash.js";
|
|
35
37
|
import * as assetsTrashDelete from "./api/assets/trash/delete.js";
|
|
36
38
|
import * as assetsTrashEmpty from "./api/assets/trash/empty.js";
|
|
@@ -49,6 +51,7 @@ import * as settings from "./api/settings.js";
|
|
|
49
51
|
import * as singleton from "./api/singleton.js";
|
|
50
52
|
import * as storage from "./api/storage.js";
|
|
51
53
|
import * as content from "./api/content.js";
|
|
54
|
+
import * as siteAccess from "./api/site-access.js";
|
|
52
55
|
|
|
53
56
|
export { adminAccess } from "./_middleware.js";
|
|
54
57
|
export { setServerConfig } from "./site.js";
|
|
@@ -66,7 +69,9 @@ const ROUTES = {
|
|
|
66
69
|
"assets/folder": { mod: null, h: assetsFolder },
|
|
67
70
|
"assets/folders": { mod: null, h: assetsFolders },
|
|
68
71
|
"assets/move": { mod: null, h: assetsMove },
|
|
72
|
+
"assets/media": { mod: null, h: assetsMedia },
|
|
69
73
|
"assets/preview": { mod: null, h: assetsPreview },
|
|
74
|
+
"assets/settings": { mod: null, h: assetsSettings },
|
|
70
75
|
"assets/trash": { mod: null, h: assetsTrash },
|
|
71
76
|
"assets/trash/delete": { mod: null, h: assetsTrashDelete },
|
|
72
77
|
"assets/trash/empty": { mod: null, h: assetsTrashEmpty },
|
|
@@ -88,6 +93,7 @@ const ROUTES = {
|
|
|
88
93
|
"content/document": { mod: null, h: content },
|
|
89
94
|
"content/save": { mod: null, h: content },
|
|
90
95
|
"content/discard": { mod: null, h: content },
|
|
96
|
+
"content/site-access": { mod: null, h: siteAccess },
|
|
91
97
|
};
|
|
92
98
|
|
|
93
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
package/dist/media.d.ts
CHANGED
|
@@ -52,6 +52,30 @@ export type CreateMediaOperation = Readonly<{
|
|
|
52
52
|
now: number;
|
|
53
53
|
entries: readonly MediaOperationEntryInput[];
|
|
54
54
|
}>;
|
|
55
|
+
export type BoundMediaEntry = Readonly<{
|
|
56
|
+
operationId: string;
|
|
57
|
+
type: MediaOperationType;
|
|
58
|
+
operationState: MediaOperationState;
|
|
59
|
+
entryId: string;
|
|
60
|
+
sourceKey: string;
|
|
61
|
+
destinationKey: string | null;
|
|
62
|
+
finalizationState: "pending" | "completed" | "failed" | "cancelled";
|
|
63
|
+
}>;
|
|
64
|
+
export type MediaOperationStatus = Readonly<{
|
|
65
|
+
operationId: string;
|
|
66
|
+
type: MediaOperationType;
|
|
67
|
+
state: MediaOperationState;
|
|
68
|
+
releaseId: string | null;
|
|
69
|
+
updatedAt: number;
|
|
70
|
+
failedEntries: number;
|
|
71
|
+
}>;
|
|
72
|
+
export type StoredMediaOperationEntry = Readonly<{
|
|
73
|
+
entryId: string;
|
|
74
|
+
sourceKey: string;
|
|
75
|
+
destinationKey: string | null;
|
|
76
|
+
preparationState: string;
|
|
77
|
+
finalizationState: string;
|
|
78
|
+
}>;
|
|
55
79
|
/** SQL journal adapter. R2 copy/delete happens around this adapter; it never
|
|
56
80
|
* infers an object as removable simply because a caller stopped mentioning it. */
|
|
57
81
|
export declare class D1MediaOperationRepository {
|
|
@@ -83,6 +107,34 @@ export declare class D1MediaOperationRepository {
|
|
|
83
107
|
}): Promise<"pending" | "settled">;
|
|
84
108
|
blockReintroducedSources(sourceKeys: readonly string[], now: number): Promise<number>;
|
|
85
109
|
cancel(operationId: string, now: number): Promise<void>;
|
|
110
|
+
/** Sources are hidden from the normal library only while an unresolved
|
|
111
|
+
* operation owns them. The query deliberately never derives removability
|
|
112
|
+
* from this list. */
|
|
113
|
+
activeSourceKeys(): Promise<readonly string[]>;
|
|
114
|
+
/** Candidates bind every ready operation whose source has been rewritten;
|
|
115
|
+
* the release repository repeats the frozen-manifest no-source guard in the
|
|
116
|
+
* same transaction as the binding. */
|
|
117
|
+
readyOperationIds(): Promise<readonly string[]>;
|
|
118
|
+
/** A Discard block is reversible only when the current effective documents
|
|
119
|
+
* no longer contain any owned source. Re-evaluation changes journal state;
|
|
120
|
+
* it never synthesizes or rewrites content. */
|
|
121
|
+
refreshBlockedOperations(now: number): Promise<number>;
|
|
122
|
+
statuses(): Promise<readonly MediaOperationStatus[]>;
|
|
123
|
+
entries(operationId: string): Promise<readonly StoredMediaOperationEntry[]>;
|
|
124
|
+
/** Work is named by its immutable release binding: there is intentionally no
|
|
125
|
+
* "latest operation" cleanup path. */
|
|
126
|
+
finalizationWork(releaseId: string): Promise<readonly BoundMediaEntry[]>;
|
|
127
|
+
/** A warning is a live release with incomplete housekeeping. Retrying resets
|
|
128
|
+
* only failed effects, never an already-completed entry. */
|
|
129
|
+
resumeFinalization(operationId: string, releaseId: string, now: number): Promise<void>;
|
|
130
|
+
readyDeleteEntry(input: {
|
|
131
|
+
operationId: string;
|
|
132
|
+
entryId: string;
|
|
133
|
+
now: number;
|
|
134
|
+
objectChecksum: string;
|
|
135
|
+
etag: string | null;
|
|
136
|
+
metadataJson: string | null;
|
|
137
|
+
}): Promise<"pending" | "ready">;
|
|
86
138
|
}
|
|
87
139
|
type R2Metadata = Record<string, string>;
|
|
88
140
|
export type MediaR2Object = {
|
package/dist/media.js
CHANGED
|
@@ -164,7 +164,7 @@ export class D1MediaOperationRepository {
|
|
|
164
164
|
}
|
|
165
165
|
async beginFinalization(operationId, releaseId, now) {
|
|
166
166
|
const result = await this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?
|
|
167
|
-
WHERE operation_id = ? AND state
|
|
167
|
+
WHERE operation_id = ? AND state IN ('bound', 'finalizing') AND EXISTS (SELECT 1 FROM content_release_media_operations
|
|
168
168
|
WHERE operation_id = ? AND release_id = ?) AND EXISTS (SELECT 1 FROM content_releases
|
|
169
169
|
WHERE release_id = ? AND status = 'published')`)
|
|
170
170
|
.bind(now, operationId, operationId, releaseId, releaseId).run();
|
|
@@ -213,6 +213,108 @@ export class D1MediaOperationRepository {
|
|
|
213
213
|
if (results[0]?.meta?.changes !== 1)
|
|
214
214
|
throw new Error("media_operation_unavailable");
|
|
215
215
|
}
|
|
216
|
+
/** Sources are hidden from the normal library only while an unresolved
|
|
217
|
+
* operation owns them. The query deliberately never derives removability
|
|
218
|
+
* from this list. */
|
|
219
|
+
async activeSourceKeys() {
|
|
220
|
+
const rows = (await this.db.prepare(`SELECT DISTINCT entry.source_key AS sourceKey
|
|
221
|
+
FROM media_operation_entries entry
|
|
222
|
+
JOIN media_operations operation ON operation.operation_id = entry.operation_id
|
|
223
|
+
WHERE operation.state IN ('ready', 'bound', 'finalizing', 'completed_with_warning')
|
|
224
|
+
AND entry.finalization_state NOT IN ('completed', 'cancelled')
|
|
225
|
+
ORDER BY entry.source_key`).all()).results ?? [];
|
|
226
|
+
return rows.map((row) => row.sourceKey);
|
|
227
|
+
}
|
|
228
|
+
/** Candidates bind every ready operation whose source has been rewritten;
|
|
229
|
+
* the release repository repeats the frozen-manifest no-source guard in the
|
|
230
|
+
* same transaction as the binding. */
|
|
231
|
+
async readyOperationIds() {
|
|
232
|
+
await this.refreshBlockedOperations(Date.now());
|
|
233
|
+
const rows = (await this.db.prepare("SELECT operation_id AS operationId FROM media_operations WHERE state = 'ready' ORDER BY operation_id")
|
|
234
|
+
.all()).results ?? [];
|
|
235
|
+
return rows.map((row) => row.operationId);
|
|
236
|
+
}
|
|
237
|
+
/** A Discard block is reversible only when the current effective documents
|
|
238
|
+
* no longer contain any owned source. Re-evaluation changes journal state;
|
|
239
|
+
* it never synthesizes or rewrites content. */
|
|
240
|
+
async refreshBlockedOperations(now) {
|
|
241
|
+
const result = await this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
|
|
242
|
+
WHERE state = 'blocked_in_use' AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
|
|
243
|
+
JOIN content_documents document
|
|
244
|
+
LEFT JOIN content_revisions draft ON draft.document_id = document.document_id AND draft.revision_id = document.draft_revision_id
|
|
245
|
+
LEFT JOIN content_site_state site_state ON site_state.singleton = 1
|
|
246
|
+
LEFT JOIN content_release_entries published_entry ON published_entry.release_id = site_state.current_release_id
|
|
247
|
+
AND published_entry.document_id = document.document_id
|
|
248
|
+
LEFT JOIN content_revisions published ON published.document_id = published_entry.document_id
|
|
249
|
+
AND published.revision_id = published_entry.revision_id
|
|
250
|
+
WHERE source.operation_id = media_operations.operation_id
|
|
251
|
+
AND instr(COALESCE(draft.payload_json, published.payload_json, ''), '/media/' || source.source_key) > 0)`)
|
|
252
|
+
.bind(now).run();
|
|
253
|
+
return Number(result.meta?.changes ?? 0);
|
|
254
|
+
}
|
|
255
|
+
async statuses() {
|
|
256
|
+
return (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state,
|
|
257
|
+
binding.release_id AS releaseId, operation.updated_at AS updatedAt,
|
|
258
|
+
SUM(CASE WHEN entry.finalization_state = 'failed' THEN 1 ELSE 0 END) AS failedEntries
|
|
259
|
+
FROM media_operations operation
|
|
260
|
+
LEFT JOIN content_release_media_operations binding ON binding.operation_id = operation.operation_id
|
|
261
|
+
JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
|
|
262
|
+
WHERE operation.state NOT IN ('completed', 'cancelled')
|
|
263
|
+
GROUP BY operation.operation_id, operation.type, operation.state, binding.release_id, operation.updated_at
|
|
264
|
+
ORDER BY operation.updated_at DESC, operation.operation_id`).all()).results ?? [];
|
|
265
|
+
}
|
|
266
|
+
async entries(operationId) {
|
|
267
|
+
return (await this.db.prepare(`SELECT entry_id AS entryId, source_key AS sourceKey,
|
|
268
|
+
destination_key AS destinationKey, preparation_state AS preparationState,
|
|
269
|
+
finalization_state AS finalizationState
|
|
270
|
+
FROM media_operation_entries WHERE operation_id = ? ORDER BY entry_id`)
|
|
271
|
+
.bind(operationId).all()).results ?? [];
|
|
272
|
+
}
|
|
273
|
+
/** Work is named by its immutable release binding: there is intentionally no
|
|
274
|
+
* "latest operation" cleanup path. */
|
|
275
|
+
async finalizationWork(releaseId) {
|
|
276
|
+
const rows = (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state AS operationState,
|
|
277
|
+
entry.entry_id AS entryId, entry.source_key AS sourceKey, entry.destination_key AS destinationKey,
|
|
278
|
+
entry.finalization_state AS finalizationState
|
|
279
|
+
FROM content_release_media_operations binding
|
|
280
|
+
JOIN media_operations operation ON operation.operation_id = binding.operation_id
|
|
281
|
+
JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
|
|
282
|
+
WHERE binding.release_id = ? AND operation.state IN ('bound', 'finalizing', 'completed_with_warning')
|
|
283
|
+
AND entry.finalization_state IN ('pending', 'failed')
|
|
284
|
+
ORDER BY operation.operation_id, entry.entry_id`).bind(releaseId).all()).results ?? [];
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
/** A warning is a live release with incomplete housekeeping. Retrying resets
|
|
288
|
+
* only failed effects, never an already-completed entry. */
|
|
289
|
+
async resumeFinalization(operationId, releaseId, now) {
|
|
290
|
+
const resume = this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?, retry_count = retry_count + 1
|
|
291
|
+
WHERE operation_id = ? AND state = 'completed_with_warning'
|
|
292
|
+
AND EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ? AND release_id = ?)
|
|
293
|
+
AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'published')`)
|
|
294
|
+
.bind(now, operationId, operationId, releaseId, releaseId);
|
|
295
|
+
const reset = this.db.prepare(`UPDATE media_operation_entries SET finalization_state = 'pending', safe_error = NULL
|
|
296
|
+
WHERE operation_id = ? AND finalization_state = 'failed'
|
|
297
|
+
AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'finalizing')`)
|
|
298
|
+
.bind(operationId, operationId);
|
|
299
|
+
const results = await this.db.batch([resume, reset]);
|
|
300
|
+
if (results[0]?.meta?.changes !== 1)
|
|
301
|
+
throw new Error("media_operation_unavailable");
|
|
302
|
+
}
|
|
303
|
+
async readyDeleteEntry(input) {
|
|
304
|
+
const entry = this.db.prepare(`UPDATE media_operation_entries
|
|
305
|
+
SET object_checksum = ?, etag = ?, metadata_json = ?, preparation_state = 'ready'
|
|
306
|
+
WHERE operation_id = ? AND entry_id = ? AND preparation_state = 'pending'
|
|
307
|
+
AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND type = 'delete' AND state = 'preparing')`)
|
|
308
|
+
.bind(input.objectChecksum, input.etag, input.metadataJson, input.operationId, input.entryId, input.operationId);
|
|
309
|
+
const ready = this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
|
|
310
|
+
WHERE operation_id = ? AND type = 'delete' AND state = 'preparing' AND NOT EXISTS (SELECT 1 FROM media_operation_entries
|
|
311
|
+
WHERE operation_id = ? AND preparation_state != 'ready')`)
|
|
312
|
+
.bind(input.now, input.operationId, input.operationId);
|
|
313
|
+
const results = await this.db.batch([entry, ready]);
|
|
314
|
+
if (results[0]?.meta?.changes !== 1)
|
|
315
|
+
throw new Error("media_operation_unavailable");
|
|
316
|
+
return results[1]?.meta?.changes === 1 ? "ready" : "pending";
|
|
317
|
+
}
|
|
216
318
|
}
|
|
217
319
|
const MEDIA_CHECKSUM_METADATA = "adminkit-sha256";
|
|
218
320
|
async function bytesChecksum(bytes) {
|
|
@@ -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
|
@@ -115,6 +115,11 @@ export type ReleaseInput = {
|
|
|
115
115
|
documentId: string;
|
|
116
116
|
revisionId: string;
|
|
117
117
|
}[];
|
|
118
|
+
/** Operations are bound in the same D1 transaction as the immutable
|
|
119
|
+
* manifest. The SQL guard rejects any candidate still containing a source
|
|
120
|
+
* URL, so a caller cannot make cleanup race a public reference. */
|
|
121
|
+
mediaOperationIds?: readonly string[];
|
|
122
|
+
siteAccessVersionId?: string | null;
|
|
118
123
|
};
|
|
119
124
|
export type ReleaseDeploymentInput = ReleaseInput & {
|
|
120
125
|
attempt: {
|
|
@@ -157,6 +162,11 @@ export declare class D1ContentRepository {
|
|
|
157
162
|
* no "latest" variant: a deployment attempt must name what it exports. */
|
|
158
163
|
getReleaseExport(releaseId: string): Promise<StoredReleaseExport | null>;
|
|
159
164
|
save(input: SaveInput): Promise<"created" | "repeated">;
|
|
165
|
+
/** Multi-document editorial gestures (such as a Media rewrite) must not
|
|
166
|
+
* leave half their draft revisions behind when a later CAS discovers an
|
|
167
|
+
* editor's concurrent save. D1 batches are transactional, so we validate
|
|
168
|
+
* every identity and build every guarded statement before issuing one batch. */
|
|
169
|
+
saveMany(inputs: readonly SaveInput[]): Promise<void>;
|
|
160
170
|
discard(documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null, now: number): Promise<void>;
|
|
161
171
|
publish(input: ReleaseInput): Promise<"created" | "repeated">;
|
|
162
172
|
/** Candidate release and deployment-lane reservation are inseparable: a
|