@cardinali-studio/adminkit 1.0.0
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 +195 -0
- package/dist/api.js +118 -0
- package/dist/app/App.d.ts +3 -0
- package/dist/app/App.js +320 -0
- package/dist/app/app.css +2068 -0
- package/dist/app/index.d.ts +18 -0
- package/dist/app/index.js +33 -0
- package/dist/app/lib/bookings.d.ts +132 -0
- package/dist/app/lib/bookings.js +79 -0
- package/dist/app/lib/deliveries.d.ts +95 -0
- package/dist/app/lib/deliveries.js +104 -0
- package/dist/app/lib/delivery-drop.d.ts +1 -0
- package/dist/app/lib/delivery-drop.js +72 -0
- package/dist/app/lib/delivery-upload.d.ts +78 -0
- package/dist/app/lib/delivery-upload.js +585 -0
- package/dist/app/lib/groups.d.ts +9 -0
- package/dist/app/lib/groups.js +32 -0
- package/dist/app/lib/home.d.ts +11 -0
- package/dist/app/lib/home.js +67 -0
- package/dist/app/lib/overview.d.ts +82 -0
- package/dist/app/lib/overview.js +68 -0
- package/dist/app/lib/pages.d.ts +24 -0
- package/dist/app/lib/pages.js +156 -0
- package/dist/app/lib/publish.d.ts +18 -0
- package/dist/app/lib/publish.js +63 -0
- package/dist/app/lib/reorder.d.ts +3 -0
- package/dist/app/lib/reorder.js +31 -0
- package/dist/app/lib/requests.d.ts +10 -0
- package/dist/app/lib/requests.js +67 -0
- package/dist/app/lib/router.d.ts +1 -0
- package/dist/app/lib/router.js +14 -0
- package/dist/app/lib/settings.d.ts +29 -0
- package/dist/app/lib/settings.js +82 -0
- package/dist/app/lib/slug.d.ts +3 -0
- package/dist/app/lib/slug.js +23 -0
- package/dist/app/lib/text.d.ts +1 -0
- package/dist/app/lib/text.js +13 -0
- package/dist/app/lib/theme.d.ts +7 -0
- package/dist/app/lib/theme.js +43 -0
- package/dist/app/lib/upload-queue.d.ts +35 -0
- package/dist/app/lib/upload-queue.js +167 -0
- package/dist/app/routes/AboutEditor.d.ts +15 -0
- package/dist/app/routes/AboutEditor.js +122 -0
- package/dist/app/routes/Bookings.d.ts +21 -0
- package/dist/app/routes/Bookings.js +134 -0
- package/dist/app/routes/Deliveries.d.ts +37 -0
- package/dist/app/routes/Deliveries.js +563 -0
- package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
- package/dist/app/routes/GallerySectionsEditor.js +107 -0
- package/dist/app/routes/HomeEditor.d.ts +10 -0
- package/dist/app/routes/HomeEditor.js +188 -0
- package/dist/app/routes/Overview.d.ts +17 -0
- package/dist/app/routes/Overview.js +318 -0
- package/dist/app/routes/PageActions.d.ts +12 -0
- package/dist/app/routes/PageActions.js +93 -0
- package/dist/app/routes/PageEditor.d.ts +23 -0
- package/dist/app/routes/PageEditor.js +233 -0
- package/dist/app/routes/PageSeoEditor.d.ts +13 -0
- package/dist/app/routes/PageSeoEditor.js +102 -0
- package/dist/app/routes/PagesList.d.ts +10 -0
- package/dist/app/routes/PagesList.js +76 -0
- package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
- package/dist/app/routes/PortfolioWorksEditor.js +139 -0
- package/dist/app/routes/Requests.d.ts +13 -0
- package/dist/app/routes/Requests.js +30 -0
- package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
- package/dist/app/routes/SiteSettingsEditor.js +66 -0
- package/dist/app/routes/UploadDock.d.ts +1 -0
- package/dist/app/routes/UploadDock.js +66 -0
- package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
- package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
- package/dist/app/site.d.ts +37 -0
- package/dist/app/site.js +18 -0
- package/dist/app/ui/MediaSlot.d.ts +10 -0
- package/dist/app/ui/MediaSlot.js +20 -0
- package/dist/app/ui/OpenCard.d.ts +18 -0
- package/dist/app/ui/OpenCard.js +20 -0
- package/dist/app/ui/ParagraphEditor.d.ts +8 -0
- package/dist/app/ui/ParagraphEditor.js +37 -0
- package/dist/app/ui/dnd.d.ts +1 -0
- package/dist/app/ui/dnd.js +11 -0
- package/dist/app/ui/icons.d.ts +26 -0
- package/dist/app/ui/icons.js +45 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +26 -0
- package/dist/form/MediaBrowser.d.ts +13 -0
- package/dist/form/MediaBrowser.js +24 -0
- package/dist/form/MediaPicker.d.ts +10 -0
- package/dist/form/MediaPicker.js +20 -0
- package/dist/form/SchemaForm.d.ts +21 -0
- package/dist/form/SchemaForm.js +67 -0
- package/dist/form/Trash.d.ts +1 -0
- package/dist/form/Trash.js +42 -0
- package/dist/form/fields/index.d.ts +1 -0
- package/dist/form/fields/index.js +110 -0
- package/dist/form/fields/objectList.d.ts +1 -0
- package/dist/form/fields/objectList.js +99 -0
- package/dist/form/index.d.ts +8 -0
- package/dist/form/index.js +8 -0
- package/dist/form/media/Lightbox.d.ts +4 -0
- package/dist/form/media/Lightbox.js +70 -0
- package/dist/form/media/MediaFolders.d.ts +20 -0
- package/dist/form/media/MediaFolders.js +116 -0
- package/dist/form/media/MediaGrid.d.ts +8 -0
- package/dist/form/media/MediaGrid.js +139 -0
- package/dist/form/media/UploadDialog.d.ts +4 -0
- package/dist/form/media/UploadDialog.js +37 -0
- package/dist/form/media/useMediaLibrary.d.ts +55 -0
- package/dist/form/media/useMediaLibrary.js +495 -0
- package/dist/form/prose/ProseBar.d.ts +21 -0
- package/dist/form/prose/ProseBar.js +111 -0
- package/dist/form/prose/ProseField.d.ts +11 -0
- package/dist/form/prose/ProseField.js +152 -0
- package/dist/form/prose/pages.d.ts +6 -0
- package/dist/form/prose/pages.js +13 -0
- package/dist/form/registry.d.ts +20 -0
- package/dist/form/registry.js +15 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +202 -0
- package/dist/functions/_middleware.js +101 -0
- package/dist/functions/_shared/analytics-ga4.js +248 -0
- package/dist/functions/_shared/analytics-umami.js +119 -0
- package/dist/functions/_shared/assets.js +90 -0
- package/dist/functions/_shared/b2.js +300 -0
- package/dist/functions/_shared/delivery-crypto.js +99 -0
- package/dist/functions/_shared/delivery-purge.js +222 -0
- package/dist/functions/_shared/edge-cache.js +37 -0
- package/dist/functions/_shared/exif.js +53 -0
- package/dist/functions/_shared/github.js +138 -0
- package/dist/functions/_shared/imagesize.js +101 -0
- package/dist/functions/_shared/media-content.js +200 -0
- package/dist/functions/_shared/pending-media.js +98 -0
- package/dist/functions/_shared/refs.js +155 -0
- package/dist/functions/api/analytics.js +106 -0
- package/dist/functions/api/assets/delete.js +241 -0
- package/dist/functions/api/assets/folder.js +43 -0
- package/dist/functions/api/assets/folders.js +32 -0
- package/dist/functions/api/assets/move.js +253 -0
- package/dist/functions/api/assets/preview.js +17 -0
- package/dist/functions/api/assets/trash/delete.js +13 -0
- package/dist/functions/api/assets/trash/empty.js +16 -0
- package/dist/functions/api/assets/trash/restore.js +43 -0
- package/dist/functions/api/assets/trash.js +28 -0
- package/dist/functions/api/assets/upload.js +54 -0
- package/dist/functions/api/assets/usage.js +41 -0
- package/dist/functions/api/assets.js +94 -0
- package/dist/functions/api/deliveries.js +629 -0
- package/dist/functions/api/delivery/hero.js +21 -0
- package/dist/functions/api/delivery/image.js +35 -0
- package/dist/functions/api/delivery/multipart.js +261 -0
- package/dist/functions/api/pages/get.js +26 -0
- package/dist/functions/api/pages.js +354 -0
- package/dist/functions/api/publish.js +134 -0
- package/dist/functions/api/requests.js +57 -0
- package/dist/functions/api/settings.js +236 -0
- package/dist/functions/api/singleton.js +105 -0
- package/dist/functions/api/storage.js +125 -0
- package/dist/functions/index.js +124 -0
- package/dist/functions/site.js +38 -0
- package/dist/gallery.d.ts +28 -0
- package/dist/gallery.js +18 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +47 -0
- package/dist/normalize.d.ts +50 -0
- package/dist/normalize.js +120 -0
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +82 -0
- package/dist/prose.d.ts +17 -0
- package/dist/prose.js +205 -0
- package/dist/registries.d.ts +43 -0
- package/dist/registries.js +29 -0
- package/dist/serialize.d.ts +26 -0
- package/dist/serialize.js +136 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +66 -0
- package/dist/theme.css +636 -0
- package/dist/yaml.d.ts +8 -0
- package/dist/yaml.js +90 -0
- package/package.json +97 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* Estrazione minimale della data di scatto (EXIF DateTimeOriginal, tag 0x9003)
|
|
2
|
+
* dai JPEG. Nessuna dipendenza. Ritorna una stringa ISO (senza timezone) o null.
|
|
3
|
+
* Usato all'upload per permettere l'ordinamento "Data scatto".
|
|
4
|
+
*/
|
|
5
|
+
export function extractCapturedAt(arrayBuffer) {
|
|
6
|
+
try {
|
|
7
|
+
const dv = new DataView(arrayBuffer);
|
|
8
|
+
if (dv.byteLength < 4 || dv.getUint16(0) !== 0xffd8) return null; // non JPEG
|
|
9
|
+
const len = dv.byteLength;
|
|
10
|
+
let offset = 2;
|
|
11
|
+
while (offset + 4 < len) {
|
|
12
|
+
if (dv.getUint8(offset) !== 0xff) break;
|
|
13
|
+
const marker = dv.getUint8(offset + 1);
|
|
14
|
+
const size = dv.getUint16(offset + 2);
|
|
15
|
+
if (marker === 0xe1) {
|
|
16
|
+
// APP1: verifica "Exif\0\0"
|
|
17
|
+
const app1 = offset + 4;
|
|
18
|
+
if (dv.getUint32(app1) !== 0x45786966) return null; // "Exif"
|
|
19
|
+
const tiff = app1 + 6;
|
|
20
|
+
const little = dv.getUint16(tiff) === 0x4949;
|
|
21
|
+
const g16 = (o) => dv.getUint16(o, little);
|
|
22
|
+
const g32 = (o) => dv.getUint32(o, little);
|
|
23
|
+
const ifd0 = tiff + g32(tiff + 4);
|
|
24
|
+
// ExifIFD pointer (0x8769) in IFD0
|
|
25
|
+
let exifIFD = 0;
|
|
26
|
+
const n0 = g16(ifd0);
|
|
27
|
+
for (let i = 0; i < n0; i++) {
|
|
28
|
+
const e = ifd0 + 2 + i * 12;
|
|
29
|
+
if (g16(e) === 0x8769) { exifIFD = tiff + g32(e + 8); break; }
|
|
30
|
+
}
|
|
31
|
+
if (!exifIFD) return null;
|
|
32
|
+
// DateTimeOriginal (0x9003) nell'Exif IFD
|
|
33
|
+
const n1 = g16(exifIFD);
|
|
34
|
+
for (let i = 0; i < n1; i++) {
|
|
35
|
+
const e = exifIFD + 2 + i * 12;
|
|
36
|
+
if (g16(e) === 0x9003) {
|
|
37
|
+
const valOff = tiff + g32(e + 8);
|
|
38
|
+
let s = "";
|
|
39
|
+
for (let j = 0; j < 19 && valOff + j < len; j++) s += String.fromCharCode(dv.getUint8(valOff + j));
|
|
40
|
+
const m = s.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/);
|
|
41
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}` : null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (marker === 0xda) break; // SOS: inizio dati immagine
|
|
47
|
+
offset += 2 + size;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* Helper GitHub API condivisi (lettura contenuti + commit atomico).
|
|
2
|
+
* Usati dagli endpoint Assets per l'avviso "in uso" e la pulizia riferimenti.
|
|
3
|
+
* Richiede env.GITHUB_TOKEN, env.GITHUB_REPO, env.GITHUB_BRANCH.
|
|
4
|
+
*/
|
|
5
|
+
const GH = "https://api.github.com";
|
|
6
|
+
|
|
7
|
+
export function gh(env, path, init = {}) {
|
|
8
|
+
return fetch(GH + path, {
|
|
9
|
+
...init,
|
|
10
|
+
headers: {
|
|
11
|
+
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
|
12
|
+
Accept: "application/vnd.github+json",
|
|
13
|
+
"User-Agent": "adminkit",
|
|
14
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
15
|
+
...(init.headers || {}),
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* IL BRANCH SU CUI LAVORA L'ADMIN. Non è quello pubblicato: salvare dal pannello scrive qui e
|
|
21
|
+
* non fa partire nessuna build, mentre il sito online resta fermo finché non si preme
|
|
22
|
+
* «Pubblica». Vedi `functions/admin/api/publish.js`. */
|
|
23
|
+
export function repoBranch(env) {
|
|
24
|
+
return { repo: env.GITHUB_REPO, branch: env.GITHUB_BRANCH || DRAFT };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* I due nomi sono separati apposta: erano lo stesso letterale `"main"` usato con due
|
|
28
|
+
* significati — «dove scrive l'admin» e «cosa è online» — e finché coincidevano non c'era modo
|
|
29
|
+
* di accorgersi di quale dei due si stesse parlando. */
|
|
30
|
+
export const DRAFT = "bozza";
|
|
31
|
+
export const PROD = "main";
|
|
32
|
+
|
|
33
|
+
/** Il branch PUBBLICATO: lo tocca solo la pubblicazione, mai un salvataggio. */
|
|
34
|
+
export function prodBranch(env) {
|
|
35
|
+
return env.PROD_BRANCH || PROD;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class GitHubConflictError extends Error {
|
|
39
|
+
constructor(message = "Il sito è stato modificato mentre salvavi. Riprova.") {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "GitHubConflictError";
|
|
42
|
+
this.code = "conflict";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** SHA del blob Git che conterrà esattamente questo testo. Evita di rileggere il branch dopo
|
|
47
|
+
* un commit: un'altra sessione potrebbe averlo già fatto avanzare. */
|
|
48
|
+
export async function gitBlobSha(content) {
|
|
49
|
+
const bytes = new TextEncoder().encode(String(content));
|
|
50
|
+
const header = new TextEncoder().encode(`blob ${bytes.byteLength}\0`);
|
|
51
|
+
const input = new Uint8Array(header.byteLength + bytes.byteLength);
|
|
52
|
+
input.set(header, 0);
|
|
53
|
+
input.set(bytes, header.byteLength);
|
|
54
|
+
const digest = await crypto.subtle.digest("SHA-1", input);
|
|
55
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function responseJson(res, context) {
|
|
59
|
+
if (!res.ok) throw new Error(`${context} ${res.status}: ${await res.text()}`);
|
|
60
|
+
return res.json();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function getBase(env) {
|
|
64
|
+
const { repo, branch } = repoBranch(env);
|
|
65
|
+
const r = await gh(env, `/repos/${repo}/git/ref/heads/${branch}`);
|
|
66
|
+
const commitSha = (await responseJson(r, "ref")).object.sha;
|
|
67
|
+
const c = await gh(env, `/repos/${repo}/git/commits/${commitSha}`);
|
|
68
|
+
const treeSha = (await responseJson(c, "commit")).tree.sha;
|
|
69
|
+
return { commitSha, treeSha };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Snapshot coerente del branch: albero e file letti dopo useranno questo stesso commit. */
|
|
73
|
+
export async function getRepoSnapshot(env) {
|
|
74
|
+
const { repo } = repoBranch(env);
|
|
75
|
+
const base = await getBase(env);
|
|
76
|
+
const { treeSha } = base;
|
|
77
|
+
const t = await gh(env, `/repos/${repo}/git/trees/${treeSha}?recursive=1`);
|
|
78
|
+
const body = await responseJson(t, "tree");
|
|
79
|
+
if (body.truncated) {
|
|
80
|
+
throw new Error("L'albero GitHub è troncato: non posso verificare tutti i riferimenti media.");
|
|
81
|
+
}
|
|
82
|
+
return { ...base, tree: body.tree || [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Elenca i path dei file pagina (content/<slug>/index.md).
|
|
86
|
+
export async function listIndexPaths(env) {
|
|
87
|
+
const snapshot = await getRepoSnapshot(env);
|
|
88
|
+
return snapshot.tree
|
|
89
|
+
.filter((x) => x.type === "blob" && /^content\/[^/]+\/index\.md$/.test(x.path))
|
|
90
|
+
.map((x) => x.path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Legge il testo di un file dal repo (o null).
|
|
94
|
+
export async function getText(env, path, ref) {
|
|
95
|
+
const { repo, branch } = repoBranch(env);
|
|
96
|
+
const r = await gh(env, `/repos/${repo}/contents/${path}?ref=${encodeURIComponent(ref || branch)}`);
|
|
97
|
+
if (r.status === 404) return null;
|
|
98
|
+
const j = await responseJson(r, `file ${path}`);
|
|
99
|
+
if (j.encoding !== "base64" || typeof j.content !== "string") {
|
|
100
|
+
throw new Error(`file ${path}: contenuto GitHub non disponibile in base64`);
|
|
101
|
+
}
|
|
102
|
+
const raw = atob((j.content || "").replace(/\n/g, ""));
|
|
103
|
+
const bytes = Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
|
104
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
|
105
|
+
catch { throw new Error(`file ${path}: testo UTF-8 non valido`); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Legge in parallelo tutti i file pagina: [{ path, text }].
|
|
109
|
+
export async function readAllPages(env) {
|
|
110
|
+
const snapshot = await getRepoSnapshot(env);
|
|
111
|
+
const paths = snapshot.tree
|
|
112
|
+
.filter((x) => x.type === "blob" && /^content\/[^/]+\/index\.md$/.test(x.path))
|
|
113
|
+
.map((x) => x.path);
|
|
114
|
+
const pages = await Promise.all(paths.map(async (p) => {
|
|
115
|
+
const text = await getText(env, p, snapshot.commitSha);
|
|
116
|
+
if (text == null) throw new Error(`file ${p}: presente nell'albero ma non leggibile`);
|
|
117
|
+
return { path: p, text };
|
|
118
|
+
}));
|
|
119
|
+
return pages;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Commit atomico di più file (upsert) in un solo commit → un solo rebuild.
|
|
123
|
+
export async function commitUpserts(env, message, files, options = {}) {
|
|
124
|
+
if (!files.length) return null;
|
|
125
|
+
const { repo, branch } = repoBranch(env);
|
|
126
|
+
const { commitSha, treeSha } = options.base || await getBase(env);
|
|
127
|
+
const tree = files.map((f) => ({ path: f.path, mode: "100644", type: "blob", content: f.content }));
|
|
128
|
+
const nt = await gh(env, `/repos/${repo}/git/trees`, { method: "POST", body: JSON.stringify({ base_tree: treeSha, tree }) });
|
|
129
|
+
if (!nt.ok) throw new Error(`tree ${nt.status}: ${await nt.text()}`);
|
|
130
|
+
const newTree = (await nt.json()).sha;
|
|
131
|
+
const nc = await gh(env, `/repos/${repo}/git/commits`, { method: "POST", body: JSON.stringify({ message, tree: newTree, parents: [commitSha] }) });
|
|
132
|
+
if (!nc.ok) throw new Error(`commit ${nc.status}: ${await nc.text()}`);
|
|
133
|
+
const newCommit = (await nc.json()).sha;
|
|
134
|
+
const pr = await gh(env, `/repos/${repo}/git/refs/heads/${branch}`, { method: "PATCH", body: JSON.stringify({ sha: newCommit }) });
|
|
135
|
+
if (pr.status === 422) throw new GitHubConflictError();
|
|
136
|
+
if (!pr.ok) throw new Error(`ref ${pr.status}: ${await pr.text()}`);
|
|
137
|
+
return newCommit;
|
|
138
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* Larghezza e altezza di un'immagine, lette dall'INTESTAZIONE del file.
|
|
2
|
+
*
|
|
3
|
+
* Perché servono: la griglia delle gallery riserva l'ingombro esatto di ogni cella prima che
|
|
4
|
+
* l'immagine arrivi (--ar e --span in gallery.css). Senza le dimensioni le celle avrebbero
|
|
5
|
+
* proporzioni stimate e il layout si riassesterebbe man mano che le foto si caricano — su un
|
|
6
|
+
* centinaio di immagini è una griglia che balla sotto le dita mentre si scorre.
|
|
7
|
+
*
|
|
8
|
+
* Si legge solo l'inizio del file, non l'immagine intera: WebP e PNG tengono le dimensioni
|
|
9
|
+
* entro i primi 32 byte. JPEG no — vanno cercate scorrendo i marker, e con l'EXIF davanti
|
|
10
|
+
* possono stare parecchio più avanti, quindi per quelli si legge un blocco più grande.
|
|
11
|
+
*
|
|
12
|
+
* Se il formato non è fra quelli che sappiamo leggere si torna `null`: chi chiama ripiega
|
|
13
|
+
* sulle proporzioni di default invece di rompersi. Un file illeggibile non deve poter far
|
|
14
|
+
* fallire il caricamento o l'elenco di un'intera cartella.
|
|
15
|
+
*
|
|
16
|
+
* Questo codice viveva dentro functions/api/gallery/[section].js, che calcolava le dimensioni
|
|
17
|
+
* a OGNI richiesta. Ora si calcolano una volta sola, al caricamento, e finiscono nei metadati
|
|
18
|
+
* dell'oggetto R2 (vedi admin/api/assets/upload.js).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Dimensioni da un buffer già in memoria (il caso del caricamento).
|
|
22
|
+
* @returns {[number, number] | null} */
|
|
23
|
+
export function sizeFromBuffer(buffer) {
|
|
24
|
+
try {
|
|
25
|
+
const u8 = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
26
|
+
return parseSize(u8, u8);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Dimensioni di un oggetto già nel bucket, leggendone solo i primi byte.
|
|
33
|
+
* @returns {Promise<[number, number] | null>} */
|
|
34
|
+
export async function sizeFromBucket(bucket, key) {
|
|
35
|
+
try {
|
|
36
|
+
const head = await readRange(bucket, key, 0, 32);
|
|
37
|
+
if (!head) return null;
|
|
38
|
+
// Il JPEG è l'unico che può richiedere più dei 32 byte iniziali: glieli si dà solo se
|
|
39
|
+
// serve davvero, così per WebP e PNG resta una lettura sola e minuscola.
|
|
40
|
+
if (head[0] === 0xff && head[1] === 0xd8) {
|
|
41
|
+
const big = await readRange(bucket, key, 0, 65536);
|
|
42
|
+
return big ? parseSize(head, big) : null;
|
|
43
|
+
}
|
|
44
|
+
return parseSize(head, head);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* `head` sono i primi byte (bastano a PNG e WebP), `full` il blocco più grande che serve al
|
|
51
|
+
* JPEG. Nel caso del buffer in memoria sono lo stesso array. */
|
|
52
|
+
function parseSize(head, full) {
|
|
53
|
+
if (head.length < 16) return null;
|
|
54
|
+
|
|
55
|
+
// PNG: firma + IHDR, larghezza e altezza a 16 e 20 (big-endian, 4 byte).
|
|
56
|
+
if (head[0] === 0x89 && head[1] === 0x50) {
|
|
57
|
+
const dv = view(head);
|
|
58
|
+
return [dv.getUint32(16), dv.getUint32(20)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// WebP: contenitore RIFF, il tipo di chunk sta a 12. Tre varianti, tre posizioni.
|
|
62
|
+
if (head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50) {
|
|
63
|
+
const chunk = String.fromCharCode(head[12], head[13], head[14], head[15]);
|
|
64
|
+
const dv = view(head);
|
|
65
|
+
if (chunk === "VP8X" && head.length >= 30) { // esteso: 3 byte per lato, meno uno
|
|
66
|
+
return [1 + (head[24] | (head[25] << 8) | (head[26] << 16)),
|
|
67
|
+
1 + (head[27] | (head[28] << 8) | (head[29] << 16))];
|
|
68
|
+
}
|
|
69
|
+
if (chunk === "VP8L" && head.length >= 25) { // lossless: 14 bit per lato, impacchettati
|
|
70
|
+
const n = dv.getUint32(21, true);
|
|
71
|
+
return [1 + (n & 0x3fff), 1 + ((n >> 14) & 0x3fff)];
|
|
72
|
+
}
|
|
73
|
+
if (chunk === "VP8 " && head.length >= 30) { // lossy: 14 bit per lato dopo il sync code
|
|
74
|
+
return [dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff];
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// JPEG: si scorrono i marker fino a un SOF (0xC0..0xCF, esclusi i tre che non lo sono).
|
|
80
|
+
if (head[0] === 0xff && head[1] === 0xd8) {
|
|
81
|
+
const dv = view(full);
|
|
82
|
+
let i = 2;
|
|
83
|
+
while (i + 9 < full.length) {
|
|
84
|
+
if (full[i] !== 0xff) { i++; continue; }
|
|
85
|
+
const m = full[i + 1];
|
|
86
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
|
|
87
|
+
return [dv.getUint16(i + 7), dv.getUint16(i + 5)]; // width, height
|
|
88
|
+
}
|
|
89
|
+
i += 2 + dv.getUint16(i + 2); // salta al marker successivo
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readRange(bucket, key, offset, length) {
|
|
96
|
+
const obj = await bucket.get(key, { range: { offset, length } });
|
|
97
|
+
if (!obj) return null;
|
|
98
|
+
return new Uint8Array(await obj.arrayBuffer());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function view(u8) { return new DataView(u8.buffer, u8.byteOffset, u8.byteLength); }
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/* Sorgenti del repo che possono contenere riferimenti a /media/.
|
|
2
|
+
*
|
|
3
|
+
* NON coincide con `isManagedPath`: quello stabilisce quali pagine la riconciliazione
|
|
4
|
+
* dell'admin può creare e cancellare. Qui la domanda è soltanto quali file vadano scanditi e,
|
|
5
|
+
* quando possibile, riscritti durante delete/move dei media.
|
|
6
|
+
*/
|
|
7
|
+
import { deserializeItem, checkFidelity } from "../../index.js";
|
|
8
|
+
import { site } from "../site.js";
|
|
9
|
+
import {
|
|
10
|
+
getRepoSnapshot, getText, commitUpserts, GitHubConflictError,
|
|
11
|
+
} from "./github.js";
|
|
12
|
+
import { removeImageRefs, replaceImageRefs, urlPresent } from "./refs.js";
|
|
13
|
+
|
|
14
|
+
export const SITE_SETTINGS_PATH = "data/site.json";
|
|
15
|
+
|
|
16
|
+
function issueText(issues) {
|
|
17
|
+
return issues.slice(0, 3)
|
|
18
|
+
.map((i) => `${i.path.join(".") || "?"}: ${i.message}`)
|
|
19
|
+
.join("; ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pageLabel(text, path, shape, extra) {
|
|
23
|
+
try {
|
|
24
|
+
const title = deserializeItem(text, shape, extra)?.title;
|
|
25
|
+
return typeof title === "string" && title.trim() ? title.trim() : path;
|
|
26
|
+
} catch {
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function frontmatterSource(path, text, label, shape, object, extra = {}) {
|
|
32
|
+
return { kind: "frontmatter", path, text, label, shape, object, extra };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function jsonSource(path, text, label) {
|
|
36
|
+
return { kind: "settings", path, text, label };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Legge tutte le sorgenti dallo stesso commit, senza trasformare errori in file vuoti. */
|
|
40
|
+
export async function readMediaSources(env) {
|
|
41
|
+
const snapshot = await getRepoSnapshot(env);
|
|
42
|
+
const pagePaths = snapshot.tree
|
|
43
|
+
.filter((x) => x.type === "blob" && site().isManagedPath(x.path))
|
|
44
|
+
.map((x) => x.path);
|
|
45
|
+
const existing = new Set(snapshot.tree.filter((x) => x.type === "blob").map((x) => x.path));
|
|
46
|
+
const fixedPaths = [site().homePath, SITE_SETTINGS_PATH].filter((p) => existing.has(p));
|
|
47
|
+
const paths = [...pagePaths, ...fixedPaths];
|
|
48
|
+
const texts = await Promise.all(paths.map(async (path) => {
|
|
49
|
+
const text = await getText(env, path, snapshot.commitSha);
|
|
50
|
+
if (text == null) throw new Error(`file ${path}: presente nell'albero ma non leggibile`);
|
|
51
|
+
return text;
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const sources = paths.map((path, i) => {
|
|
55
|
+
const text = texts[i];
|
|
56
|
+
if (path === site().homePath) {
|
|
57
|
+
return frontmatterSource(path, text, "Home", site().home, site().homeObject);
|
|
58
|
+
}
|
|
59
|
+
if (path === SITE_SETTINGS_PATH) {
|
|
60
|
+
return jsonSource(path, text, "Impostazioni sito");
|
|
61
|
+
}
|
|
62
|
+
const slug = path.split("/")[1] || "";
|
|
63
|
+
const extra = { slug };
|
|
64
|
+
return frontmatterSource(path, text, pageLabel(text, path, site().page, extra), site().page, site().pageObject, extra);
|
|
65
|
+
});
|
|
66
|
+
return { base: { commitSha: snapshot.commitSha, treeSha: snapshot.treeSha }, sources };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function summarizeMediaUsage(sources, keys, mediaUrl) {
|
|
70
|
+
const usage = Object.create(null);
|
|
71
|
+
const labels = new Set();
|
|
72
|
+
for (const key of keys) {
|
|
73
|
+
const url = mediaUrl(key);
|
|
74
|
+
const used = sources.filter((s) => urlPresent(s.text, url)).map((s) => s.label);
|
|
75
|
+
if (!used.length) continue;
|
|
76
|
+
usage[key] = [...new Set(used)];
|
|
77
|
+
used.forEach((label) => labels.add(label));
|
|
78
|
+
}
|
|
79
|
+
return { usage, pages: [...labels], count: Object.keys(usage).length, selectedCount: keys.length };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateFrontmatter(source, text) {
|
|
83
|
+
const fidelity = checkFidelity(text, source.shape, source.extra);
|
|
84
|
+
if (!fidelity.ok) {
|
|
85
|
+
return `non riesco a riprodurre fedelmente ${source.path}`;
|
|
86
|
+
}
|
|
87
|
+
let model;
|
|
88
|
+
try {
|
|
89
|
+
model = deserializeItem(text, source.shape, source.extra);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return `${source.path} non è leggibile: ${error.message || String(error)}`;
|
|
92
|
+
}
|
|
93
|
+
const parsed = source.object.safeParse(model);
|
|
94
|
+
if (!parsed.success) {
|
|
95
|
+
return `${source.path} non sarebbe più valido (${issueText(parsed.error.issues)})`;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rewriteSettings(text, operation) {
|
|
101
|
+
let data;
|
|
102
|
+
try { data = JSON.parse(text); }
|
|
103
|
+
catch { return { changed: false, text, error: `${SITE_SETTINGS_PATH} non contiene JSON valido` }; }
|
|
104
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
105
|
+
return { changed: false, text, error: `${SITE_SETTINGS_PATH} non contiene un oggetto JSON` };
|
|
106
|
+
}
|
|
107
|
+
const old = typeof data.favicon === "string" ? data.favicon : "";
|
|
108
|
+
let next = old;
|
|
109
|
+
if (operation.removeUrls?.includes(old)) next = "";
|
|
110
|
+
if (operation.map && Object.hasOwn(operation.map, old)) next = operation.map[old];
|
|
111
|
+
if (next === old) return { changed: false, text };
|
|
112
|
+
data.favicon = next;
|
|
113
|
+
return { changed: true, text: JSON.stringify(data, null, 2) + "\n" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Funzione pura: prepara tutte le modifiche oppure restituisce i motivi per bloccarle. */
|
|
117
|
+
export function planMediaRewrite(sources, operation) {
|
|
118
|
+
const oldUrls = operation.removeUrls || Object.keys(operation.map || {});
|
|
119
|
+
const changes = [];
|
|
120
|
+
const labels = [];
|
|
121
|
+
const blockers = [];
|
|
122
|
+
|
|
123
|
+
for (const source of sources) {
|
|
124
|
+
const present = oldUrls.filter((url) => urlPresent(source.text, url));
|
|
125
|
+
if (!present.length) continue;
|
|
126
|
+
|
|
127
|
+
let result;
|
|
128
|
+
if (source.kind === "settings") {
|
|
129
|
+
result = rewriteSettings(source.text, operation);
|
|
130
|
+
} else if (operation.removeUrls) {
|
|
131
|
+
result = removeImageRefs(source.text, present);
|
|
132
|
+
} else {
|
|
133
|
+
result = replaceImageRefs(source.text, operation.map || {});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.error) {
|
|
137
|
+
blockers.push({ path: source.path, label: source.label, reason: result.error });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const remaining = present.filter((url) => urlPresent(result.text, url));
|
|
141
|
+
if (!result.changed || remaining.length) {
|
|
142
|
+
blockers.push({
|
|
143
|
+
path: source.path,
|
|
144
|
+
label: source.label,
|
|
145
|
+
reason: `non so aggiornare in sicurezza ${remaining.join(", ") || present.join(", ")}`,
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (source.kind === "frontmatter") {
|
|
150
|
+
const error = validateFrontmatter(source, result.text);
|
|
151
|
+
if (error) {
|
|
152
|
+
blockers.push({ path: source.path, label: source.label, reason: error });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
changes.push({ path: source.path, content: result.text });
|
|
157
|
+
labels.push(source.label);
|
|
158
|
+
}
|
|
159
|
+
return { changes, labels: [...new Set(labels)], blockers };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export class MediaRewriteError extends Error {
|
|
163
|
+
constructor(blockers) {
|
|
164
|
+
const detail = blockers.map((b) => `${b.label}: ${b.reason}`).join("; ");
|
|
165
|
+
super(`Operazione annullata: ${detail}`);
|
|
166
|
+
this.name = "MediaRewriteError";
|
|
167
|
+
this.code = "media_refs_blocked";
|
|
168
|
+
this.blockers = blockers;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Rilegge e ricalcola una volta se il branch cambia durante il commit. */
|
|
173
|
+
export async function rewriteMediaReferences(env, operation, message) {
|
|
174
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
175
|
+
const { base, sources } = await readMediaSources(env);
|
|
176
|
+
const plan = planMediaRewrite(sources, operation);
|
|
177
|
+
if (plan.blockers.length) throw new MediaRewriteError(plan.blockers);
|
|
178
|
+
if (!plan.changes.length) {
|
|
179
|
+
return {
|
|
180
|
+
commit: null,
|
|
181
|
+
verifiedCommit: base.commitSha,
|
|
182
|
+
filesUpdated: [],
|
|
183
|
+
labels: [],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const commit = await commitUpserts(env, message, plan.changes, { base });
|
|
188
|
+
return {
|
|
189
|
+
commit,
|
|
190
|
+
verifiedCommit: commit,
|
|
191
|
+
filesUpdated: plan.changes.map((f) => f.path),
|
|
192
|
+
labels: plan.labels,
|
|
193
|
+
};
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof GitHubConflictError && attempt === 0) continue;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw new GitHubConflictError();
|
|
200
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { gh, repoBranch, prodBranch } from "./github.js";
|
|
2
|
+
import { readMediaSources } from "./media-content.js";
|
|
3
|
+
import { mediaUrl, urlPresent } from "./refs.js";
|
|
4
|
+
import { moveToTrash } from "./assets.js";
|
|
5
|
+
|
|
6
|
+
const PREFIX = "_pending-media/";
|
|
7
|
+
|
|
8
|
+
async function readRecords(bucket) {
|
|
9
|
+
const records = [];
|
|
10
|
+
let cursor;
|
|
11
|
+
do {
|
|
12
|
+
const page = await bucket.list({ prefix: PREFIX, cursor, limit: 1000 });
|
|
13
|
+
for (const item of page.objects || []) {
|
|
14
|
+
try {
|
|
15
|
+
const object = await bucket.get(item.key);
|
|
16
|
+
if (!object) continue;
|
|
17
|
+
const value = JSON.parse(await object.text());
|
|
18
|
+
if (value && Array.isArray(value.entries)) records.push({ marker: item.key, ...value });
|
|
19
|
+
} catch { /* Un marker corrotto resta intatto: non si indovina cosa cancellare. */ }
|
|
20
|
+
}
|
|
21
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
22
|
+
} while (cursor);
|
|
23
|
+
return records;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function recordPendingMedia(bucket, kind, entries, commit) {
|
|
27
|
+
if (!entries.length) return null;
|
|
28
|
+
const marker = `${PREFIX}${Date.now()}-${crypto.randomUUID()}.json`;
|
|
29
|
+
await bucket.put(marker, JSON.stringify({ kind, entries, commit, createdAt: new Date().toISOString() }), {
|
|
30
|
+
httpMetadata: { contentType: "application/json" },
|
|
31
|
+
});
|
|
32
|
+
return marker;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function pendingSourceKeys(bucket) {
|
|
36
|
+
const keys = new Set();
|
|
37
|
+
for (const record of await readRecords(bucket)) {
|
|
38
|
+
for (const entry of record.entries) {
|
|
39
|
+
if (typeof entry?.oldKey === "string") keys.add(entry.oldKey);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return keys;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function headSha(env, repo, branch) {
|
|
46
|
+
const response = await gh(env, `/repos/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
|
|
47
|
+
if (!response.ok) throw new Error(`ref ${branch} ${response.status}`);
|
|
48
|
+
return (await response.json()).object.sha;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Completa le operazioni R2 soltanto quando bozza e produzione puntano allo stesso commit e
|
|
53
|
+
* quel commit non contiene più nessuno degli URL vecchi. Fino ad allora gli originali restano
|
|
54
|
+
* disponibili al sito pubblicato.
|
|
55
|
+
*/
|
|
56
|
+
export async function finalizePendingMedia(env) {
|
|
57
|
+
if (!env.MEDIA || !env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
|
58
|
+
return { finalized: 0, pending: 0, skipped: "binding non configurati" };
|
|
59
|
+
}
|
|
60
|
+
const { repo, branch } = repoBranch(env);
|
|
61
|
+
const prod = prodBranch(env);
|
|
62
|
+
const [draftSha, prodSha] = await Promise.all([
|
|
63
|
+
headSha(env, repo, branch),
|
|
64
|
+
headSha(env, repo, prod),
|
|
65
|
+
]);
|
|
66
|
+
const records = await readRecords(env.MEDIA);
|
|
67
|
+
if (draftSha !== prodSha) return { finalized: 0, pending: records.length, skipped: "branch non allineati" };
|
|
68
|
+
|
|
69
|
+
const { sources } = await readMediaSources(env);
|
|
70
|
+
let finalized = 0;
|
|
71
|
+
const failures = [];
|
|
72
|
+
|
|
73
|
+
for (const record of records) {
|
|
74
|
+
const oldKeys = record.entries.map((entry) => entry?.oldKey).filter((key) => typeof key === "string");
|
|
75
|
+
const stillReferenced = oldKeys.some((key) => {
|
|
76
|
+
const url = mediaUrl(key);
|
|
77
|
+
return sources.some((source) => urlPresent(source.text, url));
|
|
78
|
+
});
|
|
79
|
+
if (stillReferenced) continue;
|
|
80
|
+
|
|
81
|
+
let complete = true;
|
|
82
|
+
for (const key of oldKeys) {
|
|
83
|
+
try {
|
|
84
|
+
if (record.kind === "trash") await moveToTrash(env.MEDIA, key);
|
|
85
|
+
else await env.MEDIA.delete(key);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
complete = false;
|
|
88
|
+
failures.push({ key, error: error?.message || String(error) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (complete) {
|
|
92
|
+
await env.MEDIA.delete(record.marker);
|
|
93
|
+
finalized++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { finalized, pending: records.length - finalized, failures };
|
|
98
|
+
}
|