@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.
Files changed (50) hide show
  1. package/dist/api.d.ts +58 -2
  2. package/dist/api.js +39 -5
  3. package/dist/app/App.js +10 -4
  4. package/dist/app/app.css +5 -0
  5. package/dist/app/lib/publish.js +9 -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/MediaGrid.js +10 -1
  18. package/dist/form/media/image-conversion.d.ts +14 -0
  19. package/dist/form/media/image-conversion.js +163 -0
  20. package/dist/form/media/media-upload-queue.d.ts +31 -0
  21. package/dist/form/media/media-upload-queue.js +230 -0
  22. package/dist/form/media/useMediaLibrary.d.ts +7 -0
  23. package/dist/form/media/useMediaLibrary.js +123 -22
  24. package/dist/functions/_shared/assets.js +3 -2
  25. package/dist/functions/_shared/d1-media.js +317 -0
  26. package/dist/functions/api/assets/delete.js +19 -1
  27. package/dist/functions/api/assets/folders.js +5 -2
  28. package/dist/functions/api/assets/media.js +30 -0
  29. package/dist/functions/api/assets/move.js +22 -6
  30. package/dist/functions/api/assets/settings.js +40 -0
  31. package/dist/functions/api/assets/upload.js +4 -1
  32. package/dist/functions/api/assets/usage.js +5 -1
  33. package/dist/functions/api/assets.js +7 -4
  34. package/dist/functions/api/content-ci.js +8 -1
  35. package/dist/functions/api/site-access.js +78 -0
  36. package/dist/functions/index.js +6 -0
  37. package/dist/functions/site.js +4 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/media.d.ts +52 -0
  41. package/dist/media.js +103 -1
  42. package/dist/migrations/0004_site_access.sql +41 -0
  43. package/dist/persistence.d.ts +10 -0
  44. package/dist/persistence.js +129 -8
  45. package/dist/release.d.ts +3 -0
  46. package/dist/release.js +7 -0
  47. package/dist/site-access.d.ts +48 -0
  48. package/dist/site-access.js +180 -0
  49. package/dist/theme.css +13 -0
  50. package/package.json +4 -1
@@ -9,8 +9,10 @@
9
9
  * una sola implementazione, due impaginazioni.
10
10
  */
11
11
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
- import { api, uploadQueue, ApiError } from "../../api.js";
12
+ import { api, ApiError } from "../../api.js";
13
13
  import { storageKey } from "../../config.js";
