@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.
Files changed (181) hide show
  1. package/dist/api.d.ts +195 -0
  2. package/dist/api.js +118 -0
  3. package/dist/app/App.d.ts +3 -0
  4. package/dist/app/App.js +320 -0
  5. package/dist/app/app.css +2068 -0
  6. package/dist/app/index.d.ts +18 -0
  7. package/dist/app/index.js +33 -0
  8. package/dist/app/lib/bookings.d.ts +132 -0
  9. package/dist/app/lib/bookings.js +79 -0
  10. package/dist/app/lib/deliveries.d.ts +95 -0
  11. package/dist/app/lib/deliveries.js +104 -0
  12. package/dist/app/lib/delivery-drop.d.ts +1 -0
  13. package/dist/app/lib/delivery-drop.js +72 -0
  14. package/dist/app/lib/delivery-upload.d.ts +78 -0
  15. package/dist/app/lib/delivery-upload.js +585 -0
  16. package/dist/app/lib/groups.d.ts +9 -0
  17. package/dist/app/lib/groups.js +32 -0
  18. package/dist/app/lib/home.d.ts +11 -0
  19. package/dist/app/lib/home.js +67 -0
  20. package/dist/app/lib/overview.d.ts +82 -0
  21. package/dist/app/lib/overview.js +68 -0
  22. package/dist/app/lib/pages.d.ts +24 -0
  23. package/dist/app/lib/pages.js +156 -0
  24. package/dist/app/lib/publish.d.ts +18 -0
  25. package/dist/app/lib/publish.js +63 -0
  26. package/dist/app/lib/reorder.d.ts +3 -0
  27. package/dist/app/lib/reorder.js +31 -0
  28. package/dist/app/lib/requests.d.ts +10 -0
  29. package/dist/app/lib/requests.js +67 -0
  30. package/dist/app/lib/router.d.ts +1 -0
  31. package/dist/app/lib/router.js +14 -0
  32. package/dist/app/lib/settings.d.ts +29 -0
  33. package/dist/app/lib/settings.js +82 -0
  34. package/dist/app/lib/slug.d.ts +3 -0
  35. package/dist/app/lib/slug.js +23 -0
  36. package/dist/app/lib/text.d.ts +1 -0
  37. package/dist/app/lib/text.js +13 -0
  38. package/dist/app/lib/theme.d.ts +7 -0
  39. package/dist/app/lib/theme.js +43 -0
  40. package/dist/app/lib/upload-queue.d.ts +35 -0
  41. package/dist/app/lib/upload-queue.js +167 -0
  42. package/dist/app/routes/AboutEditor.d.ts +15 -0
  43. package/dist/app/routes/AboutEditor.js +122 -0
  44. package/dist/app/routes/Bookings.d.ts +21 -0
  45. package/dist/app/routes/Bookings.js +134 -0
  46. package/dist/app/routes/Deliveries.d.ts +37 -0
  47. package/dist/app/routes/Deliveries.js +563 -0
  48. package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
  49. package/dist/app/routes/GallerySectionsEditor.js +107 -0
  50. package/dist/app/routes/HomeEditor.d.ts +10 -0
  51. package/dist/app/routes/HomeEditor.js +188 -0
  52. package/dist/app/routes/Overview.d.ts +17 -0
  53. package/dist/app/routes/Overview.js +318 -0
  54. package/dist/app/routes/PageActions.d.ts +12 -0
  55. package/dist/app/routes/PageActions.js +93 -0
  56. package/dist/app/routes/PageEditor.d.ts +23 -0
  57. package/dist/app/routes/PageEditor.js +233 -0
  58. package/dist/app/routes/PageSeoEditor.d.ts +13 -0
  59. package/dist/app/routes/PageSeoEditor.js +102 -0
  60. package/dist/app/routes/PagesList.d.ts +10 -0
  61. package/dist/app/routes/PagesList.js +76 -0
  62. package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
  63. package/dist/app/routes/PortfolioWorksEditor.js +139 -0
  64. package/dist/app/routes/Requests.d.ts +13 -0
  65. package/dist/app/routes/Requests.js +30 -0
  66. package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
  67. package/dist/app/routes/SiteSettingsEditor.js +66 -0
  68. package/dist/app/routes/UploadDock.d.ts +1 -0
  69. package/dist/app/routes/UploadDock.js +66 -0
  70. package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
  71. package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
  72. package/dist/app/site.d.ts +37 -0
  73. package/dist/app/site.js +18 -0
  74. package/dist/app/ui/MediaSlot.d.ts +10 -0
  75. package/dist/app/ui/MediaSlot.js +20 -0
  76. package/dist/app/ui/OpenCard.d.ts +18 -0
  77. package/dist/app/ui/OpenCard.js +20 -0
  78. package/dist/app/ui/ParagraphEditor.d.ts +8 -0
  79. package/dist/app/ui/ParagraphEditor.js +37 -0
  80. package/dist/app/ui/dnd.d.ts +1 -0
  81. package/dist/app/ui/dnd.js +11 -0
  82. package/dist/app/ui/icons.d.ts +26 -0
  83. package/dist/app/ui/icons.js +45 -0
  84. package/dist/config.d.ts +5 -0
  85. package/dist/config.js +26 -0
  86. package/dist/form/MediaBrowser.d.ts +13 -0
  87. package/dist/form/MediaBrowser.js +24 -0
  88. package/dist/form/MediaPicker.d.ts +10 -0
  89. package/dist/form/MediaPicker.js +20 -0
  90. package/dist/form/SchemaForm.d.ts +21 -0
  91. package/dist/form/SchemaForm.js +67 -0
  92. package/dist/form/Trash.d.ts +1 -0
  93. package/dist/form/Trash.js +42 -0
  94. package/dist/form/fields/index.d.ts +1 -0
  95. package/dist/form/fields/index.js +110 -0
  96. package/dist/form/fields/objectList.d.ts +1 -0
  97. package/dist/form/fields/objectList.js +99 -0
  98. package/dist/form/index.d.ts +8 -0
  99. package/dist/form/index.js +8 -0
  100. package/dist/form/media/Lightbox.d.ts +4 -0
  101. package/dist/form/media/Lightbox.js +70 -0
  102. package/dist/form/media/MediaFolders.d.ts +20 -0
  103. package/dist/form/media/MediaFolders.js +116 -0
  104. package/dist/form/media/MediaGrid.d.ts +8 -0
  105. package/dist/form/media/MediaGrid.js +139 -0
  106. package/dist/form/media/UploadDialog.d.ts +4 -0
  107. package/dist/form/media/UploadDialog.js +37 -0
  108. package/dist/form/media/useMediaLibrary.d.ts +55 -0
  109. package/dist/form/media/useMediaLibrary.js +495 -0
  110. package/dist/form/prose/ProseBar.d.ts +21 -0
  111. package/dist/form/prose/ProseBar.js +111 -0
  112. package/dist/form/prose/ProseField.d.ts +11 -0
  113. package/dist/form/prose/ProseField.js +152 -0
  114. package/dist/form/prose/pages.d.ts +6 -0
  115. package/dist/form/prose/pages.js +13 -0
  116. package/dist/form/registry.d.ts +20 -0
  117. package/dist/form/registry.js +15 -0
  118. package/dist/frontmatter.d.ts +20 -0
  119. package/dist/frontmatter.js +202 -0
  120. package/dist/functions/_middleware.js +101 -0
  121. package/dist/functions/_shared/analytics-ga4.js +248 -0
  122. package/dist/functions/_shared/analytics-umami.js +119 -0
  123. package/dist/functions/_shared/assets.js +90 -0
  124. package/dist/functions/_shared/b2.js +300 -0
  125. package/dist/functions/_shared/delivery-crypto.js +99 -0
  126. package/dist/functions/_shared/delivery-purge.js +222 -0
  127. package/dist/functions/_shared/edge-cache.js +37 -0
  128. package/dist/functions/_shared/exif.js +53 -0
  129. package/dist/functions/_shared/github.js +138 -0
  130. package/dist/functions/_shared/imagesize.js +101 -0
  131. package/dist/functions/_shared/media-content.js +200 -0
  132. package/dist/functions/_shared/pending-media.js +98 -0
  133. package/dist/functions/_shared/refs.js +155 -0
  134. package/dist/functions/api/analytics.js +106 -0
  135. package/dist/functions/api/assets/delete.js +241 -0
  136. package/dist/functions/api/assets/folder.js +43 -0
  137. package/dist/functions/api/assets/folders.js +32 -0
  138. package/dist/functions/api/assets/move.js +253 -0
  139. package/dist/functions/api/assets/preview.js +17 -0
  140. package/dist/functions/api/assets/trash/delete.js +13 -0
  141. package/dist/functions/api/assets/trash/empty.js +16 -0
  142. package/dist/functions/api/assets/trash/restore.js +43 -0
  143. package/dist/functions/api/assets/trash.js +28 -0
  144. package/dist/functions/api/assets/upload.js +54 -0
  145. package/dist/functions/api/assets/usage.js +41 -0
  146. package/dist/functions/api/assets.js +94 -0
  147. package/dist/functions/api/deliveries.js +629 -0
  148. package/dist/functions/api/delivery/hero.js +21 -0
  149. package/dist/functions/api/delivery/image.js +35 -0
  150. package/dist/functions/api/delivery/multipart.js +261 -0
  151. package/dist/functions/api/pages/get.js +26 -0
  152. package/dist/functions/api/pages.js +354 -0
  153. package/dist/functions/api/publish.js +134 -0
  154. package/dist/functions/api/requests.js +57 -0
  155. package/dist/functions/api/settings.js +236 -0
  156. package/dist/functions/api/singleton.js +105 -0
  157. package/dist/functions/api/storage.js +125 -0
  158. package/dist/functions/index.js +124 -0
  159. package/dist/functions/site.js +38 -0
  160. package/dist/gallery.d.ts +28 -0
  161. package/dist/gallery.js +18 -0
  162. package/dist/index.d.ts +10 -0
  163. package/dist/index.js +10 -0
  164. package/dist/model.d.ts +2 -0
  165. package/dist/model.js +47 -0
  166. package/dist/normalize.d.ts +50 -0
  167. package/dist/normalize.js +120 -0
  168. package/dist/preview.d.ts +3 -0
  169. package/dist/preview.js +82 -0
  170. package/dist/prose.d.ts +17 -0
  171. package/dist/prose.js +205 -0
  172. package/dist/registries.d.ts +43 -0
  173. package/dist/registries.js +29 -0
  174. package/dist/serialize.d.ts +26 -0
  175. package/dist/serialize.js +136 -0
  176. package/dist/testing.d.ts +26 -0
  177. package/dist/testing.js +66 -0
  178. package/dist/theme.css +636 -0
  179. package/dist/yaml.d.ts +8 -0
  180. package/dist/yaml.js +90 -0
  181. 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
+ }