@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,35 @@
|
|
|
1
|
+
/* Anteprima admin di un membro contenuto negli ZIP web/thumbs.
|
|
2
|
+
* Access è già verificato dal middleware; Range evita duplicazioni su B2.
|
|
3
|
+
*/
|
|
4
|
+
import { b2Get } from "../../_shared/b2.js";
|
|
5
|
+
|
|
6
|
+
export async function onRequestGet({ request, env }) {
|
|
7
|
+
if (!env.DB) return new Response("D1 non configurato", { status: 500 });
|
|
8
|
+
const url = new URL(request.url);
|
|
9
|
+
const packageId = url.searchParams.get("packageId") || "";
|
|
10
|
+
const variant = url.searchParams.get("variant") === "thumb" ? "thumb" : "web";
|
|
11
|
+
const index = Number(url.searchParams.get("index"));
|
|
12
|
+
const pkg = await env.DB.prepare(`
|
|
13
|
+
SELECT web_key,thumbs_key,manifest_json FROM delivery_packages
|
|
14
|
+
WHERE id=? AND state='ready'
|
|
15
|
+
`).bind(packageId).first();
|
|
16
|
+
const manifest = parseManifest(pkg?.manifest_json);
|
|
17
|
+
const entry = Number.isInteger(index) && index >= 0 ? manifest.entries?.[index] : null;
|
|
18
|
+
const member = variant === "thumb" ? entry?.thumb : entry?.web;
|
|
19
|
+
const key = variant === "thumb" ? pkg?.thumbs_key : pkg?.web_key;
|
|
20
|
+
if (!member || !key) return new Response("Not found", { status: 404 });
|
|
21
|
+
const upstream = await b2Get(env, key, `bytes=${member.offset}-${member.offset + member.length - 1}`);
|
|
22
|
+
if (!upstream.ok) return new Response("Not found", { status: upstream.status });
|
|
23
|
+
return new Response(upstream.body, {
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type": "image/webp",
|
|
26
|
+
"Content-Length": String(member.length),
|
|
27
|
+
"Cache-Control": "private, max-age=3600",
|
|
28
|
+
"X-Content-Type-Options": "nosniff",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseManifest(value) {
|
|
34
|
+
try { return JSON.parse(value || "{}"); } catch { return { entries: [] }; }
|
|
35
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/* Controllo multipart Backblaze B2.
|
|
2
|
+
*
|
|
3
|
+
* La Function crea/firma/completa; il browser invia ogni parte DIRETTAMENTE a
|
|
4
|
+
* B2 con l'URL prefirmato restituito da `presign`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
b2AbortMultipart, b2CompleteMultipart, b2CreateMultipart, b2ListParts,
|
|
9
|
+
b2ObjectKey, b2PresignPutObject, b2PresignUploadPart,
|
|
10
|
+
} from "../../_shared/b2.js";
|
|
11
|
+
|
|
12
|
+
const FILENAMES = {
|
|
13
|
+
originals: "originals.zip",
|
|
14
|
+
web: "web.zip",
|
|
15
|
+
thumbs: "thumbs.zip",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function onRequestPost({ request, env }) {
|
|
19
|
+
if (!env.DB) return json({ ok: false, error: "D1 non configurato" }, 500);
|
|
20
|
+
let body;
|
|
21
|
+
try { body = await request.json(); }
|
|
22
|
+
catch { return json({ ok: false, error: "Corpo JSON non valido" }, 400); }
|
|
23
|
+
try {
|
|
24
|
+
switch (body?.action) {
|
|
25
|
+
case "create": return json({ ok: true, upload: await create(env, body) });
|
|
26
|
+
case "putPresign": return json({ ok: true, ...(await putPresign(env, body)) });
|
|
27
|
+
case "putComplete": return json({ ok: true, ...(await putComplete(env, body)) });
|
|
28
|
+
case "heroPresign": return json({ ok: true, ...(await heroPresign(env, body)) });
|
|
29
|
+
case "presign": return json({ ok: true, ...(await presign(env, body)) });
|
|
30
|
+
case "record": return json({ ok: true, ...(await record(env, body)) });
|
|
31
|
+
case "reconcile": return json({ ok: true, parts: await reconcile(env, body) });
|
|
32
|
+
case "complete": return json({ ok: true, ...(await complete(env, body)) });
|
|
33
|
+
case "abort": return json({ ok: true, ...(await abort(env, body)) });
|
|
34
|
+
default: return json({ ok: false, error: "Azione multipart non valida" }, 400);
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error("delivery multipart:", e);
|
|
38
|
+
return json({ ok: false, error: e instanceof Error ? e.message : String(e) }, 500);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function heroPresign(env, body) {
|
|
43
|
+
const deliveryId = String(body.id || "");
|
|
44
|
+
const packageId = String(body.packageId || "");
|
|
45
|
+
const pkg = await env.DB.prepare(`
|
|
46
|
+
SELECT p.* FROM delivery_packages p JOIN deliveries d ON d.id=p.delivery_id
|
|
47
|
+
WHERE p.id=? AND p.delivery_id=? AND p.state IN ('uploading','ready') AND d.status!='deleted'
|
|
48
|
+
`).bind(packageId, deliveryId).first();
|
|
49
|
+
if (!pkg) throw new Error("pacchetto per la hero non trovato");
|
|
50
|
+
const heroId = crypto.randomUUID();
|
|
51
|
+
const key = b2ObjectKey(pkg.storage_prefix, `heroes/${heroId}.webp`);
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
await env.DB.prepare(`
|
|
54
|
+
INSERT INTO delivery_hero_objects
|
|
55
|
+
(id,delivery_id,package_id,object_key,state,created_at,updated_at)
|
|
56
|
+
VALUES (?,?,?,?,'pending',?,?)
|
|
57
|
+
`).bind(heroId, deliveryId, packageId, key, now, now).run();
|
|
58
|
+
return {
|
|
59
|
+
heroId, key,
|
|
60
|
+
url: await b2PresignPutObject(env, key),
|
|
61
|
+
expiresIn: 900,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function putPresign(env, body) {
|
|
66
|
+
const pkg = await packageFor(env, body);
|
|
67
|
+
const variant = String(body.variant || "");
|
|
68
|
+
if (!FILENAMES[variant]) throw new Error("variante upload non valida");
|
|
69
|
+
if (variant === "web" && !pkg.web_enabled && !pkg.gallery_enabled) {
|
|
70
|
+
throw new Error("versione web non richiesta");
|
|
71
|
+
}
|
|
72
|
+
if (variant === "thumbs" && !pkg.gallery_enabled) throw new Error("gallery non richiesta");
|
|
73
|
+
const key = b2ObjectKey(pkg.storage_prefix, FILENAMES[variant]);
|
|
74
|
+
return { url: await b2PresignPutObject(env, key), key, variant, expiresIn: 900 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function putComplete(env, body) {
|
|
78
|
+
const pkg = await packageFor(env, body);
|
|
79
|
+
const variant = String(body.variant || "");
|
|
80
|
+
if (!FILENAMES[variant]) throw new Error("variante upload non valida");
|
|
81
|
+
const key = b2ObjectKey(pkg.storage_prefix, FILENAMES[variant]);
|
|
82
|
+
const id = crypto.randomUUID();
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
const previous = await env.DB.prepare(
|
|
85
|
+
"SELECT id FROM delivery_uploads WHERE package_id=? AND variant=?"
|
|
86
|
+
).bind(pkg.id, variant).first();
|
|
87
|
+
if (previous) {
|
|
88
|
+
await env.DB.batch([
|
|
89
|
+
env.DB.prepare("DELETE FROM delivery_upload_parts WHERE upload_id=?").bind(previous.id),
|
|
90
|
+
env.DB.prepare(`
|
|
91
|
+
UPDATE delivery_uploads SET object_key=?,b2_upload_id='single',state='completed',
|
|
92
|
+
bytes=?,updated_at=?,completed_at=? WHERE id=?
|
|
93
|
+
`).bind(key, Number(body.bytes || 0), now, now, previous.id),
|
|
94
|
+
]);
|
|
95
|
+
return { uploadId: previous.id, key, bytes: Number(body.bytes || 0) };
|
|
96
|
+
}
|
|
97
|
+
await env.DB.prepare(`
|
|
98
|
+
INSERT INTO delivery_uploads
|
|
99
|
+
(id,delivery_id,package_id,variant,object_key,b2_upload_id,bytes,state,created_at,updated_at,completed_at)
|
|
100
|
+
VALUES (?,?,?,?,?,'single',?,'completed',?,?,?)
|
|
101
|
+
`).bind(id, pkg.delivery_id, pkg.id, variant, key, Number(body.bytes || 0), now, now, now).run();
|
|
102
|
+
return { uploadId: id, key, bytes: Number(body.bytes || 0) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function create(env, body) {
|
|
106
|
+
const pkg = await packageFor(env, body);
|
|
107
|
+
const variant = String(body.variant || "");
|
|
108
|
+
if (!FILENAMES[variant]) throw new Error("variante multipart non valida");
|
|
109
|
+
if (variant === "web" && !pkg.web_enabled && !pkg.gallery_enabled) {
|
|
110
|
+
throw new Error("versione web non richiesta");
|
|
111
|
+
}
|
|
112
|
+
if (variant === "thumbs" && !pkg.gallery_enabled) throw new Error("gallery non richiesta");
|
|
113
|
+
|
|
114
|
+
const previous = await env.DB.prepare(
|
|
115
|
+
"SELECT * FROM delivery_uploads WHERE package_id=? AND variant=?"
|
|
116
|
+
).bind(pkg.id, variant).first();
|
|
117
|
+
if (previous?.state === "uploading") {
|
|
118
|
+
return normalizeUpload(previous);
|
|
119
|
+
}
|
|
120
|
+
if (previous?.state === "completed") {
|
|
121
|
+
return normalizeUpload(previous);
|
|
122
|
+
}
|
|
123
|
+
const key = b2ObjectKey(pkg.storage_prefix, FILENAMES[variant]);
|
|
124
|
+
const b2UploadId = await b2CreateMultipart(env, key);
|
|
125
|
+
const now = new Date().toISOString();
|
|
126
|
+
const id = previous?.id || crypto.randomUUID();
|
|
127
|
+
if (previous) {
|
|
128
|
+
await env.DB.batch([
|
|
129
|
+
env.DB.prepare("DELETE FROM delivery_upload_parts WHERE upload_id=?").bind(id),
|
|
130
|
+
env.DB.prepare(`
|
|
131
|
+
UPDATE delivery_uploads SET object_key=?,b2_upload_id=?,state='uploading',
|
|
132
|
+
created_at=?,updated_at=?,completed_at=NULL WHERE id=?
|
|
133
|
+
`).bind(key, b2UploadId, now, now, id),
|
|
134
|
+
]);
|
|
135
|
+
} else {
|
|
136
|
+
await env.DB.prepare(`
|
|
137
|
+
INSERT INTO delivery_uploads
|
|
138
|
+
(id,delivery_id,package_id,variant,object_key,b2_upload_id,state,created_at,updated_at)
|
|
139
|
+
VALUES (?,?,?,?,?,?,'uploading',?,?)
|
|
140
|
+
`).bind(id, pkg.delivery_id, pkg.id, variant, key, b2UploadId, now, now).run();
|
|
141
|
+
}
|
|
142
|
+
return { id, deliveryId: pkg.delivery_id, packageId: pkg.id, variant, key, state: "uploading" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function presign(env, body) {
|
|
146
|
+
const upload = await uploadFor(env, body);
|
|
147
|
+
const partNumber = Number(body.partNumber);
|
|
148
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > 10_000) {
|
|
149
|
+
throw new Error("numero parte non valido");
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
url: await b2PresignUploadPart(env, upload.object_key, upload.b2_upload_id, partNumber),
|
|
153
|
+
partNumber,
|
|
154
|
+
expiresIn: 900,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function record(env, body) {
|
|
159
|
+
const upload = await uploadFor(env, body);
|
|
160
|
+
const partNumber = Number(body.partNumber);
|
|
161
|
+
const size = Number(body.size);
|
|
162
|
+
const etag = String(body.etag || "").trim();
|
|
163
|
+
const digest = String(body.digest || "").trim();
|
|
164
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || !Number.isFinite(size) || size < 1 || !etag) {
|
|
165
|
+
throw new Error("metadati parte non validi");
|
|
166
|
+
}
|
|
167
|
+
const now = new Date().toISOString();
|
|
168
|
+
await env.DB.batch([
|
|
169
|
+
env.DB.prepare(`
|
|
170
|
+
INSERT INTO delivery_upload_parts(upload_id,part_number,etag,digest,size,created_at)
|
|
171
|
+
VALUES (?,?,?,?,?,?)
|
|
172
|
+
ON CONFLICT(upload_id,part_number) DO UPDATE SET
|
|
173
|
+
etag=excluded.etag,digest=excluded.digest,size=excluded.size,created_at=excluded.created_at
|
|
174
|
+
`).bind(upload.id, partNumber, etag, digest || null, size, now),
|
|
175
|
+
env.DB.prepare("UPDATE delivery_uploads SET updated_at=? WHERE id=?").bind(now, upload.id),
|
|
176
|
+
]);
|
|
177
|
+
return { partNumber, etag, size };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function reconcile(env, body) {
|
|
181
|
+
const upload = await uploadFor(env, body);
|
|
182
|
+
const parts = await b2ListParts(env, upload.object_key, upload.b2_upload_id);
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
for (let at = 0; at < parts.length; at += 50) {
|
|
185
|
+
await env.DB.batch(parts.slice(at, at + 50).map((part) => env.DB.prepare(`
|
|
186
|
+
INSERT INTO delivery_upload_parts(upload_id,part_number,etag,size,created_at)
|
|
187
|
+
VALUES (?,?,?,?,?)
|
|
188
|
+
ON CONFLICT(upload_id,part_number) DO UPDATE SET
|
|
189
|
+
etag=excluded.etag,size=excluded.size,created_at=excluded.created_at
|
|
190
|
+
`).bind(upload.id, part.partNumber, part.etag, part.size, now)));
|
|
191
|
+
}
|
|
192
|
+
const saved = await env.DB.prepare(
|
|
193
|
+
"SELECT part_number,etag,digest,size FROM delivery_upload_parts WHERE upload_id=? ORDER BY part_number"
|
|
194
|
+
).bind(upload.id).all();
|
|
195
|
+
return (saved.results || []).map((part) => ({
|
|
196
|
+
partNumber: Number(part.part_number), etag: part.etag,
|
|
197
|
+
digest: part.digest || "", size: Number(part.size),
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function complete(env, body) {
|
|
202
|
+
const upload = await uploadFor(env, body);
|
|
203
|
+
/* ListParts è la fonte autorevole: una PUT può riuscire e la successiva chiamata
|
|
204
|
+
`record` interrompersi. Riconciliare sempre evita di lasciare fuori quella parte. */
|
|
205
|
+
await reconcile(env, body);
|
|
206
|
+
const { results } = await env.DB.prepare(
|
|
207
|
+
"SELECT part_number,etag,size FROM delivery_upload_parts WHERE upload_id=? ORDER BY part_number"
|
|
208
|
+
).bind(upload.id).all();
|
|
209
|
+
if (!results?.length) throw new Error("nessuna parte caricata");
|
|
210
|
+
const parts = results.map((p) => ({
|
|
211
|
+
partNumber: Number(p.part_number), etag: p.etag, size: Number(p.size),
|
|
212
|
+
}));
|
|
213
|
+
const answer = await b2CompleteMultipart(env, upload.object_key, upload.b2_upload_id, parts);
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
await env.DB.prepare(
|
|
216
|
+
"UPDATE delivery_uploads SET state='completed',bytes=?,completed_at=?,updated_at=? WHERE id=?"
|
|
217
|
+
).bind(parts.reduce((n, p) => n + p.size, 0), now, now, upload.id).run();
|
|
218
|
+
return { uploadId: upload.id, key: upload.object_key, bytes: parts.reduce((n, p) => n + p.size, 0), ...answer };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function abort(env, body) {
|
|
222
|
+
const upload = await uploadFor(env, body, false);
|
|
223
|
+
if (!upload) return { aborted: false };
|
|
224
|
+
await b2AbortMultipart(env, upload.object_key, upload.b2_upload_id);
|
|
225
|
+
await env.DB.prepare("UPDATE delivery_uploads SET state='aborted',updated_at=? WHERE id=?")
|
|
226
|
+
.bind(new Date().toISOString(), upload.id).run();
|
|
227
|
+
return { aborted: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function packageFor(env, body) {
|
|
231
|
+
const pkg = await env.DB.prepare(`
|
|
232
|
+
SELECT p.* FROM delivery_packages p JOIN deliveries d ON d.id=p.delivery_id
|
|
233
|
+
WHERE p.id=? AND p.delivery_id=? AND p.state='uploading' AND d.status!='deleted'
|
|
234
|
+
`).bind(String(body.packageId || ""), String(body.id || "")).first();
|
|
235
|
+
if (!pkg) throw new Error("pacchetto in caricamento non trovato");
|
|
236
|
+
return pkg;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function uploadFor(env, body, required = true) {
|
|
240
|
+
const upload = await env.DB.prepare(`
|
|
241
|
+
SELECT u.* FROM delivery_uploads u
|
|
242
|
+
JOIN delivery_packages p ON p.id=u.package_id
|
|
243
|
+
JOIN deliveries d ON d.id=u.delivery_id
|
|
244
|
+
WHERE u.id=? AND u.state='uploading' AND p.state='uploading' AND d.status!='deleted'
|
|
245
|
+
`).bind(String(body.uploadId || "")).first();
|
|
246
|
+
if (!upload && required) throw new Error("sessione multipart non trovata");
|
|
247
|
+
return upload;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeUpload(row) {
|
|
251
|
+
return {
|
|
252
|
+
id: row.id, deliveryId: row.delivery_id, packageId: row.package_id,
|
|
253
|
+
variant: row.variant, key: row.object_key, state: row.state,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function json(data, status = 200) {
|
|
258
|
+
return new Response(JSON.stringify(data), {
|
|
259
|
+
status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* GET /admin/api/pages/get?slug=<slug> — corpo (markdown) e front matter di una
|
|
2
|
+
* pagina, letti dal repo. Usato dall'editor pagina. Dietro Access.
|
|
3
|
+
*/
|
|
4
|
+
import { json } from "../../_shared/assets.js";
|
|
5
|
+
import { getText } from "../../_shared/github.js";
|
|
6
|
+
import { splitFrontMatter, deserializeItem } from "../../../index.js";
|
|
7
|
+
import { site } from "../../site.js";
|
|
8
|
+
|
|
9
|
+
export async function onRequestGet({ request, env }) {
|
|
10
|
+
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
|
|
11
|
+
const slug = new URL(request.url).searchParams.get("slug") || "";
|
|
12
|
+
if (!/^[a-z0-9-]+$/.test(slug)) return json({ ok: false, error: "slug non valido" }, 400);
|
|
13
|
+
|
|
14
|
+
const text = await getText(env, `content/${slug}/index.md`);
|
|
15
|
+
if (text == null) return json({ ok: false, error: "pagina non trovata" }, 404);
|
|
16
|
+
|
|
17
|
+
const { body: rawBody } = splitFrontMatter(text);
|
|
18
|
+
const body = (rawBody ?? text).replace(/^\n+/, "").replace(/\s+$/, "");
|
|
19
|
+
|
|
20
|
+
// Il front matter lo legge lo schema, non uno scanner di righe scritto qui: prima questo
|
|
21
|
+
// file aveva la sua interpretazione, diversa da quella del serializzatore, e due letture
|
|
22
|
+
// dello stesso formato sono due occasioni di divergere.
|
|
23
|
+
const model = deserializeItem(text, site().page, { slug });
|
|
24
|
+
|
|
25
|
+
return json({ ok: true, slug, body, page: model });
|
|
26
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/* GET·POST /admin/api/pages
|
|
2
|
+
*
|
|
3
|
+
* GET — l'elenco delle pagine letto dal repo (vedi il commento sopra `onRequestGet`).
|
|
4
|
+
* POST — riceve dall'admin l'elenco DESIDERATO e lo riscrive nel repo in un solo commit atomico
|
|
5
|
+
* (Git Data API): crea/aggiorna `content/<slug>/index.md`, gestisce i rename via `_orig`,
|
|
6
|
+
* elimina le cartelle non più presenti.
|
|
7
|
+
*
|
|
8
|
+
* Si scrive sul branch di BOZZA, quindi un salvataggio NON fa partire nessuna build e il sito
|
|
9
|
+
* online non cambia: a mandarlo online è `publish.js`, ed è un gesto a parte.
|
|
10
|
+
*
|
|
11
|
+
* La serializzazione NON è più qui: la fa il codec del kit, guidato dallo schema del sito. Questo
|
|
12
|
+
* file si occupa solo di parlare con GitHub. Ciò che prima era `MANAGED` + `buildMarkdown` —
|
|
13
|
+
* cioè la conoscenza di quali campi esistono e come si scrivono — vive ora in un posto solo,
|
|
14
|
+
* insieme alla conoscenza di come si mostrano nell'admin.
|
|
15
|
+
*
|
|
16
|
+
* Sta dietro Cloudflare Access E dietro la verifica del JWT in functions/admin/_middleware.js.
|
|
17
|
+
* Secret/vars: GITHUB_TOKEN (secret), GITHUB_REPO, GITHUB_BRANCH.
|
|
18
|
+
*/
|
|
19
|
+
import { serializeItem, deserializeItem, checkFidelity } from "../../index.js";
|
|
20
|
+
import { site } from "../site.js";
|
|
21
|
+
import { getRepoSnapshot, getText, DRAFT } from "../_shared/github.js";
|
|
22
|
+
|
|
23
|
+
const GH = "https://api.github.com";
|
|
24
|
+
|
|
25
|
+
function gh(env, path, init = {}) {
|
|
26
|
+
return fetch(`${GH}${path}`, {
|
|
27
|
+
...init,
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
|
30
|
+
Accept: "application/vnd.github+json",
|
|
31
|
+
"User-Agent": "adminkit",
|
|
32
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
33
|
+
...(init.headers || {}),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function json(data, status = 200) {
|
|
39
|
+
return new Response(JSON.stringify(data), {
|
|
40
|
+
status,
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const decode64 = (b64) => decodeURIComponent(escape(atob(String(b64 || "").replace(/\n/g, ""))));
|
|
46
|
+
|
|
47
|
+
async function readFile(env, repo, branch, path) {
|
|
48
|
+
const res = await gh(env, `/repos/${repo}/contents/${path}?ref=${branch}`);
|
|
49
|
+
if (!res.ok) return null;
|
|
50
|
+
const j = await res.json();
|
|
51
|
+
return decode64(j.content);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `type:` di un file, senza parsare tutto: serve solo a non cancellare ciò che non è nostro. */
|
|
55
|
+
function typeOf(md) {
|
|
56
|
+
const m = (md || "").match(/^---\n([\s\S]*?)\n---/);
|
|
57
|
+
const t = m && m[1].match(/^type:\s*(.+)$/m);
|
|
58
|
+
return t ? t[1].trim().replace(/^["']|["']$/g, "") : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* GET /admin/api/pages — l'elenco delle pagine, letto DAL REPO.
|
|
62
|
+
*
|
|
63
|
+
* Prima l'admin partiva da `/index.json`, cioè dal sito PUBBLICATO: un file costruito
|
|
64
|
+
* dall'ultima build, quindi indietro di una build. Nei minuti dopo un salvataggio — o in una
|
|
65
|
+
* scheda lasciata aperta da un'ora — mostrava lo stato di prima, e modificare partendo da lì
|
|
66
|
+
* riscriveva la pagina con quei valori vecchi: quello che era stato salvato nel frattempo
|
|
67
|
+
* spariva, senza un errore da nessuna parte. È la «finestra di ricostruzione» di ADMIN.md §3,
|
|
68
|
+
* ed è già costata delle card appena aggiunte a un lavoro del portfolio.
|
|
69
|
+
*
|
|
70
|
+
* Leggendo dal repo quella finestra non esiste: non c'è niente da tamponare, perché non c'è
|
|
71
|
+
* niente di stantio. In più l'albero porta con sé lo `sha` di ogni file, che è ciò che permette
|
|
72
|
+
* di accorgersi se qualcuno ha modificato la stessa pagina nel frattempo (vedi il POST).
|
|
73
|
+
*
|
|
74
|
+
* Si usa `getRepoSnapshot` e non `readAllPages`, che pure esiste: servono anche gli `sha`, e
|
|
75
|
+
* quella funzione rifarebbe la stessa chiamata all'albero per conto suo. Tutti i file si leggono
|
|
76
|
+
* dallo STESSO commit, così l'elenco non può essere un misto di due momenti diversi.
|
|
77
|
+
*/
|
|
78
|
+
export async function onRequestGet({ env }) {
|
|
79
|
+
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
|
80
|
+
return json({ ok: false, error: "Backend non configurato (GITHUB_TOKEN/GITHUB_REPO mancanti)." }, 500);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const snapshot = await getRepoSnapshot(env);
|
|
84
|
+
const blobs = snapshot.tree.filter((t) => t.type === "blob" && site().isManagedPath(t.path));
|
|
85
|
+
|
|
86
|
+
const letti = await Promise.all(blobs.map(async (t) => {
|
|
87
|
+
const text = await getText(env, t.path, snapshot.commitSha);
|
|
88
|
+
if (text == null) return null;
|
|
89
|
+
// Stesso filtro del manifest: un tipo escluso non si mostra E non si cancella. Prima i
|
|
90
|
+
// due criteri erano scritti in due posti e coincidevano solo per caso.
|
|
91
|
+
if (site().excludedTypes.includes(typeOf(text))) return null;
|
|
92
|
+
const slug = t.path.split("/")[1];
|
|
93
|
+
return { slug, url: `/${slug}/`, sha: t.sha, page: deserializeItem(text, site().page, { slug }) };
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const pages = letti.filter(Boolean)
|
|
97
|
+
.sort((a, b) => Number(a.page.weight ?? 0) - Number(b.page.weight ?? 0));
|
|
98
|
+
|
|
99
|
+
return json({ ok: true, commit: snapshot.commitSha, pages });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return json({ ok: false, error: String((err && err.message) || err) }, 502);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function onRequestPost({ request, env, data }) {
|
|
106
|
+
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
|
107
|
+
return json({ ok: false, error: "Backend non configurato (GITHUB_TOKEN/GITHUB_REPO mancanti)." }, 500);
|
|
108
|
+
}
|
|
109
|
+
const repo = env.GITHUB_REPO;
|
|
110
|
+
const branch = env.GITHUB_BRANCH || DRAFT;
|
|
111
|
+
// L'email viene dal JWT VERIFICATO dal middleware, non dall'header omonimo: quello lo scrive
|
|
112
|
+
// chiunque con un curl, e qui finisce nel messaggio di commit — l'unica traccia di chi ha
|
|
113
|
+
// cambiato cosa.
|
|
114
|
+
const editor = data?.cloudflareAccess?.JWT?.payload?.email || "admin";
|
|
115
|
+
|
|
116
|
+
let body;
|
|
117
|
+
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
118
|
+
const pages = Array.isArray(body.pages) ? body.pages : null;
|
|
119
|
+
if (!pages) return json({ ok: false, error: "Campo 'pages' mancante" }, 400);
|
|
120
|
+
|
|
121
|
+
// Il peso si RICALCOLA dalla posizione, non si prende dal client: è il server a decidere
|
|
122
|
+
// l'ordine, e l'ordine è ciò che l'utente ha espresso trascinando.
|
|
123
|
+
pages.forEach((p, i) => { p.weight = site().positionWeight(p, i, pages); });
|
|
124
|
+
|
|
125
|
+
/* IL CANCELLO. Questo endpoint scrive contenuti nel repo, e fino a qui l'unico controllo era
|
|
126
|
+
* la forma dello slug: tutto il resto —
|
|
127
|
+
* `type`, `sections`, `works` — diventava YAML e commit senza che nessuno lo guardasse. Ci
|
|
128
|
+
* sono già passate una card del portfolio con `type: ""` (invisibile sul sito, valore che non
|
|
129
|
+
* corrisponde a nessun ramo del template) e il rischio di un `info: ""` al posto di `[]`:
|
|
130
|
+
* valori sintatticamente validi e di FORMA sbagliata, che nessuna delle difese esistenti
|
|
131
|
+
* intercetta. La guardia di fedeltà risponde a un'altra domanda — «so riprodurre il file
|
|
132
|
+
* VECCHIO che sto per sovrascrivere?» — e `validate-content.mjs` gira in CI in parallelo alla
|
|
133
|
+
* build, cioè dice che il sito è sbagliato quando è già online.
|
|
134
|
+
*
|
|
135
|
+
* Si valida DOPO il ricalcolo dei pesi, non prima: l'invariante da garantire è «non
|
|
136
|
+
* scriviamo mai un modello non valido», e il modello che viene scritto è questo.
|
|
137
|
+
*
|
|
138
|
+
* `r.data` non si usa MAI: `z.object` scarta le chiavi che non dichiara, e «un campo non
|
|
139
|
+
* dichiarato sopravvive comunque» è una regola del progetto (ADMIN.md §5) — serializzare il
|
|
140
|
+
* risultato del parse la romperebbe in silenzio. Si valida l'oggetto e si serializza quello. */
|
|
141
|
+
const problems = [];
|
|
142
|
+
for (const p of pages) {
|
|
143
|
+
const r = site().pagePayload.safeParse(p);
|
|
144
|
+
if (r.success) continue;
|
|
145
|
+
const detail = r.error.issues.slice(0, 5)
|
|
146
|
+
.map((i) => `${i.path.join(".") || "?"} ${i.message}`).join("; ");
|
|
147
|
+
problems.push({ slug: typeof p?.slug === "string" ? p.slug : "?", detail });
|
|
148
|
+
}
|
|
149
|
+
if (problems.length) {
|
|
150
|
+
// Tutto o niente, come già fa `unreadable`: il commit è atomico, e un rifiuto parziale
|
|
151
|
+
// lascerebbe metà delle modifiche a terra senza che nessuno sappia quali.
|
|
152
|
+
return json({
|
|
153
|
+
ok: false,
|
|
154
|
+
code: "invalid",
|
|
155
|
+
error: "Contenuti non validi, non ho salvato nulla:\n" +
|
|
156
|
+
problems.map((p) => `${p.slug}: ${p.detail}`).join("\n"),
|
|
157
|
+
slugs: problems.map((p) => p.slug),
|
|
158
|
+
}, 400);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const result = await commitPages({ env, repo, branch, editor, pages });
|
|
163
|
+
return json(result.body, result.status);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return json({ ok: false, error: String((err && err.message) || err) }, 502);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function commitPages(args, attempt = 0) {
|
|
170
|
+
const { env, repo, branch, editor, pages } = args;
|
|
171
|
+
|
|
172
|
+
// 1) ref -> commit base -> tree base
|
|
173
|
+
const refRes = await gh(env, `/repos/${repo}/git/ref/heads/${branch}`);
|
|
174
|
+
if (!refRes.ok) throw new Error(`ref ${refRes.status}: ${await refRes.text()}`);
|
|
175
|
+
const baseCommitSha = (await refRes.json()).object.sha;
|
|
176
|
+
const commitRes = await gh(env, `/repos/${repo}/git/commits/${baseCommitSha}`);
|
|
177
|
+
const baseTreeSha = (await commitRes.json()).tree.sha;
|
|
178
|
+
|
|
179
|
+
// 2) file di contenuto esistenti. `isManagedPath` viene dallo SCHEMA ed è lo stesso predicato
|
|
180
|
+
// che usa il manifest: prima il manifest escludeva per `type` e la spazzata filtrava per
|
|
181
|
+
// forma del path, e coincidevano solo per caso.
|
|
182
|
+
const treeRes = await gh(env, `/repos/${repo}/git/trees/${baseTreeSha}?recursive=1`);
|
|
183
|
+
const tree = (await treeRes.json()).tree || [];
|
|
184
|
+
const managed = tree.filter((t) => site().isManagedPath(t.path));
|
|
185
|
+
const existingSlugs = new Set(managed.map((t) => t.path.split("/")[1]));
|
|
186
|
+
const shaOf = new Map(managed.map((t) => [t.path.split("/")[1], t.sha]));
|
|
187
|
+
|
|
188
|
+
/* CONTROLLO DI VERSIONE PER FILE. Il client dice da quale versione è partito (`_sha`, lo sha
|
|
189
|
+
* del blob che gli ha dato l'elenco); se quel file nel frattempo è cambiato, la sua copia è
|
|
190
|
+
* vecchia e sovrascriverla cancellerebbe il lavoro di qualcun altro.
|
|
191
|
+
*
|
|
192
|
+
* È il caso che ADMIN.md §3 dichiarava scoperto: due schede aperte sulla STESSA pagina,
|
|
193
|
+
* entrambe modificate, vince l'ultima che salva. Non lo copriva nessuna delle due difese —
|
|
194
|
+
* spedire solo ciò che si è toccato protegge le pagine che NON si sono aperte, non quella che
|
|
195
|
+
* si sta scrivendo.
|
|
196
|
+
*
|
|
197
|
+
* Si guarda solo lo slug di partenza (`_orig` per un rename): una pagina nuova non ha una
|
|
198
|
+
* versione da confrontare, e `_sha` assente significa "non controllare", così un client che
|
|
199
|
+
* non lo manda continua a funzionare. */
|
|
200
|
+
const stale = [];
|
|
201
|
+
for (const p of pages) {
|
|
202
|
+
if (!p._sha) continue;
|
|
203
|
+
const sourceSlug = existingSlugs.has(p.slug) ? p.slug : (p._orig || p.slug);
|
|
204
|
+
const attuale = shaOf.get(sourceSlug);
|
|
205
|
+
if (attuale && attuale !== p._sha) stale.push(sourceSlug);
|
|
206
|
+
}
|
|
207
|
+
if (stale.length) {
|
|
208
|
+
// Tutto o niente come gli altri rifiuti: il commit è atomico, e salvarne metà lascerebbe a
|
|
209
|
+
// terra l'altra metà senza che nessuno sappia quale.
|
|
210
|
+
return {
|
|
211
|
+
status: 409,
|
|
212
|
+
body: {
|
|
213
|
+
ok: false,
|
|
214
|
+
code: "stale",
|
|
215
|
+
error: stale.length === 1
|
|
216
|
+
? `La pagina "${stale[0]}" è stata modificata altrove dopo che l'hai aperta. Non ho ` +
|
|
217
|
+
`salvato nulla: ricarica per vedere la versione aggiornata.`
|
|
218
|
+
: `Queste pagine sono state modificate altrove dopo che le hai aperte: ` +
|
|
219
|
+
`${stale.join(", ")}. Non ho salvato nulla: ricarica per vedere le versioni aggiornate.`,
|
|
220
|
+
slugs: stale,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 3) entry del nuovo tree
|
|
226
|
+
const entries = [];
|
|
227
|
+
const desiredSlugs = new Set();
|
|
228
|
+
const rejected = [];
|
|
229
|
+
|
|
230
|
+
for (const p of pages) {
|
|
231
|
+
desiredSlugs.add(p.slug);
|
|
232
|
+
const sourceSlug = existingSlugs.has(p.slug)
|
|
233
|
+
? p.slug
|
|
234
|
+
: (p._orig && existingSlugs.has(p._orig) ? p._orig : null);
|
|
235
|
+
const oldMd = sourceSlug
|
|
236
|
+
? await readFile(env, repo, branch, `content/${sourceSlug}/index.md`)
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
// GUARDIA DI FEDELTÀ: se non sappiamo riprodurre il file che stiamo per sovrascrivere,
|
|
240
|
+
// non lo sovrascriviamo. Intercetta i costrutti che il lettore del front matter non regge
|
|
241
|
+
// e trasforma una corruzione silenziosa in un rifiuto visibile.
|
|
242
|
+
if (oldMd) {
|
|
243
|
+
const f = checkFidelity(oldMd, site().page, { slug: sourceSlug });
|
|
244
|
+
if (!f.ok) { rejected.push(sourceSlug); continue; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
entries.push({
|
|
248
|
+
path: `content/${p.slug}/index.md`,
|
|
249
|
+
mode: "100644",
|
|
250
|
+
type: "blob",
|
|
251
|
+
content: serializeItem(p, {
|
|
252
|
+
shape: site().page,
|
|
253
|
+
oldMd,
|
|
254
|
+
// Solo per i tipi che un corpo ce l'hanno: su una gallery il segnaposto sarebbe
|
|
255
|
+
// testo scritto nel repo che non compare da nessuna parte (vedi hasBody).
|
|
256
|
+
placeholderBody: site().hasBody(p.type)
|
|
257
|
+
? (m) => `Placeholder content for **${m.title || p.slug}**.`
|
|
258
|
+
: undefined,
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (rejected.length) {
|
|
264
|
+
return {
|
|
265
|
+
status: 409,
|
|
266
|
+
body: {
|
|
267
|
+
ok: false,
|
|
268
|
+
code: "unreadable",
|
|
269
|
+
error:
|
|
270
|
+
`Non riesco a riprodurre fedelmente ${rejected.join(", ")}: il file contiene qualcosa ` +
|
|
271
|
+
`che il lettore del front matter non gestisce. Per non rischiare di corromperlo non ho ` +
|
|
272
|
+
`salvato NULLA. Vanno sistemati a mano.`,
|
|
273
|
+
slugs: rejected,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 4) cancellazioni. Un file che non è nell'elenco desiderato viene rimosso — ma solo dopo
|
|
279
|
+
// aver controllato che sia davvero una pagina dell'admin: un sito può dichiarare tipi
|
|
280
|
+
// ESCLUSI (banchi di prova, pagine tecniche) che il manifest non mostra, e cancellarli
|
|
281
|
+
// sarebbe un danno silenzioso. In pratica qui non entra quasi mai nessuno, quindi la
|
|
282
|
+
// lettura non costa.
|
|
283
|
+
const skipped = [];
|
|
284
|
+
for (const slug of existingSlugs) {
|
|
285
|
+
if (desiredSlugs.has(slug)) continue;
|
|
286
|
+
const md = await readFile(env, repo, branch, `content/${slug}/index.md`);
|
|
287
|
+
if (md && site().excludedTypes.includes(typeOf(md))) { skipped.push(slug); continue; }
|
|
288
|
+
entries.push({ path: `content/${slug}/index.md`, mode: "100644", type: "blob", sha: null });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 5) tree, commit, ref
|
|
292
|
+
const newTreeRes = await gh(env, `/repos/${repo}/git/trees`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
body: JSON.stringify({ base_tree: baseTreeSha, tree: entries }),
|
|
295
|
+
});
|
|
296
|
+
if (!newTreeRes.ok) throw new Error(`tree ${newTreeRes.status}: ${await newTreeRes.text()}`);
|
|
297
|
+
const newTreeSha = (await newTreeRes.json()).sha;
|
|
298
|
+
|
|
299
|
+
const newCommitRes = await gh(env, `/repos/${repo}/git/commits`, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
body: JSON.stringify({
|
|
302
|
+
message: `admin: aggiorna pagine (${editor})`,
|
|
303
|
+
tree: newTreeSha,
|
|
304
|
+
parents: [baseCommitSha],
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
if (!newCommitRes.ok) throw new Error(`commit ${newCommitRes.status}: ${await newCommitRes.text()}`);
|
|
308
|
+
const newCommitSha = (await newCommitRes.json()).sha;
|
|
309
|
+
|
|
310
|
+
const patchRes = await gh(env, `/repos/${repo}/git/refs/heads/${branch}`, {
|
|
311
|
+
method: "PATCH",
|
|
312
|
+
body: JSON.stringify({ sha: newCommitSha }),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
if (!patchRes.ok) {
|
|
316
|
+
// `PATCH /git/refs` senza `force` è fast-forward-only: un 422 qui significa che qualcuno ha
|
|
317
|
+
// committato mentre stavamo lavorando (un altro salvataggio, o uno spostamento di media,
|
|
318
|
+
// che passa dallo stesso meccanismo). Prima usciva come un `ref update 422:` illeggibile.
|
|
319
|
+
// Un tentativo da base fresca, poi si dice all'utente cosa è successo davvero.
|
|
320
|
+
if (patchRes.status === 422 && attempt === 0) {
|
|
321
|
+
return commitPages(args, 1);
|
|
322
|
+
}
|
|
323
|
+
if (patchRes.status === 422) {
|
|
324
|
+
return {
|
|
325
|
+
status: 409,
|
|
326
|
+
body: {
|
|
327
|
+
ok: false,
|
|
328
|
+
code: "conflict",
|
|
329
|
+
error: "Il sito è stato modificato altrove mentre salvavi. Ricarica la pagina e riprova.",
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
throw new Error(`ref update ${patchRes.status}: ${await patchRes.text()}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* Le VERSIONI nuove, da rimandare al client. Dopo questo commit ogni file toccato ha uno `sha`
|
|
337
|
+
* diverso: senza rispedirli, il salvataggio successivo partirebbe da versioni che non esistono
|
|
338
|
+
* più e il controllo qui sopra lo rifiuterebbe — cioè il pannello si bloccherebbe da solo dopo
|
|
339
|
+
* il primo salvataggio. Costa una chiamata, e l'albero serve solo a questo. */
|
|
340
|
+
const shas = {};
|
|
341
|
+
try {
|
|
342
|
+
const t = await gh(env, `/repos/${repo}/git/trees/${newTreeSha}?recursive=1`);
|
|
343
|
+
if (t.ok) {
|
|
344
|
+
for (const e of (await t.json()).tree || []) {
|
|
345
|
+
if (e.type === "blob" && site().isManagedPath(e.path)) shas[e.path.split("/")[1]] = e.sha;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch { /* il salvataggio è andato: senza gli sha il client ricarica, non perde niente */ }
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
status: 200,
|
|
352
|
+
body: { ok: true, pushed: true, commit: newCommitSha, files: entries.length, skipped, shas },
|
|
353
|
+
};
|
|
354
|
+
}
|