14
+ import { contentPersistenceMode } from "../../app/site.js";
15
+ import { startMediaUpload, useMediaUploadCompletions } from "./media-upload-queue.js";
14
16
  /* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
15
17
  * server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
16
18
  * Dieci tiene una richiesta sotto i pochi secondi, che è la soglia oltre la quale chi guarda
@@ -34,9 +36,15 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
34
36
  const [busy, setBusy] = useState(false);
35
37
  const [loaded, setLoaded] = useState(false);
36
38
  const [error, setError] = useState(null);
39
+ const [mediaOperations, setMediaOperations] = useState([]);
37
40
  const [progress, setProgress] = useState(null);
38
41
  const [uploadOpen, setUploadOpen] = useState(false);
39
42
  const [viewing, setViewing] = useState(null);
43
+ const [autoWebp, setAutoWebpState] = useState(true);
44
+ const [settingsBusy, setSettingsBusy] = useState(false);
45
+ const [settingsLoaded, setSettingsLoaded] = useState(false);
46
+ const mediaUploadCompletions = useMediaUploadCompletions();
47
+ const seenMediaUploadCompletions = useRef(mediaUploadCompletions);
40
48
  // Le preferenze di ordinamento restano dov'erano — il prefisso che il sito dichiara al
41
49
  // bootstrap ricostruisce le chiavi di prima: la memoria muscolare di chi usa il pannello
42
50
  // tutti i giorni non ha motivo di azzerarsi per una riorganizzazione dei sorgenti.
@@ -54,7 +62,13 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
54
62
  setBusy(true);
55
63
  setError(null);
56
64
  try {
57
- const [a, f] = await Promise.all([api.assets(p), api.folders()]);
65
+ const [a, f, journal] = await Promise.all([
66
+ api.assets(p),
67
+ api.folders(),
68
+ mode === "manage" && contentPersistenceMode() === "d1"
69
+ ? api.mediaOperations()
70
+ : Promise.resolve({ ok: true, operations: [] }),
71
+ ]);
58
72
  if (fallback && p && !(f.folders ?? []).some((x) => p === x.prefix || p.startsWith(x.prefix))) {
59
73
  setBusy(false);
60
74
  return load("");
@@ -68,6 +82,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
68
82
  setFiles(a.files ?? []);
69
83
  setSubfolders(a.folders ?? []);
70
84
  setFolders(f.folders ?? []);
85
+ setMediaOperations(journal.operations ?? []);
71
86
  setPrefix(p);
72
87
  setSelected(new Set());
73
88
  setViewing(null);
@@ -88,7 +103,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
88
103
  finally {
89
104
  setBusy(false);
90
105
  }
91
- }, [remember]);
106
+ }, [remember, mode]);
92
107
  /* Caricamento PIGRO come per la home: chi apre l'admin sulle pagine non paga l'elenco del
93
108
  * bucket. `loaded` e non `files.length`, altrimenti una cartella vuota ritenterebbe all'infinito.
94
109
  *
@@ -116,6 +131,29 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
116
131
  void load(start, true);
117
132
  }, [active, loaded, busy, error, load, remember]);
118
133
  useEffect(() => { localStorage.setItem(storageKey("sortKey"), sortKey); localStorage.setItem(storageKey("sortDir"), sortDir); }, [sortKey, sortDir]);
134
+ /* Questa preferenza è del SITO, non del browser: il backend la conserva nel bucket Media.
135
+ * Finché non arriva la risposta vale il default ON, così anche i siti già esistenti hanno il
136
+ * nuovo comportamento senza migrazioni. Si carica solo nella vista di gestione: il picker non
137
+ * offre upload e non deve fare una richiesta che non userà. */
138
+ useEffect(() => {
139
+ if (mode !== "manage" || !active || settingsLoaded)
140
+ return;
141
+ setSettingsBusy(true);
142
+ void api.mediaSettings()
143
+ .then((result) => { setAutoWebpState(result.settings.autoWebp); setSettingsLoaded(true); })
144
+ .catch((e) => setError(e instanceof ApiError ? e.message : String(e)))
145
+ .finally(() => setSettingsBusy(false));
146
+ }, [mode, active, settingsLoaded]);
147
+ /* Un upload può finire mentre si guarda un'altra sezione. Al ritorno ricarichiamo la
148
+ * cartella corrente una volta sola, senza riportare l'utente nella cartella in cui il job era
149
+ * partito. Lo stato della coda è esterno a React, quindi sopravvive alla navigazione. */
150
+ useEffect(() => {
151
+ if (!active || seenMediaUploadCompletions.current === mediaUploadCompletions)
152
+ return;
153
+ seenMediaUploadCompletions.current = mediaUploadCompletions;
154
+ if (loaded)
155
+ void load(prefix);
156
+ }, [active, mediaUploadCompletions, loaded, load, prefix]);
119
157
  /* Chiudere o ricaricare mentre un'operazione è in corso la interrompe: il gruppo in volo non
120
158
  * arriva mai. Ora ogni gruppo è definitivo, quindi si perde poco — ma vale comunque la pena
121
159
  * chiedere, perché è esattamente il gesto che uno fa quando "non sta succedendo niente". */
@@ -164,26 +202,50 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
164
202
  const arr = Array.from(list);
165
203
  if (!arr.length)
166
204
  return true;
167
- setProgress({ done: 0, total: arr.length, verb: "Carico" });
168
- const errs = [];
205
+ let convert = autoWebp;
206
+ /* Evita la piccola finestra fra apertura dei Media e arrivo dell'impostazione: se sul sito
207
+ la conversione è OFF, un upload immediato non deve partire col default ON. */
208
+ if (!settingsLoaded) {
209
+ try {
210
+ const result = await api.mediaSettings();
211
+ convert = result.settings.autoWebp;
212
+ setAutoWebpState(convert);
213
+ setSettingsLoaded(true);
214
+ }
215
+ catch (e) {
216
+ setError(e instanceof ApiError ? e.message : String(e));
217
+ return false;
218
+ }
219
+ }
220
+ const destination = prefix
221
+ ? seenLabels.current[prefix] || prefix.replace(/\/$/, "").split("/").pop() || prefix
222
+ : "Senza cartella";
223
+ startMediaUpload({
224
+ prefix,
225
+ title: `${arr.length} file · ${destination}`,
226
+ files: arr,
227
+ autoWebp: convert,
228
+ });
229
+ return true;
230
+ }, [prefix, autoWebp, settingsLoaded]);
231
+ const setAutoWebp = useCallback(async (on) => {
232
+ const previous = autoWebp;
233
+ setAutoWebpState(on);
234
+ setSettingsBusy(true);
235
+ setError(null);
169
236
  try {
170
- await uploadQueue(prefix, arr, (done, total, _r, err) => {
171
- setProgress({ done, total, verb: "Carico" });
172
- if (err)
173
- errs.push(err);
174
- });
237
+ const result = await api.saveMediaSettings(on);
238
+ setAutoWebpState(result.settings.autoWebp);
239
+ setSettingsLoaded(true);
240
+ }
241
+ catch (e) {
242
+ setAutoWebpState(previous);
243
+ setError(e instanceof ApiError ? e.message : String(e));
175
244
  }
176
245
  finally {
177
- setProgress(null);
178
- // SEMPRE, anche se qualcosa è fallito: dopo un'operazione a metà l'elenco che si vede è
179
- // una bugia, ed è peggio dell'errore.
180
- await load(prefix);
246
+ setSettingsBusy(false);
181
247
  }
182
- // Dopo `load`, che azzera il messaggio: prima veniva scritto e subito cancellato.
183
- if (errs.length)
184
- setError(`Non caricati: ${errs.join("; ")}`);
185
- return errs.length === 0;
186
- }, [prefix, load]);
248
+ }, [autoWebp]);
187
249
  const labelOf = useCallback((p) => {
188
250
  const f = folders.find((x) => x.prefix === p) ?? subfolders.find((x) => x.prefix === p);
189
251
  return f?.label || seenLabels.current[p] || p.replace(/\/$/, "").split("/").pop() || "";
@@ -469,6 +531,44 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
469
531
  }, [prefix, load, moveFolder]);
