@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,155 @@
1
+ /* Riferimenti alle immagini dentro le pagine (front matter `images:` e corpo). */
2
+
3
+ // URL pubblico di una chiave R2, come viene salvato nelle pagine.
4
+ export function mediaUrl(key) {
5
+ return "/media/" + key.split("/").map(encodeURIComponent).join("/");
6
+ }
7
+
8
+ // Titolo della pagina dal front matter (o null).
9
+ export function parseTitle(text) {
10
+ const m = text.match(/^title:[ \t]*(.*?)[ \t]*$/m);
11
+ if (!m) return null;
12
+ const value = m[1].trim();
13
+ const quoted = value.match(/^(["'])([\s\S]*)\1$/);
14
+ return (quoted ? quoted[2] : value) || null;
15
+ }
16
+
17
+ // Vero se l'URL è presente nel testo come riferimento a sé stante
18
+ // (boundary: evita che /media/a/b.png matchi dentro /media/a/b.png2).
19
+ export function urlPresent(text, url) {
20
+ const esc = url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ return new RegExp(esc + "(?![A-Za-z0-9._%/-])").test(text);
22
+ }
23
+
24
+ /* Rimuove i riferimenti alle url indicate: voci di lista YAML, valori scalari, immagini
25
+ * markdown `![](url)` e tag `<img src="url">`. Ritorna { text, changed }.
26
+ *
27
+ * È una passata TESTUALE e non un giro dallo schema, per la stessa ragione per cui
28
+ * `frontmatter.ts` non usa una libreria YAML: qui non si sa — e non si deve sapere — in quali
29
+ * campi possa comparire un'immagine. Oggi sono le foto della gallery, la copertina e le card di
30
+ * un lavoro, la locandina di un workshop, il ritratto, l'immagine di una sezione, la cover Open
31
+ * Graph; domani ne basta uno nuovo per far dimenticare un posto. Una regola sulla FORMA li copre
32
+ * tutti, compresi quelli non ancora dichiarati.
33
+ *
34
+ * La distinzione che conta è fra una voce che È un'immagine e una voce che ne CONTIENE una:
35
+ *
36
+ * - src: "/media/food/uno.webp" la voce È la foto -> via INTERA, righe figlie comprese
37
+ * w: 1708 (`w`/`h`/`hidden` lasciate orfane sarebbero front matter
38
+ * h: 2560 rotto scritto sul repo)
39
+ *
40
+ * - "/media/develop/uno.webp" idem: una lista di percorsi nudi
41
+ *
42
+ * - image: "/media/workshop/x.webp" la voce è un'EDIZIONE, che ha una locandina -> si svuota
43
+ * title: "Roma 2022" il solo valore. Portare via la voce vorrebbe dire
44
+ * blocks: … cancellare titolo e testi insieme alla foto.
45
+ *
46
+ * cover: "/media/books/uno.webp" valore SCALARE -> si SVUOTA, non si cancella la riga:
47
+ * togliere la chiave romperebbe la voce di lista che
48
+ * comincia con essa, e per un campo obbligatorio sarebbe un
49
+ * file senza un campo che deve esserci. `cover: ""` dice la
50
+ * cosa giusta — quell'immagine non c'è più.
51
+ *
52
+ * A separarli è il NOME della chiave, ed è una convenzione di questo progetto, non una regola di
53
+ * YAML: `src` si usa dove la voce è la fotografia e basta; `image`/`cover` dove l'immagine
54
+ * appartiene a qualcosa che ha una vita propria — un lavoro, un'edizione, una card. Chi
55
+ * aggiungesse una lista di immagini con un'altra chiave deve aggiungerla anche qui.
56
+ *
57
+ * Reggeva solo `- "url"`, cioè una lista di stringhe nude. Da quando le foto di una gallery sono
58
+ * OGGETTI (`w`/`h` servono alla griglia per riservare l'ingombro di ogni cella) quella riga non
59
+ * combaciava più con niente: si eliminava una foto dai media, l'admin dichiarava di aver pulito
60
+ * i riferimenti, e nella griglia restava un buco della dimensione esatta della foto sparita. */
61
+ export function removeImageRefs(text, urls) {
62
+ const norm = (s) => s.trim().replace(/^["']|["']$/g, "");
63
+ const indentOf = (l) => l.match(/^[ \t]*/)[0].length;
64
+ const KEY = /^([ \t]*)([A-Za-z_][\w-]*)\s*:\s*(.*)$/;
65
+ const set = new Set(urls);
66
+ /** Le chiavi con cui una voce di lista È l'immagine, e quindi va via tutta. */
67
+ const VOCE_E_IMMAGINE = new Set(["src"]);
68
+ let changed = false;
69
+
70
+ const lines = text.split("\n");
71
+ const out = [];
72
+ /* Le chiavi da cui abbiamo tolto delle voci: se restano senza, vanno chiuse con `[]` o il file
73
+ avrebbe una chiave senza valore. Si segna DOVE sono in `out`, non il loro nome: due campi
74
+ diversi possono chiamarsi uguale a profondità diverse. */
75
+ const svuotate = [];
76
+
77
+ for (let i = 0; i < lines.length; i++) {
78
+ const line = lines[i];
79
+ const item = line.match(/^([ \t]*)-\s+(.*)$/);
80
+ if (item) {
81
+ const kv = item[2].match(KEY);
82
+ // La prima chiave di una voce sta sulla riga del trattino (è la forma che emette
83
+ // `toYamlLines`): `- src: "…"`. Senza chiave, la voce è una stringa nuda.
84
+ const laVoceEUnImmagine = !kv || VOCE_E_IMMAGINE.has(kv[2]);
85
+ if (laVoceEUnImmagine && set.has(norm(kv ? kv[3] : item[2]))) {
86
+ const base = item[1].length;
87
+ while (i + 1 < lines.length && lines[i + 1].trim() !== "" && indentOf(lines[i + 1]) > base) i++;
88
+ for (let j = out.length - 1; j >= 0; j--) {
89
+ const k = out[j].match(KEY);
90
+ if (k && indentOf(out[j]) < base) { if (k[3] === "") svuotate.push(j); break; }
91
+ }
92
+ changed = true;
93
+ continue;
94
+ }
95
+ // La voce resta: se è lei a portare l'immagine (`- image: "…"`), si svuota il valore.
96
+ if (kv && kv[3] !== "" && set.has(norm(kv[3]))) {
97
+ out.push(`${item[1]}- ${kv[2]}: ""`);
98
+ changed = true;
99
+ continue;
100
+ }
101
+ } else {
102
+ const kv = line.match(KEY);
103
+ if (kv && kv[3] !== "" && set.has(norm(kv[3]))) {
104
+ out.push(`${kv[1]}${kv[2]}: ""`);
105
+ changed = true;
106
+ continue;
107
+ }
108
+ }
109
+ out.push(line);
110
+ }
111
+
112
+ for (const j of svuotate) {
113
+ const base = indentOf(out[j]);
114
+ // È rimasta almeno una voce sotto questa chiave? Si guarda fin dove arriva il suo blocco.
115
+ let restano = false;
116
+ for (let k = j + 1; k < out.length; k++) {
117
+ if (out[k].trim() === "") continue;
118
+ if (indentOf(out[k]) <= base) break;
119
+ if (/^[ \t]*-\s/.test(out[k])) { restano = true; break; }
120
+ }
121
+ if (!restano) out[j] = out[j].replace(/:\s*$/, ": []");
122
+ }
123
+ let t = out.join("\n");
124
+
125
+ for (const u of urls) {
126
+ const esc = u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
127
+ const before = t;
128
+ // Markdown: supporta anche <url>, spazi e il titolo facoltativo dopo l'indirizzo.
129
+ t = t.replace(new RegExp(
130
+ "!\\[[^\\]]*\\]\\(\\s*<?" + esc +
131
+ ">?(?:\\s+(?:[\"'][^\"']*[\"']|\\([^)]*\\)))?\\s*\\)",
132
+ "g",
133
+ ), "");
134
+ t = t.replace(new RegExp(
135
+ "<img\\b[^>]*\\bsrc\\s*=\\s*[\"']" + esc + "[\"'][^>]*>",
136
+ "gi",
137
+ ), "");
138
+ if (t !== before) changed = true;
139
+ }
140
+
141
+ return { text: t, changed };
142
+ }
143
+
144
+ // Sostituisce gli URL immagine (map oldUrl -> newUrl), es. dopo uno spostamento.
145
+ export function replaceImageRefs(text, map) {
146
+ let t = text, changed = false;
147
+ for (const [oldUrl, newUrl] of Object.entries(map)) {
148
+ const esc = oldUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ const re = new RegExp(esc + "(?![A-Za-z0-9._%/-])", "g");
150
+ const before = t;
151
+ t = t.replace(re, newUrl);
152
+ if (t !== before) changed = true;
153
+ }
154
+ return { text: t, changed };
155
+ }
@@ -0,0 +1,106 @@
1
+ /* GET /admin/api/analytics?range=7d — le visite del sito, da Umami o da Google Analytics.
2
+ *
3
+ * Sta sotto /admin/: Access e la verifica del JWT sono già passate dal middleware.
4
+ *
5
+ * È un endpoint SEPARATO da /admin/api/storage, e non è pignoleria: è l'unico pezzo della
6
+ * Panoramica che dipende da un servizio di qualcun altro. Messo insieme agli altri, un servizio
7
+ * lento o giù terrebbe ferma una schermata che per il resto legge solo roba nostra — e la
8
+ * schermata è quella da cui si entra nel pannello.
9
+ *
10
+ * ── I due servizi ──────────────────────────────────────────────────────────────────────────
11
+ * Questo file non sa niente di nessuno dei due: sceglie un driver e gli chiede i numeri di una
12
+ * finestra temporale. I driver stanno in `_shared/analytics-umami.js` e
13
+ * `_shared/analytics-ga4.js`, espongono le stesse quattro cose (`id`, `label`, `missing`, `collect`) e
14
+ * restituiscono la stessa forma. Aggiungerne un terzo significa scrivere un file e metterlo
15
+ * nell'elenco qui sotto: né questo endpoint né il pannello cambiano.
16
+ *
17
+ * Quale dei due: `ANALYTICS_PROVIDER` se dichiarato, altrimenti il primo che risulta
18
+ * configurato. L'ordine dell'elenco è quindi anche la precedenza.
19
+ *
20
+ * ── La chiave non attraversa mai il browser ────────────────────────────────────────────────
21
+ * Vale per entrambi, con motivi diversi: una chiave di Umami Cloud vale per TUTTI i siti
22
+ * dell'account, e la chiave privata di un service account Google firma richieste per conto di
23
+ * quell'identità. Restano secret delle Functions, e fuori esce solo il risultato.
24
+ *
25
+ * ── Il tag sul sito è un'altra cosa ────────────────────────────────────────────────────────
26
+ * Questo endpoint LEGGE le statistiche. A produrle è il tag che si incolla in
27
+ * Impostazioni → Sito, che vive in `data/site.json` e lo emette `layouts/partials/head.html`.
28
+ * Sono indipendenti apposta: si può avere il tag senza il pannello collegato (i dati esistono,
29
+ * si guardano sul servizio) e viceversa (il pannello legge un sito tracciato altrove — è così
30
+ * che è stato collaudato).
31
+ */
32
+ import { cachedJson } from "../_shared/edge-cache.js";
33
+ import * as umami from "../_shared/analytics-umami.js";
34
+ import * as ga4 from "../_shared/analytics-ga4.js";
35
+
36
+ const DRIVERS = [umami, ga4];
37
+
38
+ /* Un minuto. Umami Cloud ha un tetto di chiamate e ogni caricamento della Panoramica ne fa
39
+ * quattro (GA4 pure): senza, basta rimbalzare fra le sezioni per consumarlo. Un minuto di
40
+ * ritardo su un grafico di giorni non lo nota nessuno. */
41
+ const TTL = 60;
42
+
43
+ const RANGES = {
44
+ "24h": { hours: 24, unit: "hour" },
45
+ "7d": { hours: 24 * 7, unit: "day" },
46
+ "30d": { hours: 24 * 30, unit: "day" },
47
+ };
48
+
49
+ export async function onRequestGet({ request, env, waitUntil }) {
50
+ const driver = pickDriver(env);
51
+ if (!driver) {
52
+ /* NON è un errore: è una funzione che non è stata ancora accesa, e il pannello lo dice
53
+ * invece di mostrare un guasto. Si manda cosa manca PER OGNI servizio, così la schermata
54
+ * può proporre la scelta invece di raccontarne uno solo. */
55
+ return json({
56
+ ok: true,
57
+ configured: false,
58
+ providers: DRIVERS.map((d) => ({ id: d.id, label: d.label, missing: d.missing(env) })),
59
+ });
60
+ }
61
+
62
+ const asked = new URL(request.url).searchParams.get("range") || "7d";
63
+ const range = RANGES[asked] ? asked : "7d";
64
+ const { hours, unit } = RANGES[range];
65
+ const endAt = Date.now();
66
+ const startAt = endAt - hours * 3_600_000;
67
+
68
+ try {
69
+ // La chiave della cache contiene il provider: cambiandolo non si eredita il grafico dell'altro.
70
+ const { data, cached } = await cachedJson(
71
+ request, waitUntil, `analytics-${driver.id}-${range}`, TTL,
72
+ () => driver.collect(env, { startAt, endAt, unit }),
73
+ );
74
+ return json({
75
+ ok: true, configured: true, provider: driver.id, providerLabel: driver.label,
76
+ range, unit, startAt, endAt, cached, ...data,
77
+ });
78
+ } catch (e) {
79
+ console.error(`analytics ${driver.id}:`, e);
80
+ return json({
81
+ ok: false, configured: true, provider: driver.id, providerLabel: driver.label,
82
+ error: message(e),
83
+ }, 502);
84
+ }
85
+ }
86
+
87
+ function pickDriver(env) {
88
+ const wanted = String(env.ANALYTICS_PROVIDER || "").trim().toLowerCase();
89
+ if (wanted) {
90
+ /* Dichiarato ma incompleto NON deve ricadere sull'altro: si è chiesto quel servizio, e un
91
+ * silenzioso passaggio al secondo mostrerebbe i numeri di un sito diverso da quello che si
92
+ * pensa di stare guardando. Meglio «non collegato», che dice cosa manca. */
93
+ const chosen = DRIVERS.find((d) => d.id === wanted);
94
+ return chosen && chosen.missing(env).length === 0 ? chosen : null;
95
+ }
96
+ return DRIVERS.find((d) => d.missing(env).length === 0) || null;
97
+ }
98
+
99
+ function message(e) { return e instanceof Error ? e.message : String(e); }
100
+
101
+ function json(data, status = 200) {
102
+ return new Response(JSON.stringify(data), {
103
+ status,
104
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
105
+ });
106
+ }
@@ -0,0 +1,241 @@
1
+ /* POST /admin/api/assets/delete — SOFT delete: pulisce i riferimenti nelle
2
+ * pagine (commit) e sposta i file nel cestino (_trash/, auto-purge 30gg).
3
+ * Body: { keys?: [...], prefixes?: [...] } (compat: { key }, { prefix }).
4
+ * Supporta più file/cartelle insieme.
5
+ *
6
+ * DUE INTERRUTTORI, e servono a spezzare una cancellazione grande in più richieste senza
7
+ * moltiplicare i commit:
8
+ * refsOnly pulisce i riferimenti di TUTTE le chiavi e non tocca il bucket
9
+ * skipRefs sposta i file nel cestino e non tocca il repo
10
+ *
11
+ * Perché non basta chiamare l'endpoint più volte: la pulizia dei riferimenti costa un commit —
12
+ * cioè una build — e farne uno per ogni gruppo di file significherebbe otto deploy per una
13
+ * cancellazione sola. Il client fa una passata `refsOnly` con l'elenco intero (un commit) e poi
14
+ * manda i file a gruppi con `skipRefs`.
15
+ *
16
+ * Perché il client deve spezzare: `moveToTrash` è get + put + delete per file, e ottanta foto da
17
+ * qualche MB in una richiesta sola non ci stanno — la richiesta muore a metà e i file restano
18
+ * spostati per una parte e no per il resto. Il valore `trashed` nella risposta è quello vero,
19
+ * quindi chi chiama sa sempre quanti ne sono passati davvero.
20
+ */
21
+ import { json, isKey, isPrefix, listKeysUnder } from "../../_shared/assets.js";
22
+ import { mediaUrl } from "../../_shared/refs.js";
23
+ import { rewriteMediaReferences } from "../../_shared/media-content.js";
24
+ import { getBase, DRAFT } from "../../_shared/github.js";
25
+ import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
26
+
27
+ const enc = new TextEncoder();
28
+
29
+ function b64url(bytes) {
30
+ let raw = "";
31
+ for (const byte of new Uint8Array(bytes)) raw += String.fromCharCode(byte);
32
+ return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
33
+ }
34
+
35
+ function fromB64url(value) {
36
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
37
+ const raw = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));
38
+ return Uint8Array.from(raw, (c) => c.charCodeAt(0));
39
+ }
40
+
41
+ async function hmacKey(env) {
42
+ return crypto.subtle.importKey(
43
+ "raw",
44
+ enc.encode(env.GITHUB_TOKEN),
45
+ { name: "HMAC", hash: "SHA-256" },
46
+ false,
47
+ ["sign", "verify"],
48
+ );
49
+ }
50
+
51
+ /** Prova che queste chiavi hanno superato il preflight. Vive un'ora: abbastanza per i gruppi
52
+ * grandi, troppo poco per diventare un'autorizzazione riutilizzabile in futuro. */
53
+ export async function signRefsPreflight(env, keys, commitSha) {
54
+ const payload = b64url(enc.encode(JSON.stringify({
55
+ keys: [...new Set(keys)].sort(),
56
+ repo: env.GITHUB_REPO,
57
+ branch: env.GITHUB_BRANCH || DRAFT,
58
+ commit: commitSha,
59
+ exp: Date.now() + 60 * 60 * 1000,
60
+ })));
61
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(env), enc.encode(payload));
62
+ return `${payload}.${b64url(signature)}`;
63
+ }
64
+
65
+ export async function verifyRefsPreflight(env, token, keys, currentCommit) {
66
+ if (typeof token !== "string") return false;
67
+ const [payload, signature, extra] = token.split(".");
68
+ if (!payload || !signature || extra) return false;
69
+ let data;
70
+ try {
71
+ const valid = await crypto.subtle.verify(
72
+ "HMAC",
73
+ await hmacKey(env),
74
+ fromB64url(signature),
75
+ enc.encode(payload),
76
+ );
77
+ if (!valid) return false;
78
+ data = JSON.parse(new TextDecoder().decode(fromB64url(payload)));
79
+ } catch {
80
+ return false;
81
+ }
82
+ if (
83
+ !data || !Array.isArray(data.keys) || data.exp < Date.now()
84
+ || data.repo !== env.GITHUB_REPO
85
+ || data.branch !== (env.GITHUB_BRANCH || DRAFT)
86
+ || !data.commit
87
+ || data.commit !== currentCommit
88
+ ) return false;
89
+ const allowed = new Set(data.keys);
90
+ return keys.every((key) => allowed.has(key));
91
+ }
92
+
93
+ export async function onRequestPost({ request, env }) {
94
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
95
+ let body;
96
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
97
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
98
+ return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
99
+ }
100
+
101
+ const keys = new Set();
102
+ for (const k of (Array.isArray(body.keys) ? body.keys : [])) if (isKey(k)) keys.add(k);
103
+ if (body.key && isKey(body.key)) keys.add(body.key);
104
+
105
+ const prefixes = [
106
+ ...(Array.isArray(body.prefixes) ? body.prefixes : []),
107
+ ...(body.prefix ? [body.prefix] : []),
108
+ ].filter((p) => p && isPrefix(p));
109
+ const keepKeys = [];
110
+ for (const pre of prefixes) {
111
+ for (const k of await listKeysUnder(env.MEDIA, pre)) {
112
+ if (k.endsWith("/.keep")) keepKeys.push(k); else keys.add(k);
113
+ }
114
+ }
115
+ const keyList = [...keys].filter((k) => !k.startsWith("_trash/"));
116
+ if (!keyList.length && !keepKeys.length) return json({ ok: true, trashed: 0, pagesUpdated: [] });
117
+
118
+ const refsOnly = body.refsOnly === true;
119
+ const skipRefs = body.skipRefs === true;
120
+ if (keyList.length && (!env.GITHUB_TOKEN || !env.GITHUB_REPO)) {
121
+ return json({
122
+ ok: false,
123
+ code: "github_unavailable",
124
+ error: "Non posso eliminare media senza verificare e aggiornare i riferimenti nel sito.",
125
+ }, 503);
126
+ }
127
+ if (skipRefs && keyList.length) {
128
+ let valid = false;
129
+ try {
130
+ const current = await getBase(env);
131
+ valid = await verifyRefsPreflight(env, body.refsToken, keyList, current.commitSha);
132
+ } catch (error) {
133
+ return json({
134
+ ok: false,
135
+ code: "github_read_failed",
136
+ error: `Non riesco a confermare la verifica riferimenti: ${error.message || String(error)}`,
137
+ }, 502);
138
+ }
139
+ if (!valid) {
140
+ return json({
141
+ ok: false,
142
+ code: "media_preflight_required",
143
+ error: "Il sito è cambiato dopo la verifica dei riferimenti. Ripeti l'operazione.",
144
+ }, 409);
145
+ }
146
+ return json({
147
+ ok: true,
148
+ trashed: keyList.length,
149
+ deferred: true,
150
+ pagesUpdated: [],
151
+ warning: "I file resteranno disponibili al sito online fino alla prossima pubblicazione.",
152
+ });
153
+ }
154
+
155
+ // 1) pulizia riferimenti in tutte le sorgenti (un solo commit). Qualunque file non
156
+ // riproducibile o non valido blocca TUTTA l'operazione: nessun riferimento viene saltato.
157
+ const pagesUpdated = [];
158
+ let verifiedCommit = null;
159
+ if (!skipRefs && keyList.length) {
160
+ const urls = keyList.map(mediaUrl);
161
+ try {
162
+ const result = await rewriteMediaReferences(
163
+ env,
164
+ { removeUrls: urls },
165
+ "admin: rimuovi riferimenti a immagini eliminate",
166
+ );
167
+ pagesUpdated.push(...result.labels);
168
+ verifiedCommit = result.verifiedCommit;
169
+ } catch (error) {
170
+ const blocked = Array.isArray(error.blockers) ? error.blockers : undefined;
171
+ return json({
172
+ ok: false,
173
+ code: error.code || "media_refs_failed",
174
+ error: error.message || String(error),
175
+ ...(blocked ? { blockers: blocked } : {}),
176
+ }, error.code === "media_refs_blocked" || error.code === "conflict" ? 409 : 502);
177
+ }
178
+ }
179
+
180
+ if (refsOnly) {
181
+ if (keyList.length) {
182
+ await recordPendingMedia(
183
+ env.MEDIA,
184
+ "trash",
185
+ keyList.map((oldKey) => ({ oldKey })),
186
+ verifiedCommit,
187
+ );
188
+ await finalizePendingMedia(env).catch(() => null);
189
+ }
190
+ for (const keepKey of keepKeys) await env.MEDIA.delete(keepKey);
191
+ return json({
192
+ ok: true,
193
+ trashed: 0,
194
+ refsOnly: true,
195
+ refsToken: await signRefsPreflight(env, keyList, verifiedCommit),
196
+ pagesUpdated: [...new Set(pagesUpdated)],
197
+ });
198
+ }
199
+
200
+ // Chiamata non spezzata: anche nella breve finestra fra commit e R2 il branch non deve
201
+ // cambiare, perché una modifica concorrente potrebbe aver reintrodotto uno dei riferimenti.
202
+ if (!skipRefs && keyList.length) {
203
+ try {
204
+ const current = await getBase(env);
205
+ if (current.commitSha !== verifiedCommit) {
206
+ return json({
207
+ ok: false,
208
+ code: "conflict",
209
+ error: "Il sito è cambiato dopo la pulizia dei riferimenti. Nessun file è stato cestinato.",
210
+ }, 409);
211
+ }
212
+ } catch (error) {
213
+ return json({
214
+ ok: false,
215
+ code: "github_read_failed",
216
+ error: `Non riesco a confermare il commit dei riferimenti: ${error.message || String(error)}`,
217
+ }, 502);
218
+ }
219
+ }
220
+
221
+ // 2) la rimozione fisica è differita: `main` può ancora contenere i vecchi riferimenti.
222
+ if (keyList.length) {
223
+ await recordPendingMedia(
224
+ env.MEDIA,
225
+ "trash",
226
+ keyList.map((oldKey) => ({ oldKey })),
227
+ verifiedCommit,
228
+ );
229
+ await finalizePendingMedia(env).catch(() => null);
230
+ }
231
+ for (const kk of keepKeys) {
232
+ await env.MEDIA.delete(kk);
233
+ }
234
+ return json({
235
+ ok: true,
236
+ trashed: keyList.length,
237
+ deferred: true,
238
+ pagesUpdated: [...new Set(pagesUpdated)],
239
+ warning: "I file resteranno disponibili al sito online fino alla prossima pubblicazione.",
240
+ });
241
+ }
@@ -0,0 +1,43 @@
1
+ /* POST /admin/api/assets/folder — crea una cartella (prefisso) nel bucket.
2
+ * Le cartelle in R2 sono solo prefissi di chiave: creiamo un placeholder
3
+ * "<prefix>.keep" così la cartella (anche vuota) compare nella lista.
4
+ * Body: { prefix: "food/" } oppure { parent: "food/", name: "2024" }.
5
+ * Dietro Access.
6
+ *
7
+ * IL NOME SCELTO SI CONSERVA. La chiave dev'essere uno slug — finisce dentro gli URL delle
8
+ * immagini, che sono pubblici e già indicizzati — ma «Hospitality & Interiors» non deve
9
+ * diventare «hospitality-interiors» sotto gli occhi di chi l'ha scritto. Il nome per esteso vive
10
+ * nei customMetadata del `.keep`, cioè accanto alla cosa che nomina: niente elenco di etichette
11
+ * da tenere allineato altrove, e una cartella senza `.keep` (create prima, o nate caricando in
12
+ * un prefisso) semplicemente mostra il suo slug.
13
+ */
14
+ import { isPrefix, json } from "../../_shared/assets.js";
15
+
16
+ export async function onRequestPost({ request, env }) {
17
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
18
+ let body;
19
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
20
+
21
+ let prefix = String(body.prefix || "").trim().toLowerCase();
22
+ if (!prefix && body.name) {
23
+ const parent = String(body.parent || "").trim().toLowerCase();
24
+ const name = String(body.name).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
25
+ if (!name) return json({ ok: false, error: "nome cartella non valido" }, 400);
26
+ prefix = `${parent}${name}/`;
27
+ }
28
+ if (!prefix.endsWith("/")) prefix += "/";
29
+ if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
30
+
31
+ // L'etichetta è il nome per esteso. Se non arriva, si prende il nome grezzo del body: chi
32
+ // chiama non deve ricordarsi di mandare due volte la stessa cosa.
33
+ const label = String(body.label ?? body.name ?? "").trim().slice(0, 120);
34
+
35
+ const existing = await env.MEDIA.head(prefix + ".keep");
36
+ if (existing && !body.relabel) return json({ ok: true, prefix, exists: true, label: existing.customMetadata?.label || "" });
37
+
38
+ await env.MEDIA.put(prefix + ".keep", new Uint8Array(0), {
39
+ httpMetadata: { contentType: "application/x-empty" },
40
+ customMetadata: label ? { label } : undefined,
41
+ });
42
+ return json({ ok: true, prefix, label });
43
+ }
@@ -0,0 +1,32 @@
1
+ /* GET /admin/api/assets/folders — elenco delle cartelle di primo livello con
2
+ * il numero di file contenuti (ricorsivo). Usato dalla 2ª colonna dell'admin.
3
+ * Dietro Access.
4
+ */
5
+ import { json, TRASH, listKeysUnder } from "../../_shared/assets.js";
6
+ import { pendingSourceKeys } from "../../_shared/pending-media.js";
7
+
8
+ export async function onRequestGet({ env }) {
9
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
10
+ const keys = await listKeysUnder(env.MEDIA, "");
11
+ const pending = await pendingSourceKeys(env.MEDIA);
12
+ const counts = {};
13
+ for (const k of keys) {
14
+ if (k.startsWith(TRASH) || k.startsWith("_pending-media/") || pending.has(k)) continue;
15
+ const slash = k.indexOf("/");
16
+ if (slash < 0) continue; // file in root: non appartiene a una cartella
17
+ const top = k.slice(0, slash);
18
+ if (!(top in counts)) counts[top] = 0;
19
+ if (!k.endsWith("/.keep")) counts[top] += 1; // il placeholder non conta
20
+ }
21
+ /* L'etichetta (il nome per esteso scelto da chi ha creato la cartella) sta nei metadati del
22
+ * `.keep`. Una `head` per cartella: sono una manciata, e il conto lo si fa una volta per
23
+ * caricamento del pannello. Chi non ce l'ha mostra il suo slug, che è il comportamento di
24
+ * prima — nessuna cartella esistente cambia aspetto. */
25
+ const names = Object.keys(counts);
26
+ const folders = await Promise.all(names.map(async (name) => {
27
+ const keep = await env.MEDIA.head(name + "/.keep");
28
+ return { name, prefix: name + "/", count: counts[name], label: keep?.customMetadata?.label || "" };
29
+ }));
30
+ folders.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
31
+ return json({ ok: true, folders });
32
+ }