@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,134 @@
1
+ /* GET·POST /admin/api/publish — mandare online ciò che è stato salvato.
2
+ *
3
+ * Salvare e pubblicare erano lo stesso gesto: ogni «Salva e pubblica» era un commit su `main`,
4
+ * cioè una build di Cloudflare e il sito che cambia. Tre conseguenze, e nessuna era voluta:
5
+ * il cliente non poteva mettere il lavoro al sicuro senza mandarlo online, ogni salvataggio
6
+ * costava una build (ed è la build il contatore che ha un tetto), e non esisteva un momento in
7
+ * cui guardare l'insieme prima di pubblicarlo.
8
+ *
9
+ * Ora l'admin scrive sul branch di bozza — nessuna build, il sito fermo — e questo endpoint fa
10
+ * avanzare il branch pubblicato fino a lì. È l'unico posto in cui `main` viene toccato.
11
+ *
12
+ * Dietro Cloudflare Access come tutto ciò che sta sotto /admin/.
13
+ */
14
+ import { gh, repoBranch, prodBranch, GitHubConflictError } from "../_shared/github.js";
15
+ import { finalizePendingMedia } from "../_shared/pending-media.js";
16
+
17
+ function json(data, status = 200) {
18
+ return new Response(JSON.stringify(data), {
19
+ status, headers: { "Content-Type": "application/json" },
20
+ });
21
+ }
22
+
23
+ async function headSha(env, repo, branch) {
24
+ const r = await gh(env, `/repos/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
25
+ if (r.status === 404) return null;
26
+ if (!r.ok) throw new Error(`ref ${branch} ${r.status}: ${await r.text()}`);
27
+ return (await r.json()).object.sha;
28
+ }
29
+
30
+ /* GET — quanto c'è di non pubblicato.
31
+ *
32
+ * `compare` in UNA chiamata dà il conteggio e i messaggi: `ahead_by` è quanto la bozza è avanti,
33
+ * `behind_by` quanto è indietro (cioè: qualcuno ha committato su `main` a mano, e allora un
34
+ * fast-forward non è possibile).
35
+ *
36
+ * I messaggi servono a rispondere alla domanda che uno si fa davvero prima di pubblicare —
37
+ * «cosa sto mandando online?» — senza dover aprire GitHub.
38
+ */
39
+ export async function onRequestGet({ env }) {
40
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
41
+ return json({ ok: false, error: "Backend non configurato." }, 500);
42
+ }
43
+ const { repo, branch } = repoBranch(env);
44
+ const prod = prodBranch(env);
45
+
46
+ try {
47
+ /* Il branch di bozza può non esistere ancora: è un passo manuale della configurazione
48
+ * (vedi CONFIGURAZIONE.md). Meglio dirlo che rispondere «niente da pubblicare», che sarebbe
49
+ * vero e insieme profondamente fuorviante. */
50
+ if ((await headSha(env, repo, branch)) === null) {
51
+ return json({
52
+ ok: true, configured: false, ahead: 0, behind: 0, commits: [],
53
+ error: `Il branch "${branch}" non esiste ancora: va creato da "${prod}".`,
54
+ });
55
+ }
56
+
57
+ const r = await gh(env, `/repos/${repo}/compare/${encodeURIComponent(prod)}...${encodeURIComponent(branch)}`);
58
+ if (!r.ok) throw new Error(`compare ${r.status}: ${await r.text()}`);
59
+ const d = await r.json();
60
+
61
+ return json({
62
+ ok: true,
63
+ configured: true,
64
+ ahead: d.ahead_by ?? 0,
65
+ behind: d.behind_by ?? 0,
66
+ commits: (d.commits || []).slice(-20).reverse().map((c) => ({
67
+ message: String(c.commit?.message || "").split("\n")[0],
68
+ date: c.commit?.author?.date || null,
69
+ })),
70
+ });
71
+ } catch (err) {
72
+ return json({ ok: false, error: String((err && err.message) || err) }, 502);
73
+ }
74
+ }
75
+
76
+ /* POST — pubblica.
77
+ *
78
+ * `PATCH /git/refs` senza `force` è fast-forward-only, ed è esattamente la garanzia che serve:
79
+ * pubblicare non può riscrivere la storia di `main` né cancellare un commit fatto altrove. Se
80
+ * non è possibile, il 422 diventa un messaggio che dice cosa è successo davvero.
81
+ */
82
+ export async function onRequestPost({ env, data }) {
83
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
84
+ return json({ ok: false, error: "Backend non configurato." }, 500);
85
+ }
86
+ const { repo, branch } = repoBranch(env);
87
+ const prod = prodBranch(env);
88
+ const editor = data?.cloudflareAccess?.JWT?.payload?.email || "admin";
89
+
90
+ try {
91
+ const sha = await headSha(env, repo, branch);
92
+ if (!sha) return json({ ok: false, error: `Il branch "${branch}" non esiste.` }, 400);
93
+
94
+ const prodSha = await headSha(env, repo, prod);
95
+ if (prodSha === sha) {
96
+ const media = await finalizePendingMedia(env).catch((error) => ({
97
+ finalized: 0,
98
+ pending: null,
99
+ warning: error?.message || String(error),
100
+ }));
101
+ // Non è un errore: è che non c'era niente da fare. Un 200 che lo dice evita di far
102
+ // partire una build per nulla, e di mostrare un rosso a chi ha solo premuto due volte.
103
+ return json({ ok: true, published: false, message: "Non c'è niente di nuovo da pubblicare.", media });
104
+ }
105
+
106
+ const r = await gh(env, `/repos/${repo}/git/refs/heads/${encodeURIComponent(prod)}`, {
107
+ method: "PATCH",
108
+ body: JSON.stringify({ sha }),
109
+ });
110
+
111
+ if (r.status === 422) {
112
+ /* `main` è andato avanti per conto suo — un commit fatto a mano, o da un'altra sessione.
113
+ * Non si forza: sovrascriverlo cancellerebbe quel lavoro. Va riportato nella bozza, e
114
+ * quello è un gesto da riga di comando, non da pannello. */
115
+ throw new GitHubConflictError(
116
+ `Non posso pubblicare: "${prod}" contiene modifiche che non sono nella bozza. ` +
117
+ `Vanno prima riportate nella bozza (merge), altrimenti pubblicando andrebbero perse.`
118
+ );
119
+ }
120
+ if (!r.ok) throw new Error(`ref ${r.status}: ${await r.text()}`);
121
+
122
+ const media = await finalizePendingMedia(env).catch((error) => ({
123
+ finalized: 0,
124
+ pending: null,
125
+ warning: error?.message || String(error),
126
+ }));
127
+ return json({ ok: true, published: true, commit: sha, by: editor, media });
128
+ } catch (err) {
129
+ if (err instanceof GitHubConflictError) {
130
+ return json({ ok: false, code: "diverged", error: err.message }, 409);
131
+ }
132
+ return json({ ok: false, error: String((err && err.message) || err) }, 502);
133
+ }
134
+ }
@@ -0,0 +1,57 @@
1
+ /* /admin/api/requests — le richieste arrivate dal form della home.
2
+ *
3
+ * Sta sotto /admin/, quindi è già dietro Cloudflare Access E dietro la verifica del JWT in
4
+ * functions/admin/_middleware.js: qui non serve nessun controllo d'identità, e aggiungerne uno
5
+ * darebbe la falsa impressione che serva.
6
+ *
7
+ * GET ?limit=200 elenco, dalla più recente
8
+ * PATCH { id, handled } segna gestita / da gestire
9
+ * DELETE { id } elimina
10
+ *
11
+ * Non c'è cestino, a differenza dei media: una richiesta è un testo di poche righe, e chi la
12
+ * elimina lo sta facendo dopo averla letta. Il cestino dei media esiste perché lì si cancellano
13
+ * file per sbaglio trascinandoli.
14
+ */
15
+ const MAX = 500;
16
+
17
+ export async function onRequestGet({ request, env }) {
18
+ if (!env.DB) return json({ ok: false, error: "Archivio non configurato" }, 500);
19
+ const url = new URL(request.url);
20
+ const limit = Math.min(MAX, Math.max(1, Number(url.searchParams.get("limit")) || 200));
21
+
22
+ const { results } = await env.DB
23
+ .prepare("SELECT id, name, email, message, created_at, handled_at FROM requests ORDER BY created_at DESC LIMIT ?")
24
+ .bind(limit)
25
+ .all();
26
+
27
+ return json({ ok: true, requests: results ?? [] });
28
+ }
29
+
30
+ export async function onRequestPatch({ request, env }) {
31
+ if (!env.DB) return json({ ok: false, error: "Archivio non configurato" }, 500);
32
+ const { id, handled } = await request.json().catch(() => ({}));
33
+ if (!Number.isInteger(id)) return json({ ok: false, error: "id mancante" }, 400);
34
+
35
+ await env.DB
36
+ .prepare("UPDATE requests SET handled_at = ? WHERE id = ?")
37
+ .bind(handled ? new Date().toISOString() : null, id)
38
+ .run();
39
+
40
+ return json({ ok: true });
41
+ }
42
+
43
+ export async function onRequestDelete({ request, env }) {
44
+ if (!env.DB) return json({ ok: false, error: "Archivio non configurato" }, 500);
45
+ const { id } = await request.json().catch(() => ({}));
46
+ if (!Number.isInteger(id)) return json({ ok: false, error: "id mancante" }, 400);
47
+
48
+ await env.DB.prepare("DELETE FROM requests WHERE id = ?").bind(id).run();
49
+ return json({ ok: true });
50
+ }
51
+
52
+ function json(data, status = 200) {
53
+ return new Response(JSON.stringify(data), {
54
+ status,
55
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
56
+ });
57
+ }
@@ -0,0 +1,236 @@
1
+ /* GET/POST /admin/api/settings — impostazioni globali del sito.
2
+ *
3
+ * I valori pubblici vivono in data/site.json e vengono letti da Hugo durante la build.
4
+ * Nello stesso file teniamo la sola verifica crittografica della password: mai la password
5
+ * originale. Hugo non emette automaticamente i file di data/ e i template non serializzano
6
+ * il blocco `access`, quindi l'hash non finisce nel sito statico.
7
+ *
8
+ * Il POST è un unico commit: interfaccia, metadati e protezione del sito entrano in vigore con
9
+ * la stessa build, senza una finestra in cui il pannello dica una cosa e il sito ne faccia
10
+ * un'altra.
11
+ */
12
+ import { json } from "../_shared/assets.js";
13
+ import { site } from "../site.js";
14
+ import {
15
+ getText, commitUpserts, getRepoSnapshot, gitBlobSha, GitHubConflictError,
16
+ } from "../_shared/github.js";
17
+
18
+ const PATH = "data/site.json";
19
+ const enc = new TextEncoder();
20
+
21
+ const defaults = {
22
+ siteTitle: "", // il ripiego vero lo dà il sito: vedi `site().name` in publicSettings
23
+ shortDescription: "",
24
+ ownerName: "",
25
+ siteDescription: "",
26
+ favicon: "",
27
+ seoWebsiteTitle: "",
28
+ searchEngineIndexing: true,
29
+ blockAiTraining: false,
30
+ analyticsTag: "",
31
+ /* Le funzioni aggiuntive: attive o no. NON si scrivono dal pannello — `stored` le ricopia
32
+ * sempre da `previous`, esattamente come `booking`. È deliberato: sono funzioni che il
33
+ * cliente attiva mettendosi d'accordo con chi gli ha fatto il sito, non premendo un
34
+ * interruttore. Il pannello le mostra e basta, con l'interruttore disattivato.
35
+ *
36
+ * `booking` viene da prima e resta dov'è: è già l'interruttore degli appuntamenti e lo
37
+ * leggono cinque template Hugo. Duplicarlo in un secondo campo vorrebbe dire due verità
38
+ * sulla stessa cosa, cioè una delle due prima o poi sbagliata. */
39
+ analytics: { enabled: false },
40
+ delivery: { enabled: false },
41
+ booking: { enabled: false },
42
+ access: { enabled: false, salt: "", hash: "" },
43
+ };
44
+
45
+ function cleanText(value, max) {
46
+ return typeof value === "string" ? value.trim().slice(0, max) : "";
47
+ }
48
+
49
+ function publicSettings(value) {
50
+ const v = value && typeof value === "object" ? value : {};
51
+ return {
52
+ siteTitle: cleanText(v.siteTitle, 140) || site().name,
53
+ shortDescription: cleanText(v.shortDescription, 255),
54
+ ownerName: cleanText(v.ownerName, 120),
55
+ siteDescription: cleanText(v.siteDescription, 1200),
56
+ favicon: cleanText(v.favicon, 500),
57
+ seoWebsiteTitle: cleanText(v.seoWebsiteTitle, 140),
58
+ searchEngineIndexing: v.searchEngineIndexing !== false,
59
+ blockAiTraining: v.blockAiTraining === true,
60
+ /* Codice ARBITRARIO che finisce in ogni pagina del sito. Non si prova a ripulirlo: uno
61
+ * snippet di analytics È uno `<script>`, quindi qualunque filtro o lo rompe o non
62
+ * protegge da niente. Quello che lo rende accettabile è chi può scriverlo — solo da
63
+ * dietro Cloudflare Access, cioè chi potrebbe comunque modificare i template. Il tetto
64
+ * di caratteri non è sicurezza: impedisce che un incolla sbagliato gonfi ogni pagina. */
65
+ analyticsTag: cleanText(v.analyticsTag, 4000),
66
+ /* Una forma SOLA per il pannello, anche se sul disco i due interruttori stanno in posti
67
+ * diversi (`analytics.enabled` è nuovo, `booking.enabled` c'era già e lo leggono i
68
+ * template). L'interfaccia non deve conoscere quella differenza: le due voci della
69
+ * sezione «Funzioni aggiuntive» si disegnano nello stesso modo. */
70
+ features: {
71
+ analytics: v.analytics?.enabled === true,
72
+ delivery: v.delivery?.enabled === true,
73
+ booking: v.booking?.enabled === true,
74
+ },
75
+ passwordEnabled: v.access?.enabled === true && !!v.access?.salt && !!v.access?.hash,
76
+ };
77
+ }
78
+
79
+ function fromHex(hex) {
80
+ if (!/^[a-f0-9]+$/i.test(hex) || hex.length % 2) return new Uint8Array();
81
+ return Uint8Array.from(hex.match(/../g) || [], (byte) => parseInt(byte, 16));
82
+ }
83
+
84
+ function toHex(bytes) {
85
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
86
+ }
87
+
88
+ /* 100.000 e non di più, e NON è una scelta di sicurezza: è il tetto della piattaforma. Il
89
+ * runtime dei Workers risponde `NotSupportedError: Pbkdf2 failed: iteration counts above 100000
90
+ * are not supported` (la richiesta di alzarlo è cloudflare/workerd#1346, aperta da ottobre 2023
91
+ * e ancora aperta). OWASP ne chiederebbe 600.000 per SHA-256: non si possono avere.
92
+ *
93
+ * ⚠️ Una delle due copie da cui nasce questo pacchetto era passata a 210.000 — un valore che in
94
+ * produzione solleva l'eccezione al primo utilizzo. Non se n'era accorto nessuno perché su quel
95
+ * sito la protezione con password non è mai stata accesa. E non lo mostra nemmeno la prova
96
+ * locale: `wrangler pages dev` accetta 210.000 e persino 600.000 senza fiatare — il tetto esiste
97
+ * solo nel runtime distribuito. Un limite che non si riproduce in sviluppo è il tipo peggiore,
98
+ * quindi resta scritto qui.
99
+ *
100
+ * ⚠️ Deve restare IDENTICO al conteggio con cui il sito verifica la password (il middleware di
101
+ * radice del sito, fuori da questo pacchetto): cambiarlo qui senza cambiarlo là rompe l'accesso
102
+ * senza nessun errore visibile. */
103
+ const PBKDF2_ITERATIONS = 100_000;
104
+
105
+ async function passwordRecord(password) {
106
+ const salt = crypto.getRandomValues(new Uint8Array(16));
107
+ const key = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]);
108
+ const hash = await crypto.subtle.deriveBits(
109
+ { name: "PBKDF2", hash: "SHA-256", salt, iterations: PBKDF2_ITERATIONS },
110
+ key,
111
+ 256,
112
+ );
113
+ return { enabled: true, salt: toHex(salt), hash: toHex(hash) };
114
+ }
115
+
116
+ async function readSettings(env, ref) {
117
+ const raw = await getText(env, PATH, ref);
118
+ if (raw == null) return { ...defaults, access: { ...defaults.access } };
119
+ try {
120
+ const parsed = JSON.parse(raw);
121
+ return {
122
+ ...defaults,
123
+ ...parsed,
124
+ access: { ...defaults.access, ...(parsed.access || {}) },
125
+ analytics: { ...defaults.analytics, ...(parsed.analytics || {}) },
126
+ delivery: { ...defaults.delivery, ...(parsed.delivery || {}) },
127
+ booking: { ...defaults.booking, ...(parsed.booking || {}) },
128
+ };
129
+ } catch {
130
+ throw new Error(`${PATH} non contiene JSON valido`);
131
+ }
132
+ }
133
+
134
+ export async function onRequestGet({ env }) {
135
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
136
+ return json({ ok: false, error: "backend GitHub non configurato" }, 500);
137
+ }
138
+ try {
139
+ const snapshot = await getRepoSnapshot(env);
140
+ const file = snapshot.tree.find((entry) => entry.type === "blob" && entry.path === PATH);
141
+ if (!file) return json({ ok: false, error: `${PATH} non trovato` }, 404);
142
+ return json({
143
+ ok: true,
144
+ settings: publicSettings(await readSettings(env, snapshot.commitSha)),
145
+ sha: file.sha,
146
+ });
147
+ } catch (error) {
148
+ return json({ ok: false, error: error.message || String(error) }, 500);
149
+ }
150
+ }
151
+
152
+ export async function onRequestPost({ request, env, data: ctx }) {
153
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
154
+ return json({ ok: false, error: "backend GitHub non configurato" }, 500);
155
+ }
156
+
157
+ let body;
158
+ try { body = await request.json(); }
159
+ catch { return json({ ok: false, error: "JSON non valido" }, 400); }
160
+
161
+ try {
162
+ const snapshot = await getRepoSnapshot(env);
163
+ const file = snapshot.tree.find((entry) => entry.type === "blob" && entry.path === PATH);
164
+ if (!file || typeof body._sha !== "string" || body._sha !== file.sha) {
165
+ return json({
166
+ ok: false,
167
+ code: "stale",
168
+ error: "Le impostazioni sono state modificate altrove. Non ho salvato nulla: ricarica e riprova.",
169
+ }, 409);
170
+ }
171
+ const previous = await readSettings(env, snapshot.commitSha);
172
+ if (!cleanText(body.siteTitle, 140)) {
173
+ return json({ ok: false, code: "invalid", error: "Il titolo del sito è obbligatorio." }, 400);
174
+ }
175
+ const nextPublic = publicSettings(body);
176
+
177
+ let access = { ...previous.access };
178
+ if (body.passwordEnabled === false) {
179
+ access = { enabled: false, salt: "", hash: "" };
180
+ } else if (body.passwordEnabled === true) {
181
+ const password = typeof body.password === "string" ? body.password : "";
182
+ if (password) {
183
+ if (password.length < 8 || password.length > 200) {
184
+ return json({
185
+ ok: false, code: "invalid",
186
+ error: "La password deve contenere almeno 8 caratteri.",
187
+ }, 400);
188
+ }
189
+ access = await passwordRecord(password);
190
+ } else if (!(previous.access?.enabled && previous.access?.salt && previous.access?.hash)) {
191
+ return json({
192
+ ok: false, code: "invalid",
193
+ error: "Inserisci una password prima di attivare la protezione.",
194
+ }, 400);
195
+ } else {
196
+ access.enabled = true;
197
+ }
198
+ }
199
+
200
+ const stored = {
201
+ siteTitle: nextPublic.siteTitle,
202
+ shortDescription: nextPublic.shortDescription,
203
+ ownerName: nextPublic.ownerName,
204
+ siteDescription: nextPublic.siteDescription,
205
+ favicon: nextPublic.favicon,
206
+ seoWebsiteTitle: nextPublic.seoWebsiteTitle,
207
+ searchEngineIndexing: nextPublic.searchEngineIndexing,
208
+ blockAiTraining: nextPublic.blockAiTraining,
209
+ analyticsTag: nextPublic.analyticsTag,
210
+ /* Ricopiati da `previous`, MAI da `body`: un salvataggio delle impostazioni non può
211
+ * accendere una funzione a pagamento, nemmeno con una richiesta costruita a mano. */
212
+ analytics: { ...defaults.analytics, ...(previous.analytics || {}) },
213
+ delivery: { ...defaults.delivery, ...(previous.delivery || {}) },
214
+ booking: { ...defaults.booking, ...(previous.booking || {}) },
215
+ access,
216
+ };
217
+ const editor = ctx?.cloudflareAccess?.JWT?.payload?.email || "admin";
218
+ const content = JSON.stringify(stored, null, 2) + "\n";
219
+ const sha = await commitUpserts(env, `admin: aggiorna impostazioni sito (${editor})`, [
220
+ { path: PATH, content },
221
+ ], { base: snapshot });
222
+ if (!sha) return json({ ok: false, error: "commit non riuscito" }, 502);
223
+
224
+ return json({
225
+ ok: true, commit: sha, settings: publicSettings(stored), sha: await gitBlobSha(content),
226
+ });
227
+ } catch (error) {
228
+ if (error instanceof GitHubConflictError) {
229
+ return json({
230
+ ok: false, code: "conflict",
231
+ error: "Le impostazioni sono cambiate mentre salvavi. Non ho sovrascritto nulla: ricarica e riprova.",
232
+ }, 409);
233
+ }
234
+ return json({ ok: false, error: error.message || String(error) }, 500);
235
+ }
236
+ }
@@ -0,0 +1,105 @@
1
+ /* GET/POST /admin/api/singleton?name=home — i contenuti della home.
2
+ *
3
+ * Endpoint SEPARATO da /admin/api/pages, e non è pigrizia architetturale. Il contratto di
4
+ * quello è "ecco l'elenco completo delle pagine, cancella tutto il resto": un singleton non ha
5
+ * niente da fare vicino a una spazzata di cancellazione. Qui si usa `commitUpserts`, che sa
6
+ * solo scrivere — che è esattamente ciò che serve a una cosa che non si elimina mai.
7
+ *
8
+ * Vale la stessa invariante delle pagine: il salvataggio è un MERGE. I campi dichiarati nello
9
+ * schema vengono rigenerati, tutto il resto del front matter — commenti compresi — si ricopia,
10
+ * e prima di sovrascrivere si verifica di saper riprodurre il file (guardia di fedeltà).
11
+ */
12
+ import { json } from "../_shared/assets.js";
13
+ import {
14
+ getText, commitUpserts, getRepoSnapshot, gitBlobSha, GitHubConflictError,
15
+ } from "../_shared/github.js";
16
+ import { serializeItem, deserializeItem, checkFidelity } from "../../index.js";
17
+ import { site } from "../site.js";
18
+
19
+ // Un solo singleton per ora. È un elenco esplicito e non un percorso arbitrario preso dalla
20
+ // query: l'endpoint sta dietro Access, ma un parametro che diventa un path è comunque il modo
21
+ // in cui si finisce a leggere e scrivere file che non c'entrano.
22
+ const SINGLETONS = {
23
+ home: () => ({ path: site().homePath, shape: site().home, object: site().homeObject }),
24
+ };
25
+
26
+ export async function onRequestGet({ request, env }) {
27
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
28
+ return json({ ok: false, error: "backend GitHub non configurato" }, 500);
29
+ }
30
+ const make = SINGLETONS[new URL(request.url).searchParams.get("name") || ""];
31
+ const s = make?.();
32
+ if (!s) return json({ ok: false, error: "singleton sconosciuto" }, 404);
33
+
34
+ const snapshot = await getRepoSnapshot(env);
35
+ const file = snapshot.tree.find((entry) => entry.type === "blob" && entry.path === s.path);
36
+ if (!file) return json({ ok: false, error: "file non trovato" }, 404);
37
+ const text = await getText(env, s.path, snapshot.commitSha);
38
+ if (text == null) return json({ ok: false, error: "file non trovato" }, 404);
39
+
40
+ return json({ ok: true, data: deserializeItem(text, s.shape), sha: file.sha });
41
+ }
42
+
43
+ export async function onRequestPost({ request, env, data: ctx }) {
44
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
45
+ return json({ ok: false, error: "backend GitHub non configurato" }, 500);
46
+ }
47
+
48
+ let body;
49
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
50
+ const make = SINGLETONS[body.name || ""];
51
+ const s = make?.();
52
+ if (!s) return json({ ok: false, error: "singleton sconosciuto" }, 404);
53
+
54
+ const parsed = s.object.safeParse(body.data);
55
+ if (!parsed.success) {
56
+ return json({
57
+ ok: false, code: "invalid",
58
+ error: "Contenuti non validi: " +
59
+ parsed.error.issues.map((i) => `${i.path.join(".") || "?"} ${i.message}`).join("; "),
60
+ }, 400);
61
+ }
62
+
63
+ const snapshot = await getRepoSnapshot(env);
64
+ const file = snapshot.tree.find((entry) => entry.type === "blob" && entry.path === s.path);
65
+ if (!file || typeof body._sha !== "string" || body._sha !== file.sha) {
66
+ return json({
67
+ ok: false,
68
+ code: "stale",
69
+ error: "La home è stata modificata altrove. Non ho salvato nulla: ricarica e riprova.",
70
+ }, 409);
71
+ }
72
+ const oldMd = await getText(env, s.path, snapshot.commitSha);
73
+ if (oldMd == null) return json({ ok: false, error: "file non trovato" }, 404);
74
+
75
+ // Se non sappiamo riprodurre il file che stiamo per sovrascrivere, non lo sovrascriviamo.
76
+ const f = checkFidelity(oldMd, s.shape);
77
+ if (!f.ok) {
78
+ return json({
79
+ ok: false, code: "unreadable",
80
+ error: `Non riesco a riprodurre fedelmente ${s.path}: contiene qualcosa che il lettore del ` +
81
+ `front matter non gestisce. Per non rischiare di corromperlo non ho salvato nulla.`,
82
+ }, 409);
83
+ }
84
+
85
+ const editor = ctx?.cloudflareAccess?.JWT?.payload?.email || "admin";
86
+ // Si valida con Zod, ma si serializza l'oggetto originale: eventuali sotto-campi non ancora
87
+ // esposti dallo schema non vengono scartati dal parse di z.object.
88
+ const content = serializeItem(body.data, { shape: s.shape, oldMd });
89
+
90
+ try {
91
+ const sha = await commitUpserts(env, `admin: aggiorna la home (${editor})`, [
92
+ { path: s.path, content },
93
+ ], { base: snapshot });
94
+ if (!sha) return json({ ok: false, error: "commit non riuscito" }, 502);
95
+ return json({ ok: true, commit: sha, sha: await gitBlobSha(content) });
96
+ } catch (error) {
97
+ if (error instanceof GitHubConflictError) {
98
+ return json({
99
+ ok: false, code: "conflict",
100
+ error: "La home è cambiata mentre salvavi. Non ho sovrascritto nulla: ricarica e riprova.",
101
+ }, 409);
102
+ }
103
+ throw error;
104
+ }
105
+ }
@@ -0,0 +1,125 @@
1
+ /* GET /admin/api/storage — quanto spazio occupano le due librerie.
2
+ *
3
+ * Sta sotto /admin/, quindi Access e la verifica del JWT sono già passate dal middleware.
4
+ *
5
+ * Due sorgenti, e sono diverse per natura:
6
+ *
7
+ * R2 (i media del sito) si CONTA, elencando il bucket. Non esiste un'API che dica "quanto
8
+ * pesa questo bucket": il totale è la somma degli oggetti, mille per
9
+ * chiamata. Per questo la risposta passa dalla cache dell'edge.
10
+ * B2 (le consegne) si LEGGE da D1. Gli oggetti su B2 li abbiamo scritti noi e ne
11
+ * conosciamo la dimensione: interrogare il bucket vorrebbe dire
12
+ * firmare una ListObjectsV2 per pagina per sapere una cosa che è già
13
+ * scritta accanto al pacchetto che la usa. E soprattutto: quello che
14
+ * conta è lo spazio ATTRIBUITO a un transfer vivo, non i byte fisici,
15
+ * che restano indietro di qualche giorno per via del lifecycle
16
+ * (tombstone, versioni nascoste e multipart orfani).
17
+ *
18
+ * Da qui la parola scelta nel pannello: «in uso», non «fatturato».
19
+ */
20
+ import { cachedJson } from "../_shared/edge-cache.js";
21
+ import { moduleEnabled } from "../site.js";
22
+
23
+ /* Cinque minuti: caricare l'elenco di un bucket da qualche migliaio di foto non è gratis, e
24
+ * nessuno guarda questo numero due volte nello stesso minuto aspettandolo diverso. */
25
+ const TTL = 300;
26
+ /* Ventimila oggetti. Non è una stima dell'archivio di oggi (qualche centinaio): è il punto in
27
+ * cui smettere per non far scadere la Function. Oltre, il numero si dichiara PARZIALE nel
28
+ * pannello invece di mostrarne uno sbagliato con l'aria di essere giusto. */
29
+ const MAX_PAGES = 20;
30
+
31
+ export async function onRequestGet({ request, env, waitUntil }) {
32
+ const [r2, b2] = await Promise.all([
33
+ cachedJson(request, waitUntil, "r2-usage", TTL, () => r2Usage(env))
34
+ .then((out) => ({ ...out.data, cached: out.cached }))
35
+ .catch((e) => ({ ok: false, error: message(e) })),
36
+ moduleEnabled("delivery")
37
+ ? b2Usage(env).catch((e) => ({ ok: false, error: message(e) }))
38
+ : Promise.resolve({
39
+ ok: true, bytes: 0, sourceBytes: 0, files: 0, packages: 0,
40
+ heroes: 0, pendingBytes: 0, configured: false,
41
+ }),
42
+ ]);
43
+ return json({ ok: true, r2, b2 });
44
+ }
45
+
46
+ async function r2Usage(env) {
47
+ if (!env.MEDIA) return { ok: false, error: "Binding MEDIA non configurato" };
48
+
49
+ let cursor;
50
+ let objects = 0, bytes = 0, trashObjects = 0, trashBytes = 0, pages = 0, partial = false;
51
+ do {
52
+ const page = await env.MEDIA.list({ limit: 1000, cursor });
53
+ for (const o of page.objects || []) {
54
+ // I `.keep` sono i segnaposto che tengono in piedi una cartella vuota: zero byte, ma
55
+ // contarli gonfierebbe il numero delle foto con oggetti che nessuno ha caricato.
56
+ if (o.key.endsWith("/.keep")) continue;
57
+ if (o.key.startsWith("_pending-media/")) continue;
58
+ if (o.key.startsWith("_trash/")) { trashObjects++; trashBytes += o.size; continue; }
59
+ objects++; bytes += o.size;
60
+ }
61
+ cursor = page.truncated ? page.cursor : undefined;
62
+ if (++pages >= MAX_PAGES && cursor) { partial = true; cursor = undefined; }
63
+ } while (cursor);
64
+
65
+ /* Il cestino è spazio occupato a tutti gli effetti — R2 non ha versioning, `_trash/` è
66
+ * l'unica rete — ma è anche l'unica parte che si può liberare senza perdere niente del
67
+ * sito. Va tenuto separato proprio per questo: è l'unica azione possibile davanti al
68
+ * numero totale. */
69
+ return { ok: true, objects, bytes, trashObjects, trashBytes, partial };
70
+ }
71
+
72
+ async function b2Usage(env) {
73
+ if (!env.DB) return { ok: false, error: "Archivio non configurato" };
74
+
75
+ /* `state='ready'` e non semplicemente "non cancellato": un pacchetto in caricamento ha
76
+ * `stored_bytes` a zero finché non viene finalizzato, quindi includerlo mostrerebbe un
77
+ * transfer in corso come se non occupasse niente. Lo spazio di chi sta caricando si vede
78
+ * dentro il transfer, dove c'è la barra di avanzamento che lo spiega. */
79
+ const packages = await env.DB.prepare(`
80
+ SELECT COALESCE(SUM(p.stored_bytes),0) bytes,
81
+ COALESCE(SUM(p.source_bytes),0) source_bytes,
82
+ COALESCE(SUM(p.file_count),0) files,
83
+ COUNT(*) packages
84
+ FROM delivery_packages p
85
+ JOIN deliveries d ON d.id = p.delivery_id
86
+ WHERE p.state='ready' AND p.deleted_at IS NULL AND d.status != 'deleted'
87
+ `).first();
88
+
89
+ // Le hero dedicate sono derivate piccole ma sono oggetti B2 come gli altri, e stanno in una
90
+ // tabella loro: senza questa riga il totale sarebbe più basso del vero.
91
+ const heroes = await env.DB.prepare(`
92
+ SELECT COALESCE(SUM(h.bytes),0) bytes, COUNT(*) n
93
+ FROM delivery_hero_objects h
94
+ JOIN deliveries d ON d.id = h.delivery_id
95
+ WHERE h.state='ready' AND d.status != 'deleted'
96
+ `).first();
97
+
98
+ // In attesa di sparire: revocati o scaduti, ancora su B2 finché non passa l'housekeeping.
99
+ // È spazio che si libera da solo, e dirlo evita la domanda «perché non cala?».
100
+ const pending = await env.DB.prepare(`
101
+ SELECT COALESCE(SUM(p.stored_bytes),0) bytes
102
+ FROM delivery_packages p
103
+ WHERE p.state='ready' AND p.deleted_at IS NOT NULL
104
+ `).first();
105
+
106
+ return {
107
+ ok: true,
108
+ bytes: Number(packages?.bytes || 0) + Number(heroes?.bytes || 0),
109
+ sourceBytes: Number(packages?.source_bytes || 0),
110
+ files: Number(packages?.files || 0),
111
+ packages: Number(packages?.packages || 0),
112
+ heroes: Number(heroes?.n || 0),
113
+ pendingBytes: Number(pending?.bytes || 0),
114
+ configured: !!(env.B2_BUCKET && env.B2_ENDPOINT),
115
+ };
116
+ }
117
+
118
+ function message(e) { return e instanceof Error ? e.message : String(e); }
119
+
120
+ function json(data, status = 200) {
121
+ return new Response(JSON.stringify(data), {
122
+ status,
123
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
124
+ });
125
+ }