470
532
  /** Cestina una cartella con tutto quello che contiene. È `remove` con una lista di uno. */
471
533
  const removeFolder = useCallback((target) => remove([target]), [remove]);
534
+ const resumeMedia = useCallback(async (operation) => {
535
+ if (!operation.releaseId)
536
+ return;
537
+ setBusy(true);
538
+ setError(null);
539
+ let failure = null;
540
+ try {
541
+ await api.resumeMediaOperation(operation.operationId, operation.releaseId);
542
+ }
543
+ catch (e) {
544
+ failure = e instanceof ApiError ? e.message : String(e);
545
+ }
546
+ finally {
547
+ setBusy(false);
548
+ await load(prefix);
549
+ if (failure)
550
+ setError(failure);
551
+ }
552
+ }, [load, prefix]);
553
+ const cancelMedia = useCallback(async (operation) => {
554
+ if (!confirm("Annullare questa operazione Media? Le copie già preparate resteranno disponibili nella libreria."))
555
+ return;
556
+ setBusy(true);
557
+ setError(null);
558
+ let failure = null;
559
+ try {
560
+ await api.cancelMediaOperation(operation.operationId);
561
+ }
562
+ catch (e) {
563
+ failure = e instanceof ApiError ? e.message : String(e);
564
+ }
565
+ finally {
566
+ setBusy(false);
567
+ await load(prefix);
568
+ if (failure)
569
+ setError(failure);
570
+ }
571
+ }, [load, prefix]);
472
572
  const step = useCallback((delta) => {
473
573
  setViewing((i) => {
474
574
  if (i === null)
@@ -478,8 +578,8 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
478
578
  });
479
579
  }, [shown.length]);
480
580
  return {
481
- mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, progress,
482
- sortKey, sortDir, uploadOpen, viewing,
581
+ mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, mediaOperations, progress,
582
+ sortKey, sortDir, uploadOpen, viewing, autoWebp, settingsBusy,
483
583
  go: (p) => void load(p),
484
584
  reload: () => void load(prefix),
485
585
  toggle, setAll,
@@ -487,9 +587,10 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
487
587
  flipDir: () => setSortDir((d) => (d === "asc" ? "desc" : "asc")),
488
588
  upload,
489
589
  setUploadOpen,
590
+ setAutoWebp,
490
591
  view: setViewing,
491
592
  step,
492
- move, remove, newFolder, renameFolder, removeFolder, labelOf,
593
+ move, remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
493
594
  clearError: () => setError(null),
494
595
  };
495
596
  }
