@cardinali-studio/adminkit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +195 -0
- package/dist/api.js +118 -0
- package/dist/app/App.d.ts +3 -0
- package/dist/app/App.js +320 -0
- package/dist/app/app.css +2068 -0
- package/dist/app/index.d.ts +18 -0
- package/dist/app/index.js +33 -0
- package/dist/app/lib/bookings.d.ts +132 -0
- package/dist/app/lib/bookings.js +79 -0
- package/dist/app/lib/deliveries.d.ts +95 -0
- package/dist/app/lib/deliveries.js +104 -0
- package/dist/app/lib/delivery-drop.d.ts +1 -0
- package/dist/app/lib/delivery-drop.js +72 -0
- package/dist/app/lib/delivery-upload.d.ts +78 -0
- package/dist/app/lib/delivery-upload.js +585 -0
- package/dist/app/lib/groups.d.ts +9 -0
- package/dist/app/lib/groups.js +32 -0
- package/dist/app/lib/home.d.ts +11 -0
- package/dist/app/lib/home.js +67 -0
- package/dist/app/lib/overview.d.ts +82 -0
- package/dist/app/lib/overview.js +68 -0
- package/dist/app/lib/pages.d.ts +24 -0
- package/dist/app/lib/pages.js +156 -0
- package/dist/app/lib/publish.d.ts +18 -0
- package/dist/app/lib/publish.js +63 -0
- package/dist/app/lib/reorder.d.ts +3 -0
- package/dist/app/lib/reorder.js +31 -0
- package/dist/app/lib/requests.d.ts +10 -0
- package/dist/app/lib/requests.js +67 -0
- package/dist/app/lib/router.d.ts +1 -0
- package/dist/app/lib/router.js +14 -0
- package/dist/app/lib/settings.d.ts +29 -0
- package/dist/app/lib/settings.js +82 -0
- package/dist/app/lib/slug.d.ts +3 -0
- package/dist/app/lib/slug.js +23 -0
- package/dist/app/lib/text.d.ts +1 -0
- package/dist/app/lib/text.js +13 -0
- package/dist/app/lib/theme.d.ts +7 -0
- package/dist/app/lib/theme.js +43 -0
- package/dist/app/lib/upload-queue.d.ts +35 -0
- package/dist/app/lib/upload-queue.js +167 -0
- package/dist/app/routes/AboutEditor.d.ts +15 -0
- package/dist/app/routes/AboutEditor.js +122 -0
- package/dist/app/routes/Bookings.d.ts +21 -0
- package/dist/app/routes/Bookings.js +134 -0
- package/dist/app/routes/Deliveries.d.ts +37 -0
- package/dist/app/routes/Deliveries.js +563 -0
- package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
- package/dist/app/routes/GallerySectionsEditor.js +107 -0
- package/dist/app/routes/HomeEditor.d.ts +10 -0
- package/dist/app/routes/HomeEditor.js +188 -0
- package/dist/app/routes/Overview.d.ts +17 -0
- package/dist/app/routes/Overview.js +318 -0
- package/dist/app/routes/PageActions.d.ts +12 -0
- package/dist/app/routes/PageActions.js +93 -0
- package/dist/app/routes/PageEditor.d.ts +23 -0
- package/dist/app/routes/PageEditor.js +233 -0
- package/dist/app/routes/PageSeoEditor.d.ts +13 -0
- package/dist/app/routes/PageSeoEditor.js +102 -0
- package/dist/app/routes/PagesList.d.ts +10 -0
- package/dist/app/routes/PagesList.js +76 -0
- package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
- package/dist/app/routes/PortfolioWorksEditor.js +139 -0
- package/dist/app/routes/Requests.d.ts +13 -0
- package/dist/app/routes/Requests.js +30 -0
- package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
- package/dist/app/routes/SiteSettingsEditor.js +66 -0
- package/dist/app/routes/UploadDock.d.ts +1 -0
- package/dist/app/routes/UploadDock.js +66 -0
- package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
- package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
- package/dist/app/site.d.ts +37 -0
- package/dist/app/site.js +18 -0
- package/dist/app/ui/MediaSlot.d.ts +10 -0
- package/dist/app/ui/MediaSlot.js +20 -0
- package/dist/app/ui/OpenCard.d.ts +18 -0
- package/dist/app/ui/OpenCard.js +20 -0
- package/dist/app/ui/ParagraphEditor.d.ts +8 -0
- package/dist/app/ui/ParagraphEditor.js +37 -0
- package/dist/app/ui/dnd.d.ts +1 -0
- package/dist/app/ui/dnd.js +11 -0
- package/dist/app/ui/icons.d.ts +26 -0
- package/dist/app/ui/icons.js +45 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +26 -0
- package/dist/form/MediaBrowser.d.ts +13 -0
- package/dist/form/MediaBrowser.js +24 -0
- package/dist/form/MediaPicker.d.ts +10 -0
- package/dist/form/MediaPicker.js +20 -0
- package/dist/form/SchemaForm.d.ts +21 -0
- package/dist/form/SchemaForm.js +67 -0
- package/dist/form/Trash.d.ts +1 -0
- package/dist/form/Trash.js +42 -0
- package/dist/form/fields/index.d.ts +1 -0
- package/dist/form/fields/index.js +110 -0
- package/dist/form/fields/objectList.d.ts +1 -0
- package/dist/form/fields/objectList.js +99 -0
- package/dist/form/index.d.ts +8 -0
- package/dist/form/index.js +8 -0
- package/dist/form/media/Lightbox.d.ts +4 -0
- package/dist/form/media/Lightbox.js +70 -0
- package/dist/form/media/MediaFolders.d.ts +20 -0
- package/dist/form/media/MediaFolders.js +116 -0
- package/dist/form/media/MediaGrid.d.ts +8 -0
- package/dist/form/media/MediaGrid.js +139 -0
- package/dist/form/media/UploadDialog.d.ts +4 -0
- package/dist/form/media/UploadDialog.js +37 -0
- package/dist/form/media/useMediaLibrary.d.ts +55 -0
- package/dist/form/media/useMediaLibrary.js +495 -0
- package/dist/form/prose/ProseBar.d.ts +21 -0
- package/dist/form/prose/ProseBar.js +111 -0
- package/dist/form/prose/ProseField.d.ts +11 -0
- package/dist/form/prose/ProseField.js +152 -0
- package/dist/form/prose/pages.d.ts +6 -0
- package/dist/form/prose/pages.js +13 -0
- package/dist/form/registry.d.ts +20 -0
- package/dist/form/registry.js +15 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +202 -0
- package/dist/functions/_middleware.js +101 -0
- package/dist/functions/_shared/analytics-ga4.js +248 -0
- package/dist/functions/_shared/analytics-umami.js +119 -0
- package/dist/functions/_shared/assets.js +90 -0
- package/dist/functions/_shared/b2.js +300 -0
- package/dist/functions/_shared/delivery-crypto.js +99 -0
- package/dist/functions/_shared/delivery-purge.js +222 -0
- package/dist/functions/_shared/edge-cache.js +37 -0
- package/dist/functions/_shared/exif.js +53 -0
- package/dist/functions/_shared/github.js +138 -0
- package/dist/functions/_shared/imagesize.js +101 -0
- package/dist/functions/_shared/media-content.js +200 -0
- package/dist/functions/_shared/pending-media.js +98 -0
- package/dist/functions/_shared/refs.js +155 -0
- package/dist/functions/api/analytics.js +106 -0
- package/dist/functions/api/assets/delete.js +241 -0
- package/dist/functions/api/assets/folder.js +43 -0
- package/dist/functions/api/assets/folders.js +32 -0
- package/dist/functions/api/assets/move.js +253 -0
- package/dist/functions/api/assets/preview.js +17 -0
- package/dist/functions/api/assets/trash/delete.js +13 -0
- package/dist/functions/api/assets/trash/empty.js +16 -0
- package/dist/functions/api/assets/trash/restore.js +43 -0
- package/dist/functions/api/assets/trash.js +28 -0
- package/dist/functions/api/assets/upload.js +54 -0
- package/dist/functions/api/assets/usage.js +41 -0
- package/dist/functions/api/assets.js +94 -0
- package/dist/functions/api/deliveries.js +629 -0
- package/dist/functions/api/delivery/hero.js +21 -0
- package/dist/functions/api/delivery/image.js +35 -0
- package/dist/functions/api/delivery/multipart.js +261 -0
- package/dist/functions/api/pages/get.js +26 -0
- package/dist/functions/api/pages.js +354 -0
- package/dist/functions/api/publish.js +134 -0
- package/dist/functions/api/requests.js +57 -0
- package/dist/functions/api/settings.js +236 -0
- package/dist/functions/api/singleton.js +105 -0
- package/dist/functions/api/storage.js +125 -0
- package/dist/functions/index.js +124 -0
- package/dist/functions/site.js +38 -0
- package/dist/gallery.d.ts +28 -0
- package/dist/gallery.js +18 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +47 -0
- package/dist/normalize.d.ts +50 -0
- package/dist/normalize.js +120 -0
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +82 -0
- package/dist/prose.d.ts +17 -0
- package/dist/prose.js +205 -0
- package/dist/registries.d.ts +43 -0
- package/dist/registries.js +29 -0
- package/dist/serialize.d.ts +26 -0
- package/dist/serialize.js +136 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +66 -0
- package/dist/theme.css +636 -0
- package/dist/yaml.d.ts +8 -0
- package/dist/yaml.js +90 -0
- package/package.json +97 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/* Driver Google Analytics 4 per la Panoramica.
|
|
2
|
+
*
|
|
3
|
+
* Stesse due domande dell'altro driver («sei configurato?», «dammi i numeri»), stesso formato
|
|
4
|
+
* di risposta: il pannello non sa quale dei due sta guardando.
|
|
5
|
+
*
|
|
6
|
+
* ── Configurazione ─────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* GA4_PROPERTY_ID var — il numero della proprietà GA4 (Amministrazione → Proprietà →
|
|
8
|
+
* Dettagli proprietà). NON è il "measurement ID" `G-XXXXXXX`, che
|
|
9
|
+
* invece sta nel tag: sbagliarli dà un 403 che sembra un problema di
|
|
10
|
+
* permessi.
|
|
11
|
+
* GA4_SERVICE_ACCOUNT secret Pages — il file JSON della chiave del service account, INTERO.
|
|
12
|
+
*
|
|
13
|
+
* ── Perché un service account e non l'OAuth che c'è già ────────────────────────────────────
|
|
14
|
+
* Il sito ha già un OAuth Google per le Prenotazioni (`google-calendar.js`), e verrebbe voglia
|
|
15
|
+
* di aggiungergli lo scope `analytics.readonly`. Non si fa, per tre ragioni concrete:
|
|
16
|
+
*
|
|
17
|
+
* 1. `accessToken()` di quel modulo SCRIVE lo stato della connessione Calendar a ogni
|
|
18
|
+
* chiamata (`token_status = 'connected' | 'error'`). Un errore su una lettura di
|
|
19
|
+
* statistiche marcherebbe come rotto il sistema delle prenotazioni;
|
|
20
|
+
* 2. le statistiche smetterebbero di funzionare il giorno in cui si scollega il Calendar,
|
|
21
|
+
* che è una cosa che non c'entra niente;
|
|
22
|
+
* 3. la schermata di consenso chiederebbe l'accesso ad Analytics anche a chi vuole solo le
|
|
23
|
+
* prenotazioni.
|
|
24
|
+
*
|
|
25
|
+
* Un service account è anche la strada normale per una lettura server-to-server: nessun
|
|
26
|
+
* consenso da rinnovare e nessun refresh token da custodire. L'unico passo manuale è aggiungere
|
|
27
|
+
* la sua email come **Visualizzatore** sulla proprietà GA4, altrimenti risponde 403.
|
|
28
|
+
*/
|
|
29
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
30
|
+
const API = "https://analyticsdata.googleapis.com/v1beta";
|
|
31
|
+
const SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
|
|
32
|
+
|
|
33
|
+
export const id = "ga4";
|
|
34
|
+
export const label = "Google Analytics";
|
|
35
|
+
export const config = [
|
|
36
|
+
{ name: "GA4_PROPERTY_ID", secret: false },
|
|
37
|
+
{ name: "GA4_SERVICE_ACCOUNT", secret: true },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function missing(env) {
|
|
41
|
+
return config.filter((c) => !env[c.name]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function collect(env, { startAt, endAt, unit }) {
|
|
45
|
+
const token = await accessToken(env);
|
|
46
|
+
const current = { startDate: day(startAt), endDate: day(endAt) };
|
|
47
|
+
/* Il periodo precedente ha la STESSA durata e finisce il giorno prima dell'inizio di questo:
|
|
48
|
+
* è ciò che rende confrontabili le due cifre della pillola di variazione. */
|
|
49
|
+
const span = endAt - startAt;
|
|
50
|
+
const previous = { startDate: day(startAt - span), endDate: day(startAt - 86_400_000) };
|
|
51
|
+
|
|
52
|
+
/* GA4 non ha un endpoint "stats": è sempre `runReport`, cambiano dimensioni e metriche.
|
|
53
|
+
* Le quattro insieme per la stessa ragione dell'altro driver. Le due di contorno possono
|
|
54
|
+
* cadere da sole senza portarsi via la scheda. */
|
|
55
|
+
const [totals, series, pages, referrers] = await Promise.all([
|
|
56
|
+
report(token, env, {
|
|
57
|
+
dateRanges: [current, previous],
|
|
58
|
+
metrics: names("screenPageViews", "totalUsers", "sessions", "bounceRate", "userEngagementDuration"),
|
|
59
|
+
}),
|
|
60
|
+
report(token, env, {
|
|
61
|
+
dateRanges: [current],
|
|
62
|
+
dimensions: names(unit === "hour" ? "dateHour" : "date"),
|
|
63
|
+
metrics: names("screenPageViews", "sessions"),
|
|
64
|
+
orderBys: [{ dimension: { dimensionName: unit === "hour" ? "dateHour" : "date" } }],
|
|
65
|
+
limit: 400,
|
|
66
|
+
}),
|
|
67
|
+
report(token, env, {
|
|
68
|
+
dateRanges: [current],
|
|
69
|
+
dimensions: names("pagePath"),
|
|
70
|
+
metrics: names("screenPageViews"),
|
|
71
|
+
orderBys: [{ metric: { metricName: "screenPageViews" }, desc: true }],
|
|
72
|
+
limit: 6,
|
|
73
|
+
}).catch(() => null),
|
|
74
|
+
report(token, env, {
|
|
75
|
+
dateRanges: [current],
|
|
76
|
+
dimensions: names("sessionSource"),
|
|
77
|
+
metrics: names("sessions"),
|
|
78
|
+
orderBys: [{ metric: { metricName: "sessions" }, desc: true }],
|
|
79
|
+
limit: 8,
|
|
80
|
+
}).catch(() => null),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const now = periodRow(totals, 0);
|
|
84
|
+
const before = periodRow(totals, 1);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
totals: {
|
|
88
|
+
pageviews: metric(now.screenPageViews, before.screenPageViews),
|
|
89
|
+
visitors: metric(now.totalUsers, before.totalUsers),
|
|
90
|
+
visits: metric(now.sessions, before.sessions),
|
|
91
|
+
/* `bounceRate` è una FRAZIONE, non un conteggio: moltiplicata per le sessioni torna a
|
|
92
|
+
* essere la stessa cosa che manda Umami. Il pannello oggi non la mostra, ma il campo
|
|
93
|
+
* deve contenere ciò che il suo nome promette, o al primo riquadro nuovo si scopre di
|
|
94
|
+
* avere una percentuale al posto di un numero. */
|
|
95
|
+
bounces: metric(now.bounceRate * now.sessions, before.bounceRate * before.sessions),
|
|
96
|
+
totaltime: metric(now.userEngagementDuration, before.userEngagementDuration),
|
|
97
|
+
},
|
|
98
|
+
series: (series?.rows || []).map((row) => ({
|
|
99
|
+
date: stamp(dim(row, 0), unit),
|
|
100
|
+
pageviews: num(met(row, 0)),
|
|
101
|
+
sessions: num(met(row, 1)),
|
|
102
|
+
})).sort((a, b) => a.date.localeCompare(b.date)),
|
|
103
|
+
pages: topList(pages),
|
|
104
|
+
/* `(direct)` e `(not set)` non sono siti che portano traffico: sono l'assenza di un sito.
|
|
105
|
+
* Il riquadro si chiama «Da dove arrivano» e mostrarli lì sarebbe una risposta sbagliata
|
|
106
|
+
* alla domanda scritta sopra l'elenco. */
|
|
107
|
+
referrers: topList(referrers).filter((r) => r.name && !/^\(.*\)$/.test(r.name)).slice(0, 6),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function report(token, env, body) {
|
|
112
|
+
const property = String(env.GA4_PROPERTY_ID).replace(/^properties\//, "");
|
|
113
|
+
const res = await fetch(`${API}/properties/${encodeURIComponent(property)}:runReport`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${token}`,
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
Accept: "application/json",
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
/* Il messaggio di Google finisce nell'errore: un 403 «User does not have sufficient
|
|
124
|
+
* permissions» (service account non aggiunto alla proprietà) e un 400 su un id sbagliato
|
|
125
|
+
* si curano in due modi diversi, e dal solo numero non si distinguono. */
|
|
126
|
+
const detail = await res.json().catch(() => null);
|
|
127
|
+
const text = detail?.error?.message || (await res.text().catch(() => "")).slice(0, 200);
|
|
128
|
+
throw new Error(`Google Analytics ha risposto ${res.status}${text ? `: ${text}` : ""}`);
|
|
129
|
+
}
|
|
130
|
+
return res.json();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---- Autenticazione: JWT firmato, scambiato per un access token ------------------------- */
|
|
134
|
+
|
|
135
|
+
async function accessToken(env) {
|
|
136
|
+
let key;
|
|
137
|
+
try {
|
|
138
|
+
key = JSON.parse(env.GA4_SERVICE_ACCOUNT);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error("GA4_SERVICE_ACCOUNT non contiene il JSON della chiave del service account");
|
|
141
|
+
}
|
|
142
|
+
if (!key.client_email || !key.private_key) {
|
|
143
|
+
throw new Error("Nel JSON del service account mancano client_email o private_key");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const now = Math.floor(Date.now() / 1000);
|
|
147
|
+
const claims = {
|
|
148
|
+
iss: key.client_email,
|
|
149
|
+
scope: SCOPE,
|
|
150
|
+
aud: key.token_uri || TOKEN_URL,
|
|
151
|
+
iat: now,
|
|
152
|
+
exp: now + 3600,
|
|
153
|
+
};
|
|
154
|
+
const unsigned = `${b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }))}.${b64url(JSON.stringify(claims))}`;
|
|
155
|
+
const signature = await crypto.subtle.sign(
|
|
156
|
+
"RSASSA-PKCS1-v1_5",
|
|
157
|
+
await importPrivateKey(key.private_key),
|
|
158
|
+
new TextEncoder().encode(unsigned),
|
|
159
|
+
);
|
|
160
|
+
const assertion = `${unsigned}.${b64urlBytes(new Uint8Array(signature))}`;
|
|
161
|
+
|
|
162
|
+
const res = await fetch(key.token_uri || TOKEN_URL, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
165
|
+
body: new URLSearchParams({
|
|
166
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
167
|
+
assertion,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
const data = await res.json().catch(() => ({}));
|
|
171
|
+
if (!res.ok || !data.access_token) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Google non ha rilasciato il token: ${data.error_description || data.error || res.status}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return data.access_token;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* La chiave arriva come PEM PKCS#8 dentro il JSON. `JSON.parse` ha già trasformato le sequenze
|
|
180
|
+
* `\n` in a capo veri, quindi qui restano da togliere solo intestazione, coda e spaziatura. */
|
|
181
|
+
async function importPrivateKey(pem) {
|
|
182
|
+
const body = String(pem)
|
|
183
|
+
.replace(/-----BEGIN [^-]+-----/, "")
|
|
184
|
+
.replace(/-----END [^-]+-----/, "")
|
|
185
|
+
.replace(/\s+/g, "");
|
|
186
|
+
const bytes = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
|
|
187
|
+
return crypto.subtle.importKey(
|
|
188
|
+
"pkcs8",
|
|
189
|
+
bytes,
|
|
190
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
191
|
+
false,
|
|
192
|
+
["sign"],
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function b64url(text) {
|
|
197
|
+
return b64urlBytes(new TextEncoder().encode(text));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function b64urlBytes(bytes) {
|
|
201
|
+
let binary = "";
|
|
202
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
203
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ---- Lettura della risposta ------------------------------------------------------------- */
|
|
207
|
+
|
|
208
|
+
/* Con più `dateRanges`, GA4 aggiunge da sé una dimensione `dateRange` con valori
|
|
209
|
+
* `date_range_0` / `date_range_1`, e le righe NON arrivano in ordine garantito: si cerca
|
|
210
|
+
* quella giusta invece di prendere `rows[i]`. Se manca (periodo senza dati) si torna zeri, che
|
|
211
|
+
* è il caso di un sito appena messo online. */
|
|
212
|
+
function periodRow(response, index) {
|
|
213
|
+
const headers = (response?.metricHeaders || []).map((h) => h.name);
|
|
214
|
+
const want = `date_range_${index}`;
|
|
215
|
+
const row = (response?.rows || []).find(
|
|
216
|
+
(r) => (r.dimensionValues || []).some((d) => d.value === want),
|
|
217
|
+
) || (response?.rows || [])[index];
|
|
218
|
+
const out = {};
|
|
219
|
+
headers.forEach((name, i) => { out[name] = num(row?.metricValues?.[i]?.value); });
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function metric(value, prev) {
|
|
224
|
+
return { value: num(value), prev: Number.isFinite(prev) ? num(prev) : null };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* GA4 dà `20260728` (e `2026072814` a ore); il pannello legge il formato di Umami. Senza
|
|
228
|
+
* questa conversione il grafico si riempie di «Invalid Date» in silenzio — l'asse resta
|
|
229
|
+
* disegnato, e a mancare sono solo le etichette. */
|
|
230
|
+
function stamp(raw, unit) {
|
|
231
|
+
const v = String(raw || "");
|
|
232
|
+
const date = `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
|
|
233
|
+
const hour = unit === "hour" ? v.slice(8, 10) || "00" : "00";
|
|
234
|
+
return `${date} ${hour}:00:00`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function topList(response) {
|
|
238
|
+
return (response?.rows || []).map((row) => ({
|
|
239
|
+
name: String(dim(row, 0) || ""),
|
|
240
|
+
count: num(met(row, 0)),
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function names(...list) { return list.map((name) => ({ name })); }
|
|
245
|
+
function dim(row, i) { return row?.dimensionValues?.[i]?.value; }
|
|
246
|
+
function met(row, i) { return row?.metricValues?.[i]?.value; }
|
|
247
|
+
function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
|
|
248
|
+
function day(ms) { return new Date(ms).toISOString().slice(0, 10); }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* Driver Umami per la Panoramica.
|
|
2
|
+
*
|
|
3
|
+
* Un driver risponde a due domande e basta: «sei configurato?» e «dammi i numeri di questa
|
|
4
|
+
* finestra temporale». Il formato del risultato è quello che il pannello sa leggere, ed è lo
|
|
5
|
+
* stesso per tutti i driver (vedi `analytics-ga4.js`): chi aggiunge un servizio nuovo non
|
|
6
|
+
* tocca né l'endpoint né l'interfaccia.
|
|
7
|
+
*
|
|
8
|
+
* Configurazione:
|
|
9
|
+
* UMAMI_WEBSITE_ID var — l'id del sito dentro Umami (Settings → Websites)
|
|
10
|
+
* UMAMI_API_KEY secret Pages (Settings → API keys)
|
|
11
|
+
* UMAMI_API_URL opzionale. Assente = Umami Cloud. Un'istanza propria si dichiara qui
|
|
12
|
+
* (es. https://analytics.esempio.it/api) e cambia SOLO l'intestazione di
|
|
13
|
+
* autenticazione: il Cloud vuole `x-umami-api-key`, un'istanza propria un
|
|
14
|
+
* `Bearer`. Le rotte dei dati sono identiche.
|
|
15
|
+
*/
|
|
16
|
+
const CLOUD = "https://api.umami.is/v1";
|
|
17
|
+
|
|
18
|
+
/* Il fuso decide DOVE cade la mezzanotte, cioè quali visite finiscono nella colonna «oggi».
|
|
19
|
+
* Senza, Umami usa UTC e d'estate le due ore di sera scivolano nel giorno dopo. */
|
|
20
|
+
const TZ = "Europe/Rome";
|
|
21
|
+
|
|
22
|
+
export const id = "umami";
|
|
23
|
+
export const label = "Umami";
|
|
24
|
+
/* `secret: true` non è decorazione: è ciò che permette al pannello di dire «questa va in
|
|
25
|
+
wrangler.toml» e «questa si mette con `wrangler pages secret put`» senza conoscere i
|
|
26
|
+
servizi uno per uno. */
|
|
27
|
+
export const config = [
|
|
28
|
+
{ name: "UMAMI_WEBSITE_ID", secret: false },
|
|
29
|
+
{ name: "UMAMI_API_KEY", secret: true },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export function missing(env) {
|
|
33
|
+
return config.filter((c) => !env[c.name]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function collect(env, { startAt, endAt, unit }) {
|
|
37
|
+
const window = { startAt, endAt };
|
|
38
|
+
|
|
39
|
+
/* Le quattro chiamate insieme: sono indipendenti e in fila costerebbero la somma delle
|
|
40
|
+
* latenze di un servizio esterno. Se cade una sola delle tre di contorno la scheda mostra
|
|
41
|
+
* il resto — le visite in cima sono l'unica senza cui non c'è niente da vedere. */
|
|
42
|
+
const [totals, series, pages, referrers] = await Promise.all([
|
|
43
|
+
call(env, "/stats", window),
|
|
44
|
+
call(env, "/pageviews", { ...window, unit, timezone: TZ }),
|
|
45
|
+
call(env, "/metrics", { ...window, type: "url", limit: 6 }).catch(() => []),
|
|
46
|
+
call(env, "/metrics", { ...window, type: "referrer", limit: 6 }).catch(() => []),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
totals: {
|
|
51
|
+
pageviews: metric(totals?.pageviews),
|
|
52
|
+
visitors: metric(totals?.visitors),
|
|
53
|
+
visits: metric(totals?.visits),
|
|
54
|
+
bounces: metric(totals?.bounces),
|
|
55
|
+
totaltime: metric(totals?.totaltime),
|
|
56
|
+
},
|
|
57
|
+
series: mergeSeries(series),
|
|
58
|
+
pages: topList(pages),
|
|
59
|
+
referrers: topList(referrers).filter((r) => r.name),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function call(env, path, params) {
|
|
64
|
+
const base = String(env.UMAMI_API_URL || CLOUD).replace(/\/+$/, "");
|
|
65
|
+
const url = new URL(`${base}/websites/${encodeURIComponent(env.UMAMI_WEBSITE_ID)}${path}`);
|
|
66
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
67
|
+
|
|
68
|
+
const key = env.UMAMI_API_KEY;
|
|
69
|
+
const headers = base.startsWith(CLOUD)
|
|
70
|
+
? { "x-umami-api-key": key, Accept: "application/json" }
|
|
71
|
+
: { Authorization: `Bearer ${key}`, Accept: "application/json" };
|
|
72
|
+
|
|
73
|
+
const res = await fetch(url.toString(), { headers, cf: { cacheTtl: 0 } });
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
/* Il corpo di Umami finisce nel messaggio: un 401 e un «website not found» si curano in
|
|
76
|
+
* due modi diversi (chiave sbagliata contro id sbagliato) e distinguerli guardando solo
|
|
77
|
+
* il numero è impossibile. */
|
|
78
|
+
const body = (await res.text().catch(() => "")).slice(0, 200);
|
|
79
|
+
throw new Error(`Umami ha risposto ${res.status}${body ? `: ${body}` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
return res.json();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Umami restituisce `{ value, prev }` per ogni statistica, ma le versioni più vecchie (e le
|
|
85
|
+
* istanze proprie non aggiornate) danno un numero secco. Accettarli entrambi costa tre righe e
|
|
86
|
+
* evita una scheda vuota su un'istanza che risponde benissimo. */
|
|
87
|
+
function metric(raw) {
|
|
88
|
+
if (raw && typeof raw === "object") {
|
|
89
|
+
return { value: num(raw.value), prev: raw.prev === undefined ? null : num(raw.prev) };
|
|
90
|
+
}
|
|
91
|
+
return { value: num(raw), prev: null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Due serie parallele (viste e sessioni) con la stessa asse dei tempi, che però Umami manda
|
|
95
|
+
* separate e non garantisce allineate: un'ora senza sessioni manca da una delle due liste.
|
|
96
|
+
* Si uniscono per data, così il grafico non disegna due curve sfasate. */
|
|
97
|
+
function mergeSeries(raw) {
|
|
98
|
+
const byDate = new Map();
|
|
99
|
+
const put = (list, field) => {
|
|
100
|
+
for (const point of Array.isArray(list) ? list : []) {
|
|
101
|
+
const date = String(point?.x ?? "");
|
|
102
|
+
if (!date) continue;
|
|
103
|
+
const row = byDate.get(date) || { date, pageviews: 0, sessions: 0 };
|
|
104
|
+
row[field] = num(point?.y);
|
|
105
|
+
byDate.set(date, row);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
put(raw?.pageviews, "pageviews");
|
|
109
|
+
put(raw?.sessions, "sessions");
|
|
110
|
+
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function topList(raw) {
|
|
114
|
+
return (Array.isArray(raw) ? raw : [])
|
|
115
|
+
.map((row) => ({ name: String(row?.x ?? ""), count: num(row?.y) }))
|
|
116
|
+
.slice(0, 6);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* Helper condivisi per il media manager (R2 binding env.MEDIA). */
|
|
2
|
+
|
|
3
|
+
// prefisso cartella: vuoto (root) oppure segmenti che finiscono con "/"
|
|
4
|
+
export const PREFIX_RE = /^([A-Za-z0-9._-]+\/)+$/;
|
|
5
|
+
// chiave file: segmenti-cartella + nome file finale
|
|
6
|
+
export const KEY_RE = /^([A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/;
|
|
7
|
+
|
|
8
|
+
// blocca traversal (segmento ".." o "."), backslash, doppie slash
|
|
9
|
+
function unsafe(s) { return /(^|\/)\.\.?(\/|$)/.test(s) || s.includes("\\") || s.includes("//"); }
|
|
10
|
+
|
|
11
|
+
export function isPrefix(p) { return p === "" || (!unsafe(p) && PREFIX_RE.test(p)); }
|
|
12
|
+
export function isKey(k) { return !!k && !k.endsWith("/") && !unsafe(k) && KEY_RE.test(k); }
|
|
13
|
+
|
|
14
|
+
export function json(data, status = 200) {
|
|
15
|
+
return new Response(JSON.stringify(data), {
|
|
16
|
+
status, headers: { "Content-Type": "application/json" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Nome file sicuro: minuscolo, senza spazi/caratteri strani, estensione preservata.
|
|
21
|
+
export function sanitizeName(name) {
|
|
22
|
+
const raw = String(name || "file");
|
|
23
|
+
const dot = raw.lastIndexOf(".");
|
|
24
|
+
let base = dot > 0 ? raw.slice(0, dot) : raw;
|
|
25
|
+
let ext = dot > 0 ? raw.slice(dot + 1) : "";
|
|
26
|
+
base = base.toLowerCase().normalize("NFKD")
|
|
27
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "file";
|
|
28
|
+
ext = ext.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
29
|
+
return ext ? `${base}.${ext}` : base;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function guessMime(key) {
|
|
33
|
+
const e = (key.split(".").pop() || "").toLowerCase();
|
|
34
|
+
return {
|
|
35
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp",
|
|
36
|
+
gif: "image/gif", avif: "image/avif", svg: "image/svg+xml",
|
|
37
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf",
|
|
38
|
+
}[e] || "application/octet-stream";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Prefisso del cestino (soft-delete). La lifecycle rule R2 purga qui dopo 30 giorni.
|
|
42
|
+
export const TRASH = "_trash/";
|
|
43
|
+
|
|
44
|
+
// Elenca tutte le chiavi sotto un prefisso (paginato).
|
|
45
|
+
export async function listKeysUnder(bucket, prefix) {
|
|
46
|
+
const keys = [];
|
|
47
|
+
let cursor;
|
|
48
|
+
do {
|
|
49
|
+
const listed = await bucket.list({ prefix, cursor, limit: 1000 });
|
|
50
|
+
for (const o of (listed.objects || [])) keys.push(o.key);
|
|
51
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
52
|
+
} while (cursor);
|
|
53
|
+
return keys;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Soft-delete: sposta un oggetto nel cestino conservando chiave originale e data.
|
|
57
|
+
// (R2 non ha "move": copia + delete. La nuova data d'upload = quando entra nel cestino,
|
|
58
|
+
// così i 30 giorni della lifecycle partono da adesso, per-file.)
|
|
59
|
+
export async function moveToTrash(bucket, key) {
|
|
60
|
+
const obj = await bucket.get(key);
|
|
61
|
+
if (!obj) return null;
|
|
62
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
63
|
+
const trashKey = TRASH + id;
|
|
64
|
+
await bucket.put(trashKey, obj.body, {
|
|
65
|
+
httpMetadata: obj.httpMetadata || {},
|
|
66
|
+
customMetadata: {
|
|
67
|
+
...(obj.customMetadata || {}),
|
|
68
|
+
originalKey: key,
|
|
69
|
+
originalName: key.split("/").pop(),
|
|
70
|
+
deletedAt: new Date().toISOString(),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
await bucket.delete(key);
|
|
74
|
+
return trashKey;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Restituisce una chiave non ancora usata nel bucket (evita sovrascritture accidentali).
|
|
78
|
+
export async function uniqueKey(bucket, prefix, filename) {
|
|
79
|
+
const name = sanitizeName(filename);
|
|
80
|
+
const dot = name.lastIndexOf(".");
|
|
81
|
+
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
82
|
+
const ext = dot > 0 ? name.slice(dot) : "";
|
|
83
|
+
let candidate = prefix + name;
|
|
84
|
+
for (let n = 2; n <= 999; n++) {
|
|
85
|
+
const head = await bucket.head(candidate);
|
|
86
|
+
if (!head) return candidate;
|
|
87
|
+
candidate = `${prefix}${base}-${n}${ext}`;
|
|
88
|
+
}
|
|
89
|
+
return `${prefix}${base}-${Date.now()}${ext}`;
|
|
90
|
+
}
|