@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
|
@@ -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,
|
|
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([
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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/") ||
|
|
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
|
+
}
|