@@ -32,8 +32,9 @@ export function sanitizeName(name) {
32
32
  export function guessMime(key) {
33
33
  const e = (key.split(".").pop() || "").toLowerCase();
34
34
  return {
35
- jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp",
36
- gif: "image/gif", avif: "image/avif", svg: "image/svg+xml",
35
+ jpg: "image/jpeg", jpeg: "image/jpeg", jfif: "image/jpeg", png: "image/png", webp: "image/webp",
36
+ gif: "image/gif", avif: "image/avif", heic: "image/heic", heif: "image/heif",
37
+ tif: "image/tiff", tiff: "image/tiff", bmp: "image/bmp", svg: "image/svg+xml",
37
38
  mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf",
38
39
  }[e] || "application/octet-stream";
39
40
  }
@@ -0,0 +1,317 @@
1
+ /* D1-mode Media bridge. It deliberately owns no site schema: the site
2
+ * validator remains the authority for every draft revision generated by an
3
+ * explicit Media operation. Git is never consulted from this module. */
4
+ import {
5
+ canonicalChecksum, canonicalJson, contentSaveRequestChecksum, D1ContentRepository,
6
+ } from "../../persistence.js";
7
+ import {
8
+ D1MediaOperationRepository, prepareMediaCopy,
9
+ } from "../../media.js";
10
+ import { validateContentDocument } from "../site.js";
11
+ import { isKey, moveToTrash, sanitizeName } from "./assets.js";
12
+
13
+ const MEDIA_PREFIX = "/media/";
14
+
15
+ function mediaUrl(key) { return MEDIA_PREFIX + key.split("/").map(encodeURIComponent).join("/"); }
16
+
17
+ function editor(data) {
18
+ const payload = data?.cloudflareAccess?.JWT?.payload;
19
+ if (!payload?.sub) throw new Error("unauthenticated");
20
+ return { subject: String(payload.sub), label: String(payload.email || payload.name || "Admin").slice(0, 200) };
21
+ }
22
+
23
+ function operationId(value) {
24
+ if (value === undefined || value === null || value === "") return crypto.randomUUID();
25
+ if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) throw new Error("invalid_media_operation");
26
+ return value;
27
+ }
28
+
29
+ function replaceBoundedUrl(value, from, to) {
30
+ let cursor = 0;
31
+ let next = "";
32
+ while (cursor < value.length) {
33
+ const found = value.indexOf(from, cursor);
34
+ if (found < 0) return next + value.slice(cursor);
35
+ const after = value[found + from.length];
36
+ /* A media key is a path, not a string prefix. Query/fragment suffixes and
37
+ * further URL/path characters belong to a different reference and must
38
+ * not be rewritten accidentally. */
39
+ if (after && /[A-Za-z0-9._~%!$&'()*+,;=:@/?#-]/.test(after)) {
40
+ next += value.slice(cursor, found + from.length);
41
+ cursor = found + from.length;
42
+ continue;
43
+ }
44
+ next += value.slice(cursor, found) + to;
45
+ cursor = found + from.length;
46
+ }
47
+ return next;
48
+ }
49
+
50
+ function replaceText(value, replacements, removals) {
51
+ let next = value;
52
+ for (const [from, to] of Object.entries(replacements)) next = replaceBoundedUrl(next, from, to);
53
+ for (const from of removals) next = replaceBoundedUrl(next, from, "");
54
+ return next;
55
+ }
56
+
57
+ /** JSON Media values are intentionally rewritten only as complete public URLs
58
+ * (or a URL embedded in Markdown). Arrays lose an exact removed URL; scalar
59
+ * slots become empty strings and are then validated by the site's schema. */
60
+ function rewriteValue(value, replacements, removals, inArray = false) {
61
+ if (typeof value === "string") {
62
+ if (removals.has(value) && inArray) return { changed: true, removed: true };
63
+ const next = replaceText(value, replacements, removals);
64
+ return { changed: next !== value, value: next };
65
+ }
66
+ if (Array.isArray(value)) {
67
+ let changed = false;
68
+ const next = [];
69
+ for (const item of value) {
70
+ const result = rewriteValue(item, replacements, removals, true);
71
+ changed ||= result.changed;
72
+ if (!result.removed) next.push(result.value);
73
+ }
74
+ return { changed, value: next };
75
+ }
76
+ if (value && typeof value === "object") {
77
+ let changed = false;
78
+ const next = {};
79
+ for (const [key, item] of Object.entries(value)) {
80
+ const result = rewriteValue(item, replacements, removals, false);
81
+ changed ||= result.changed;
82
+ if (!result.removed) next[key] = result.value;
83
+ }
84
+ return { changed, value: next };
85
+ }
86
+ return { changed: false, value };
87
+ }
88
+
89
+ async function effectiveDocuments(db) {
90
+ return ((await db.prepare(`SELECT document.document_id AS documentId, document.kind, document.locale,
91
+ document.draft_revision_id AS draftRevisionId, published.revision_id AS publishedRevisionId,
92
+ draft.payload_json AS draftPayload, draft.is_tombstone AS draftTombstone,
93
+ published.payload_json AS publishedPayload, published.is_tombstone AS publishedTombstone,
94
+ COALESCE(draft.schema_version, published.schema_version) AS schemaVersion
95
+ FROM content_documents document
96
+ LEFT JOIN content_revisions draft ON draft.document_id = document.document_id AND draft.revision_id = document.draft_revision_id
97
+ LEFT JOIN content_site_state state ON state.singleton = 1
98
+ LEFT JOIN content_release_entries entry ON entry.release_id = state.current_release_id AND entry.document_id = document.document_id
99
+ LEFT JOIN content_revisions published ON published.document_id = entry.document_id AND published.revision_id = entry.revision_id
100
+ WHERE document.abandoned_at IS NULL ORDER BY document.document_id`).all()).results ?? []);
101
+ }
102
+
103
+ async function rewriteDocuments({ db, data, replacements = {}, removals = [] }) {
104
+ const replacementUrls = Object.fromEntries(Object.entries(replacements).map(([from, to]) => [mediaUrl(from), mediaUrl(to)]));
105
+ const removalUrls = new Set(removals.map(mediaUrl));
106
+ const commands = [];
107
+ for (const row of await effectiveDocuments(db)) {
108
+ const useDraft = row.draftRevisionId !== null;
109
+ const raw = useDraft ? row.draftPayload : row.publishedPayload;
110
+ if (!raw || (useDraft ? row.draftTombstone : row.publishedTombstone)) continue;
111
+ let payload;
112
+ try { payload = JSON.parse(raw); } catch { throw new Error("invalid_document"); }
113
+ const rewritten = rewriteValue(payload, replacementUrls, removalUrls);
114
+ if (!rewritten.changed) continue;
115
+ const normalized = validateContentDocument({
116
+ documentId: row.documentId, kind: row.kind, locale: row.locale, schemaVersion: row.schemaVersion,
117
+ tombstone: false, payload: rewritten.value,
118
+ });
119
+ commands.push({
120
+ revisionId: crypto.randomUUID(), documentId: row.documentId,
121
+ baseRevisionId: useDraft ? row.draftRevisionId : row.publishedRevisionId,
122
+ expectedDraftRevisionId: row.draftRevisionId, kind: row.kind, locale: row.locale,
123
+ schemaVersion: row.schemaVersion, tombstone: false, payload: normalized.payload,
124
+ });
125
+ }
126
+ const who = editor(data);
127
+ const repository = new D1ContentRepository(db);
128
+ /* Every command was fully parsed and site-validated before the first write.
129
+ * A stale CAS leaves sources untouched; an already-created R2 destination is
130
+ * deliberately a reusable visible asset, never an orphan cleanup target. */
131
+ const now = Date.now();
132
+ await repository.saveMany(await Promise.all(commands.map(async (command) => ({
133
+ ...command, payloadChecksum: await canonicalChecksum(command.payload), requestChecksum: await contentSaveRequestChecksum(command),
134
+ now, authorSubject: who.subject, authorLabel: who.label,
135
+ }))));
136
+ return { documents: commands.map((command) => ({ documentId: command.documentId, kind: command.kind })) };
137
+ }
138
+
139
+ function keys(input) {
140
+ const unique = [...new Set((Array.isArray(input) ? input : []).filter(isKey).filter((key) => !key.startsWith("_trash/")))];
141
+ if (!unique.length) throw new Error("invalid_media_operation");
142
+ return unique;
143
+ }
144
+
145
+ async function uniqueOperationKey(bucket, prefix, filename, reserved) {
146
+ const name = sanitizeName(filename);
147
+ const dot = name.lastIndexOf(".");
148
+ const base = dot > 0 ? name.slice(0, dot) : name;
149
+ const extension = dot > 0 ? name.slice(dot) : "";
150
+ for (let suffix = 1; suffix <= 999; suffix += 1) {
151
+ const candidate = suffix === 1 ? `${prefix}${name}` : `${prefix}${base}-${suffix}${extension}`;
152
+ if (reserved.has(candidate) || await bucket.head(candidate)) continue;
153
+ reserved.add(candidate);
154
+ return candidate;
155
+ }
156
+ throw new Error("media_destination_collision");
157
+ }
158
+
159
+ async function createOperation({ db, data, type, id, entries, sourcePrefix = null, destinationPrefix = null, clientChecksum = null }) {
160
+ const who = editor(data);
161
+ const requestChecksum = await canonicalChecksum({
162
+ action: "media.operation", operationId: id, type,
163
+ sourceKeys: entries.map((entry) => entry.sourceKey), sourcePrefix, destinationPrefix,
164
+ });
165
+ if (clientChecksum && clientChecksum !== requestChecksum) throw new Error("invalid_media_operation");
166
+ const journal = new D1MediaOperationRepository(db);
167
+ const created = await journal.create({ operationId: id, requestChecksum, type, entries, sourcePrefix, destinationPrefix, authorSubject: who.subject, now: Date.now() });
168
+ return { journal, created };
169
+ }
170
+
171
+ async function inspectSource(bucket, entry) {
172
+ const object = await bucket.get(entry.sourceKey);
173
+ if (!object) throw new Error("media_source_missing");
174
+ const bytes = await object.arrayBuffer();
175
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
176
+ const objectChecksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
177
+ return {
178
+ ...entry,
179
+ objectChecksum,
180
+ etag: object.etag ?? null,
181
+ metadataJson: canonicalJson({ httpMetadata: object.httpMetadata ?? {}, customMetadata: object.customMetadata ?? {} }),
182
+ };
183
+ }
184
+
185
+ export async function d1MoveMedia({ env, data, body }) {
186
+ if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
187
+ let destination = String(body.dest || "").trim();
188
+ if (destination && !destination.endsWith("/")) destination += "/";
189
+ if (!destination || !/^(?:[A-Za-z0-9._-]+\/)+$/.test(destination) || destination.startsWith("_trash/")) throw new Error("invalid_media_operation");
190
+ const sourceKeys = keys(body.keys).filter((key) => key.slice(0, key.lastIndexOf("/") + 1) !== destination);
191
+ const id = operationId(body.operationId);
192
+ if (!sourceKeys.length) return { operationId: id, repeated: false, moved: 0, urlMap: {}, documents: [] };
193
+ const entries = [];
194
+ const reserved = new Set();
195
+ for (const sourceKey of sourceKeys) {
196
+ const base = sourceKey.split("/").pop();
197
+ const destinationKey = await uniqueOperationKey(env.MEDIA, destination, base, reserved);
198
+ entries.push({ entryId: crypto.randomUUID(), sourceKey, destinationKey });
199
+ }
200
+ const { journal, created } = await createOperation({ db: env.CONTENT_DB, data, type: "move", id, entries, sourcePrefix: null, destinationPrefix: destination, clientChecksum: body.requestChecksum });
201
+ if (created === "repeated") {
202
+ const stored = await journal.entries(id);
203
+ return {
204
+ operationId: id, repeated: true, moved: stored.length,
205
+ urlMap: Object.fromEntries(stored.filter((entry) => entry.destinationKey).map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])),
206
+ documents: [],
207
+ };
208
+ }
209
+ try {
210
+ for (const entry of entries) await prepareMediaCopy({ bucket: env.MEDIA, journal, operationId: id, entryId: entry.entryId, sourceKey: entry.sourceKey, destinationKey: entry.destinationKey, now: Date.now() });
211
+ const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, replacements: Object.fromEntries(entries.map((entry) => [entry.sourceKey, entry.destinationKey])) });
212
+ return { operationId: id, repeated: false, moved: entries.length, urlMap: Object.fromEntries(entries.map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])), documents: rewritten.documents };
213
+ } catch (error) {
214
+ await journal.cancel(id, Date.now()).catch(() => null);
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ export async function d1DeleteMedia({ env, data, body }) {
220
+ if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
221
+ const sourceKeys = keys(body.keys);
222
+ const id = operationId(body.operationId);
223
+ const entries = sourceKeys.map((sourceKey) => ({ entryId: crypto.randomUUID(), sourceKey }));
224
+ const { journal, created } = await createOperation({ db: env.CONTENT_DB, data, type: "delete", id, entries, clientChecksum: body.requestChecksum });
225
+ if (created === "repeated") return { operationId: id, repeated: true, trashed: (await journal.entries(id)).length, documents: [] };
226
+ try {
227
+ const inspected = await Promise.all(entries.map((entry) => inspectSource(env.MEDIA, entry)));
228
+ const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, removals: sourceKeys });
229
+ for (const entry of inspected) await journal.readyDeleteEntry({ ...entry, operationId: id, now: Date.now() });
230
+ return { operationId: id, repeated: false, trashed: entries.length, documents: rewritten.documents };
231
+ } catch (error) {
232
+ await journal.cancel(id, Date.now()).catch(() => null);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ export async function d1PendingSourceKeys(db) {
238
+ return new Set(await new D1MediaOperationRepository(db).activeSourceKeys());
239
+ }
240
+
241
+ /** Usage is read from the public release, every current draft, and an
242
+ * in-flight immutable candidate. It never reads Git in D1 mode. */
243
+ export async function d1MediaUsage(db, sourceKeys) {
244
+ const keys = [...new Set(sourceKeys.filter(isKey))];
245
+ if (!keys.length) return { usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] };
246
+ const rows = (await db.prepare(`SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
247
+ FROM content_documents document
248
+ JOIN content_revisions revision ON revision.document_id = document.document_id AND revision.revision_id = document.draft_revision_id
249
+ UNION
250
+ SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
251
+ FROM content_site_state state
252
+ JOIN content_release_entries entry ON entry.release_id = state.current_release_id
253
+ JOIN content_documents document ON document.document_id = entry.document_id
254
+ JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
255
+ UNION
256
+ SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
257
+ FROM content_releases release
258
+ JOIN content_release_entries entry ON entry.release_id = release.release_id
259
+ JOIN content_documents document ON document.document_id = entry.document_id
260
+ JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
261
+ WHERE release.status = 'publishing'`).all()).results ?? [];
262
+ const usage = {};
263
+ const pages = new Set();
264
+ for (const key of keys) {
265
+ const url = mediaUrl(key);
266
+ const locations = [...new Set(rows.filter((row) => typeof row.payload === "string" && row.payload.includes(url))
267
+ .map((row) => `${row.kind} · ${row.documentId}`))];
268
+ if (locations.length) { usage[key] = locations; locations.forEach((location) => pages.add(location)); }
269
+ }
270
+ return { usage, pages: [...pages], count: Object.keys(usage).length, selectedCount: keys.length, blockers: [] };
271
+ }
272
+
273
+ export async function finalizeD1MediaRelease({ env, releaseId, now = Date.now() }) {
274
+ if (!env.CONTENT_DB || !env.MEDIA) return { completed: 0, failed: 0, skipped: "binding non configurati" };
275
+ const journal = new D1MediaOperationRepository(env.CONTENT_DB);
276
+ const work = await journal.finalizationWork(releaseId);
277
+ const byOperation = new Map();
278
+ for (const entry of work) {
279
+ const group = byOperation.get(entry.operationId) || [];
280
+ group.push(entry); byOperation.set(entry.operationId, group);
281
+ }
282
+ let completed = 0, failed = 0;
283
+ for (const [id, entries] of byOperation) {
284
+ const first = entries[0];
285
+ if (first.operationState === "completed_with_warning") await journal.resumeFinalization(id, releaseId, now);
286
+ else await journal.beginFinalization(id, releaseId, now);
287
+ for (const entry of entries) {
288
+ try {
289
+ if (entry.type === "delete") await moveToTrash(env.MEDIA, entry.sourceKey);
290
+ else await env.MEDIA.delete(entry.sourceKey);
291
+ await journal.recordFinalizedEntry({ operationId: id, entryId: entry.entryId, now, outcome: "completed" });
292
+ completed += 1;
293
+ } catch {
294
+ await journal.recordFinalizedEntry({ operationId: id, entryId: entry.entryId, now, outcome: "failed", safeError: "La pulizia del Media verrà ritentata." });
295
+ failed += 1;
296
+ }
297
+ }
298
+ }
299
+ return { completed, failed };
300
+ }
301
+
302
+ export async function resumeD1MediaOperation({ env, data, operationId: id, releaseId }) {
303
+ if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
304
+ editor(data);
305
+ if (typeof id !== "string" || typeof releaseId !== "string") throw new Error("invalid_media_operation");
306
+ const journal = new D1MediaOperationRepository(env.CONTENT_DB);
307
+ await journal.resumeFinalization(id, releaseId, Date.now());
308
+ return finalizeD1MediaRelease({ env, releaseId, now: Date.now() });
309
+ }
310
+
311
+ export async function cancelD1MediaOperation({ env, data, operationId: id }) {
312
+ if (!env.CONTENT_DB) throw new Error("content_unavailable");
313
+ editor(data);
314
+ if (typeof id !== "string") throw new Error("invalid_media_operation");
315
+ await new D1MediaOperationRepository(env.CONTENT_DB).cancel(id, Date.now());
316
+ return { cancelled: true };
317
+ }
@@ -23,6 +23,8 @@ import { mediaUrl } from "../../_shared/refs.js";
23
23
  import { rewriteMediaReferences } from "../../_shared/media-content.js";
24
24
  import { getBase, DRAFT } from "../../_shared/github.js";
25
25
  import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
26
+ import { contentPersistenceMode } from "../../site.js";
27
+ import { d1DeleteMedia, d1PendingSourceKeys } from "../../_shared/d1-media.js";
26
28
 
27
29
  const enc = new TextEncoder();
28
30
 
@@ -90,7 +92,7 @@ export async function verifyRefsPreflight(env, token, keys, currentCommit) {
90
92
  return keys.every((key) => allowed.has(key));
91
93
  }
92
94
 
93
- export async function onRequestPost({ request, env }) {
95
+ export async function onRequestPost({ request, env, data }) {
94
96
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
95
97
  let body;
96
98
  try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
@@ -115,6 +117,22 @@ export async function onRequestPost({ request, env }) {
115
117
  const keyList = [...keys].filter((k) => !k.startsWith("_trash/"));
116
118
  if (!keyList.length && !keepKeys.length) return json({ ok: true, trashed: 0, pagesUpdated: [] });
117
119
 
120
+ if (contentPersistenceMode() === "d1") {
121
+ try {
122
+ /* A legacy client may repeat its old refsOnly/skipRefs sequence. Once a
123
+ * D1 operation owns a source, later chunks can only remove .keep files;
124
+ * they cannot manufacture a second destructive operation. */
125
+ const pending = await d1PendingSourceKeys(env.CONTENT_DB);
126
+ const operable = keyList.filter((key) => !pending.has(key));
127
+ const result = operable.length ? await d1DeleteMedia({ env, data, body: { ...body, keys: operable } }) : null;
128
+ for (const keepKey of keepKeys) await env.MEDIA.delete(keepKey);
129
+ return json({ ok: true, trashed: result?.trashed ?? keyList.length, operationId: result?.operationId, pagesUpdated: result?.documents.map((document) => document.kind) ?? [], deferred: Boolean(result), d1: true, warning: result ? "I file resteranno disponibili fino alla pubblicazione." : undefined });
130
+ } catch (error) {
131
+ const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
132
+ 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);
133
+ }
134
+ }
135
+
118
136
  const refsOnly = body.refsOnly === true;
119
137
  const skipRefs = body.skipRefs === true;
120
138
  if (keyList.length && (!env.GITHUB_TOKEN || !env.GITHUB_REPO)) {
@@ -4,14 +4,17 @@
4
4
  */
5
5
  import { json, TRASH, listKeysUnder } from "../../_shared/assets.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
  export async function onRequestGet({ env }) {
9
11
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
10
12
  const keys = await listKeysUnder(env.MEDIA, "");
11
- const pending = await pendingSourceKeys(env.MEDIA);
13
+ const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
12
14
  const counts = {};
13
15
  for (const k of keys) {
14
- if (k.startsWith(TRASH) || k.startsWith("_pending-media/") || pending.has(k)) continue;
16
+ if (k.startsWith(TRASH) || k.startsWith("_pending-media/") ||
17
+ k.startsWith("_adminkit/") || pending.has(k)) continue;
15
18
  const slash = k.indexOf("/");
16
19
  if (slash < 0) continue; // file in root: non appartiene a una cartella
17
20
  const top = k.slice(0, slash);
@@ -0,0 +1,30 @@
1
+ /* D1 Media operation status and bounded recovery. These routes exist only
2
+ * after D1 cutover; Git-mode Media keeps its established pending-marker flow. */
3
+ import { json } from "../../_shared/assets.js";
4
+ import { D1MediaOperationRepository } from "../../../media.js";
5
+ import { contentPersistenceMode } from "../../site.js";
6
+ import { cancelD1MediaOperation, resumeD1MediaOperation } from "../../_shared/d1-media.js";
7
+
8
+ function unavailable() { return json({ ok: false, code: "feature_inactive", error: "Journal Media D1 non attivo." }, 404); }
9
+ function failure(error) {
10
+ const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_operation_unavailable" ? "media_operation_unavailable" : "media_operation_invalid";
11
+ return json({ ok: false, code, error: code === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare." : "L'operazione Media non può essere completata." }, code === "unauthenticated" ? 401 : 409);
12
+ }
13
+
14
+ export async function onRequestGet({ env }) {
15
+ if (contentPersistenceMode() !== "d1") return unavailable();
16
+ if (!env.CONTENT_DB) return json({ ok: false, code: "content_unavailable", error: "Persistenza contenuti non disponibile." }, 503);
17
+ try { return json({ ok: true, operations: await new D1MediaOperationRepository(env.CONTENT_DB).statuses() }); }
18
+ catch (error) { return failure(error); }
19
+ }
20
+
21
+ export async function onRequestPost({ request, env, data }) {
22
+ if (contentPersistenceMode() !== "d1") return unavailable();
23
+ let body;
24
+ try { body = await request.json(); } catch { return json({ ok: false, code: "media_operation_invalid", error: "JSON non valido." }, 400); }
25
+ try {
26
+ if (body?.action === "cancel") return json({ ok: true, ...(await cancelD1MediaOperation({ env, data, operationId: body.operationId })) });
27
+ if (body?.action === "resume") return json({ ok: true, ...(await resumeD1MediaOperation({ env, data, operationId: body.operationId, releaseId: body.releaseId })) });
28
+ throw new Error("invalid_media_operation");
29
+ } catch (error) { return failure(error); }
30
+ }