@cardinali-studio/adminkit 2.2.25 → 2.2.34
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 +19 -0
- package/dist/api.js +3 -0
- package/dist/app/App.js +22 -8
- package/dist/app/app.css +3 -3
- package/dist/app/delivery.d.ts +11 -0
- package/dist/app/delivery.js +46 -0
- package/dist/app/index.d.ts +4 -1
- package/dist/app/index.js +3 -2
- package/dist/app/lib/bookings.js +5 -0
- package/dist/app/lib/deliveries.d.ts +8 -0
- package/dist/app/lib/deliveries.js +7 -0
- package/dist/app/lib/delivery-network.d.ts +25 -0
- package/dist/app/lib/delivery-network.js +159 -0
- package/dist/app/lib/delivery-upload.d.ts +29 -11
- package/dist/app/lib/delivery-upload.js +408 -131
- package/dist/app/lib/home.js +7 -4
- package/dist/app/lib/pages.js +2 -1
- package/dist/app/lib/publish.d.ts +5 -0
- package/dist/app/lib/publish.js +61 -4
- package/dist/app/lib/requests.js +6 -0
- package/dist/app/lib/settings.js +7 -3
- package/dist/app/lib/upload-queue.d.ts +21 -3
- package/dist/app/lib/upload-queue.js +76 -19
- package/dist/app/routes/Deliveries.js +108 -29
- package/dist/app/routes/PageActions.d.ts +1 -1
- package/dist/app/routes/PageActions.js +5 -2
- package/dist/app/routes/PageEditor.js +6 -4
- package/dist/app/routes/PageSeoEditor.js +6 -1
- package/dist/app/routes/PagesList.js +5 -1
- package/dist/app/routes/UploadDock.js +6 -2
- package/dist/app/site.d.ts +13 -0
- package/dist/direct-publication.d.ts +20 -0
- package/dist/direct-publication.js +75 -0
- package/dist/functions/_shared/b2.js +22 -3
- package/dist/functions/_shared/delivery-crypto.js +5 -0
- package/dist/functions/_shared/delivery-limits.d.ts +9 -0
- package/dist/functions/_shared/delivery-limits.js +29 -0
- package/dist/functions/_shared/delivery-manifest-v2.sql +49 -0
- package/dist/functions/_shared/delivery-manifest.js +392 -0
- package/dist/functions/_shared/delivery-purge.js +17 -1
- package/dist/functions/api/deliveries.js +177 -49
- package/dist/functions/api/delivery/image.js +11 -6
- package/dist/functions/api/delivery/multipart.js +196 -64
- package/dist/functions/delivery-public/assets/client.js +246 -0
- package/dist/functions/delivery-public/assets/delivery.css +297 -0
- package/dist/functions/delivery-public/assets/gallery.js +243 -0
- package/dist/functions/delivery-public/assets/stage.js +107 -0
- package/dist/functions/delivery-public/index.js +637 -0
- package/dist/functions/delivery-public/migrations/0002_deliveries.sql +128 -0
- package/dist/functions/delivery-public/migrations/0003_delivery_quick_hero.sql +34 -0
- package/dist/functions/delivery-public/migrations/0004_delivery_file_expiry.sql +9 -0
- package/dist/functions/delivery-public/migrations/0006_delivery_manifest_v2.sql +49 -0
- package/dist/functions/delivery-public/migrations/0007_delivery_link_aliases.sql +8 -0
- package/dist/functions/delivery-public/migrations/0008_delivery_web_format.sql +2 -0
- package/dist/functions/delivery-public/migrations/0009_delivery_download_labels.sql +2 -0
- package/dist/functions/index.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +10 -3
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/* Consegna pubblica: /c/<token>.
|
|
2
|
+
*
|
|
3
|
+
* Il token casuale identifica un link D1. Il bucket B2 resta privato: gli ZIP
|
|
4
|
+
* completi usano un redirect breve verso una GET SigV4, mentre le foto della
|
|
5
|
+
* gallery passano dalla Function come Range del relativo ZIP.
|
|
6
|
+
* Gli oggetti ZIP sono STORE; gli offset nel manifest permettono di servire una
|
|
7
|
+
* singola fotografia con Range senza conservarne una seconda copia.
|
|
8
|
+
*
|
|
9
|
+
* La PAGINA non viene scritta qui. Il guscio lo genera il consumer (Astro, Hugo o altro generatore) (/_delivery/shell.html: testata,
|
|
10
|
+
* identità e CSS dedicato) e questa Function ci sostituisce dentro i segnaposto `%DC_*%`.
|
|
11
|
+
* Così la consegna resta una pagina del sito invece di una copia isolata dal build.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
b2AbortMultipart, b2Delete, b2Get, b2PresignGetObject,
|
|
16
|
+
} from "../_shared/b2.js";
|
|
17
|
+
import {
|
|
18
|
+
DELIVERY_LINK_MIN_LENGTH, sha256, signValue, unseal, verifyValue,
|
|
19
|
+
} from "../_shared/delivery-crypto.js";
|
|
20
|
+
|
|
21
|
+
import { readManifestEntry, readManifestPage } from "../_shared/delivery-manifest.js";
|
|
22
|
+
import { purgeDeliveryNow } from "../_shared/delivery-purge.js";
|
|
23
|
+
|
|
24
|
+
const COOKIE = "adminkit_delivery";
|
|
25
|
+
const HOUR = 3_600_000;
|
|
26
|
+
const DAY = 86_400_000;
|
|
27
|
+
const SHELL = "/delivery-shell/";
|
|
28
|
+
|
|
29
|
+
/* Il guscio cambia solo con un deploy, e un deploy crea isolate nuovi: tenerlo qui
|
|
30
|
+
significa una lettura sola per isolate invece di una per richiesta. */
|
|
31
|
+
let shellCache = null;
|
|
32
|
+
|
|
33
|
+
export async function onRequestGet(context) {
|
|
34
|
+
try { return await get(context); }
|
|
35
|
+
catch (e) {
|
|
36
|
+
console.error("delivery public get:", e);
|
|
37
|
+
return message(context, {
|
|
38
|
+
title: "Consegna non disponibile",
|
|
39
|
+
body: "Non è stato possibile aprire questa consegna. Riprova fra poco.",
|
|
40
|
+
}, 500);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function onRequestHead(context) {
|
|
45
|
+
const response = await onRequestGet(context);
|
|
46
|
+
return new Response(null, { status: response.status, headers: response.headers });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function onRequestPost(context) {
|
|
50
|
+
try { return await post(context); }
|
|
51
|
+
catch (e) {
|
|
52
|
+
console.error("delivery public post:", e);
|
|
53
|
+
return json({ ok: false, error: "Operazione non riuscita. Riprova." }, 500);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function get(context) {
|
|
58
|
+
const { request, env, params, waitUntil } = context;
|
|
59
|
+
if (!env.DB || !env.DELIVERY_SECRET) {
|
|
60
|
+
return message(context, {
|
|
61
|
+
title: "Consegna non disponibile",
|
|
62
|
+
body: "Il servizio non è ancora attivo. Riprova fra poco.",
|
|
63
|
+
}, 503);
|
|
64
|
+
}
|
|
65
|
+
const seg = segments(params.path);
|
|
66
|
+
const token = seg[0] || "";
|
|
67
|
+
if (!token || token.length < DELIVERY_LINK_MIN_LENGTH) {
|
|
68
|
+
return message(context, {
|
|
69
|
+
title: "Link non valido",
|
|
70
|
+
body: "Controlla di aver copiato tutto l’indirizzo, fino all’ultimo carattere.",
|
|
71
|
+
}, 404);
|
|
72
|
+
}
|
|
73
|
+
const access = await accessFor(env, request, token);
|
|
74
|
+
if (!access) {
|
|
75
|
+
return message(context, {
|
|
76
|
+
title: "Link non valido",
|
|
77
|
+
body: "Questa consegna non esiste più, oppure il link è stato disattivato.",
|
|
78
|
+
}, 404);
|
|
79
|
+
}
|
|
80
|
+
if (access.expired) {
|
|
81
|
+
return message(context, {
|
|
82
|
+
title: "Questa consegna è scaduta",
|
|
83
|
+
body: "I file non sono più disponibili da questo link. Contatta chi ti ha inviato il link per riceverne uno nuovo.",
|
|
84
|
+
}, 410);
|
|
85
|
+
}
|
|
86
|
+
if (access.filesExpired && typeof waitUntil === "function") {
|
|
87
|
+
waitUntil(purgeExpiredBytes(env, access.delivery.id).catch((error) =>
|
|
88
|
+
console.error("delivery expired purge:", error)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (seg.length === 1) {
|
|
92
|
+
/* I contatori raccontano il CLIENTE, non i robot: l'anteprima di link generata da
|
|
93
|
+
WhatsApp o iMessage fa un GET su questa pagina e gonfierebbe le aperture. */
|
|
94
|
+
if (request.method !== "HEAD" && looksHuman(request)) {
|
|
95
|
+
const now = new Date().toISOString();
|
|
96
|
+
await env.DB.batch([
|
|
97
|
+
env.DB.prepare("UPDATE delivery_links SET opened_count=opened_count+1,last_opened_at=? WHERE id=?")
|
|
98
|
+
.bind(now, access.link.id),
|
|
99
|
+
env.DB.prepare("INSERT INTO delivery_events(delivery_id,link_id,kind,created_at) VALUES (?,?,'open',?)")
|
|
100
|
+
.bind(access.delivery.id, access.link.id, now),
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
return deliveryPage(context, access, token);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!access.authorized) return json({ ok: false, error: "Codice richiesto" }, 401);
|
|
107
|
+
/* Sonda del transfer in preparazione: la pagina la interroga da sola invece di chiedere
|
|
108
|
+
al cliente di ricaricare. Non tocca nessun contatore. */
|
|
109
|
+
if (seg[1] === "state" && seg.length === 2) {
|
|
110
|
+
return json({ ok: true, ready: isReady(access) });
|
|
111
|
+
}
|
|
112
|
+
if (seg[1] === "images" && seg.length === 3) {
|
|
113
|
+
const pkg = access.packages.find(p => p.id === seg[2] && p.gallery_enabled);
|
|
114
|
+
if (!pkg) return json({ ok: false, error: "Pacchetto non disponibile" }, 404);
|
|
115
|
+
const after = Number(new URL(request.url).searchParams.get("after") ?? -1);
|
|
116
|
+
if (!Number.isSafeInteger(after) || after < -1) return json({ ok: false, error: "Cursore non valido" }, 400);
|
|
117
|
+
return json({ ok: true, ...(await publicImages(env, pkg, token, after)) });
|
|
118
|
+
}
|
|
119
|
+
if (seg[1] === "download" && seg.length === 4) {
|
|
120
|
+
return download(env, request, access, seg[2], seg[3]);
|
|
121
|
+
}
|
|
122
|
+
if (seg[1] === "image" && seg.length === 5) {
|
|
123
|
+
return image(env, request, access, seg[2], seg[3], Number(seg[4]));
|
|
124
|
+
}
|
|
125
|
+
if (seg[1] === "hero" && seg.length === 3) {
|
|
126
|
+
return hero(env, access, seg[2]);
|
|
127
|
+
}
|
|
128
|
+
return message(context, {
|
|
129
|
+
title: "File non trovato",
|
|
130
|
+
body: "Il file richiesto non appartiene a questa consegna.",
|
|
131
|
+
}, 404);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function post({ request, env, params }) {
|
|
135
|
+
if (!env.DB || !env.DELIVERY_SECRET) return json({ ok: false, error: "Servizio non attivo" }, 503);
|
|
136
|
+
const seg = segments(params.path);
|
|
137
|
+
const token = seg[0] || "";
|
|
138
|
+
const action = seg[1] || "";
|
|
139
|
+
const access = await accessFor(env, request, token);
|
|
140
|
+
if (!access || access.expired) return json({ ok: false, error: "Link non valido o scaduto" }, 410);
|
|
141
|
+
const body = await request.json().catch(() => ({}));
|
|
142
|
+
|
|
143
|
+
if (action === "unlock") return unlock(env, request, token, access, body);
|
|
144
|
+
if (action === "ticket") {
|
|
145
|
+
if (!access.authorized) return json({ ok: false, error: "Codice richiesto" }, 401);
|
|
146
|
+
if (access.filesExpired) {
|
|
147
|
+
return json({ ok: false, error: "I file di questa consegna sono scaduti" }, 410);
|
|
148
|
+
}
|
|
149
|
+
const packageId = String(body.packageId || "");
|
|
150
|
+
const variant = body.variant === "web" ? "web" : "originals";
|
|
151
|
+
const pkg = access.packages.find((p) => p.id === packageId);
|
|
152
|
+
if (!pkg || (variant === "web" && !pkg.web_key) ||
|
|
153
|
+
(variant === "originals" && (!pkg.originals_key || access.delivery.status === "archived"))) {
|
|
154
|
+
return json({ ok: false, error: "Questo download non è più disponibile" }, 404);
|
|
155
|
+
}
|
|
156
|
+
const ticket = await signValue(env.DELIVERY_SECRET, {
|
|
157
|
+
kind: "download", linkId: access.link.id, deliveryId: access.delivery.id,
|
|
158
|
+
packageId, variant,
|
|
159
|
+
}, 300);
|
|
160
|
+
const now = new Date().toISOString();
|
|
161
|
+
await env.DB.batch([
|
|
162
|
+
env.DB.prepare("UPDATE delivery_links SET download_count=download_count+1,last_download_at=? WHERE id=?")
|
|
163
|
+
.bind(now, access.link.id),
|
|
164
|
+
env.DB.prepare(`
|
|
165
|
+
INSERT INTO delivery_events(delivery_id,link_id,package_id,kind,variant,created_at)
|
|
166
|
+
VALUES (?,?,?,'download_started',?,?)
|
|
167
|
+
`).bind(access.delivery.id, access.link.id, packageId, variant, now),
|
|
168
|
+
]);
|
|
169
|
+
return json({
|
|
170
|
+
ok: true,
|
|
171
|
+
url: `/c/${encodeURIComponent(token)}/download/${encodeURIComponent(packageId)}/${variant}?ticket=${encodeURIComponent(ticket)}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return json({ ok: false, error: "Operazione non valida" }, 400);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function unlock(env, request, token, access, body) {
|
|
178
|
+
if (!access.link.pin_ciphertext) return json({ ok: true });
|
|
179
|
+
const ip = request.headers.get("CF-Connecting-IP") || "";
|
|
180
|
+
const ipHash = await sha256(`${ip}|${env.DELIVERY_SECRET}`);
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
await env.DB.prepare("DELETE FROM delivery_pin_throttle WHERE seen_at<?").bind(now - DAY).run();
|
|
183
|
+
const attempts = await env.DB.prepare(
|
|
184
|
+
"SELECT COUNT(*) n FROM delivery_pin_throttle WHERE link_id=? AND ip_hash=? AND seen_at>?"
|
|
185
|
+
).bind(access.link.id, ipHash, now - HOUR).first();
|
|
186
|
+
if (Number(attempts?.n || 0) >= 10) {
|
|
187
|
+
return json({ ok: false, error: "Troppi tentativi. Riprova fra un’ora." }, 429);
|
|
188
|
+
}
|
|
189
|
+
await env.DB.prepare("INSERT INTO delivery_pin_throttle(link_id,ip_hash,seen_at) VALUES (?,?,?)")
|
|
190
|
+
.bind(access.link.id, ipHash, now).run();
|
|
191
|
+
const expected = await unseal(env.DELIVERY_SECRET, access.link.pin_ciphertext);
|
|
192
|
+
if (!safeEqual(normalizePin(body.pin), normalizePin(expected))) {
|
|
193
|
+
return json({ ok: false, error: "Codice non corretto." }, 401);
|
|
194
|
+
}
|
|
195
|
+
const maxAge = Math.max(60, Math.min(6 * 3600, expirySeconds(access)));
|
|
196
|
+
const cookie = await signValue(env.DELIVERY_SECRET, {
|
|
197
|
+
kind: "delivery-session", linkId: access.link.id, deliveryId: access.delivery.id,
|
|
198
|
+
}, maxAge);
|
|
199
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
200
|
+
headers: {
|
|
201
|
+
"Content-Type": "application/json", "Cache-Control": "no-store",
|
|
202
|
+
"Set-Cookie": `${COOKIE}=${encodeURIComponent(cookie)}; Path=/c/${encodeURIComponent(token)}; ` +
|
|
203
|
+
`Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function accessFor(env, request, token) {
|
|
209
|
+
const tokenHash = await sha256(token);
|
|
210
|
+
const lookup = env.DB.prepare(`
|
|
211
|
+
SELECT
|
|
212
|
+
l.id link_id,l.delivery_id,l.pin_ciphertext,l.expires_at link_expires,l.revoked_at,
|
|
213
|
+
d.client_name,d.title,d.description,d.license_notes,d.cta_enabled,d.cta_label,d.cta_url,
|
|
214
|
+
d.expires_at delivery_expires,d.files_expires_at,d.status,d.hero_json
|
|
215
|
+
${env.DELIVERY_DOWNLOAD_LABELS === "true" ? ",d.download_originals_label,d.download_web_label" : ""}
|
|
216
|
+
FROM delivery_links l JOIN deliveries d ON d.id=l.delivery_id
|
|
217
|
+
WHERE l.token_hash=? AND l.revoked_at IS NULL AND d.status IN ('uploading','active','archived')
|
|
218
|
+
`);
|
|
219
|
+
let row = await lookup.bind(tokenHash).first();
|
|
220
|
+
if (!row && env.DELIVERY_LINK_ALIASES === "true") {
|
|
221
|
+
const alias = await env.DB.prepare(`
|
|
222
|
+
SELECT l.token_hash FROM delivery_link_aliases a
|
|
223
|
+
JOIN delivery_links l ON l.id=a.link_id WHERE a.token_hash=?
|
|
224
|
+
`).bind(tokenHash).first();
|
|
225
|
+
if (alias) row = await lookup.bind(alias.token_hash).first();
|
|
226
|
+
}
|
|
227
|
+
if (!row) return null;
|
|
228
|
+
const expiry = earliest(row.link_expires, row.delivery_expires);
|
|
229
|
+
const expired = !!expiry && new Date(expiry).getTime() < Date.now();
|
|
230
|
+
const packagesResult = await env.DB.prepare(`
|
|
231
|
+
SELECT * FROM delivery_packages WHERE delivery_id=? AND state='ready' ORDER BY position
|
|
232
|
+
`).bind(row.delivery_id).all();
|
|
233
|
+
let authorized = !row.pin_ciphertext;
|
|
234
|
+
if (!authorized) {
|
|
235
|
+
const cookie = getCookie(request, COOKIE);
|
|
236
|
+
const session = await verifyValue(env.DELIVERY_SECRET, cookie);
|
|
237
|
+
authorized = session?.kind === "delivery-session" &&
|
|
238
|
+
session.linkId === row.link_id && session.deliveryId === row.delivery_id;
|
|
239
|
+
}
|
|
240
|
+
const filesExpired = !!row.files_expires_at &&
|
|
241
|
+
new Date(row.files_expires_at).getTime() <= Date.now();
|
|
242
|
+
const packages = (filesExpired ? [] : (packagesResult.results || [])).map((p) => ({
|
|
243
|
+
...p,
|
|
244
|
+
}));
|
|
245
|
+
return {
|
|
246
|
+
expired, expiry, authorized,
|
|
247
|
+
link: {
|
|
248
|
+
id: row.link_id, delivery_id: row.delivery_id,
|
|
249
|
+
pin_ciphertext: row.pin_ciphertext, expires_at: row.link_expires,
|
|
250
|
+
},
|
|
251
|
+
delivery: {
|
|
252
|
+
id: row.delivery_id, clientName: row.client_name, title: row.title,
|
|
253
|
+
description: row.description || "", licenseNotes: row.license_notes || "",
|
|
254
|
+
ctaEnabled: !!row.cta_enabled, ctaLabel: row.cta_label || "",
|
|
255
|
+
downloadOriginalsLabel: row.download_originals_label || "Scarica i file",
|
|
256
|
+
downloadWebLabel: row.download_web_label || "Versione leggera",
|
|
257
|
+
ctaUrl: row.cta_url || "", expiresAt: row.delivery_expires,
|
|
258
|
+
filesExpiresAt: row.files_expires_at, status: row.status,
|
|
259
|
+
},
|
|
260
|
+
packages,
|
|
261
|
+
filesExpired,
|
|
262
|
+
protectedLink: !!row.pin_ciphertext,
|
|
263
|
+
heroes: await resolveHeroes(env, filesExpired ? "[]" : row.hero_json, packages),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* ---- la pagina ---------------------------------------------------------------------- */
|
|
268
|
+
|
|
269
|
+
async function publicImages(env, pkg, token, after = -1) {
|
|
270
|
+
const page = await readManifestPage(env, pkg, after);
|
|
271
|
+
return { images: page.entries.filter(row => row.entry.thumb).map(({ entry, index }) => ({
|
|
272
|
+
name: entry.name, width: entry.width, height: entry.height,
|
|
273
|
+
thumb: `/c/${encodeURIComponent(token)}/image/${encodeURIComponent(pkg.id)}/thumb/${index}`,
|
|
274
|
+
display: `/c/${encodeURIComponent(token)}/image/${encodeURIComponent(pkg.id)}/web/${index}`,
|
|
275
|
+
download: `/c/${encodeURIComponent(token)}/image/${encodeURIComponent(pkg.id)}/web/${index}?download=1`,
|
|
276
|
+
})), nextCursor: page.nextCursor };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function deliveryPage(context, access, token) {
|
|
280
|
+
const d = access.delivery;
|
|
281
|
+
const locked = !access.authorized;
|
|
282
|
+
const preparing = d.status === "uploading";
|
|
283
|
+
const heroes = access.heroes.map((item) => item.kind === "media"
|
|
284
|
+
? item.url
|
|
285
|
+
: item.kind === "object"
|
|
286
|
+
? `/c/${encodeURIComponent(token)}/hero/${encodeURIComponent(item.id)}`
|
|
287
|
+
: `/c/${encodeURIComponent(token)}/image/${encodeURIComponent(item.packageId)}/web/${item.index}`);
|
|
288
|
+
|
|
289
|
+
const data = locked ? {
|
|
290
|
+
state: "locked",
|
|
291
|
+
preparing, archived: false, filesExpired: access.filesExpired,
|
|
292
|
+
intro: "", notes: "", final: null,
|
|
293
|
+
expiry: { filesAt: d.filesExpiresAt || null, linkAt: access.expiry || null },
|
|
294
|
+
heroes: [], packages: [],
|
|
295
|
+
} : {
|
|
296
|
+
state: "ready",
|
|
297
|
+
preparing,
|
|
298
|
+
archived: d.status === "archived",
|
|
299
|
+
filesExpired: access.filesExpired,
|
|
300
|
+
intro: d.description,
|
|
301
|
+
notes: d.licenseNotes,
|
|
302
|
+
final: d.ctaEnabled && d.ctaUrl ? { label: d.ctaLabel, url: d.ctaUrl } : null,
|
|
303
|
+
expiry: { filesAt: d.filesExpiresAt || null, linkAt: access.expiry || null },
|
|
304
|
+
heroes,
|
|
305
|
+
downloadLabels: { originals: d.downloadOriginalsLabel, web: d.downloadWebLabel },
|
|
306
|
+
packages: await Promise.all(access.packages.map(async (p) => ({
|
|
307
|
+
id: p.id,
|
|
308
|
+
label: p.label,
|
|
309
|
+
fileCount: Number(p.file_count || 0),
|
|
310
|
+
bytes: Number(p.source_bytes || 0),
|
|
311
|
+
originals: d.status !== "archived" && !!p.originals_key,
|
|
312
|
+
light: !!p.web_enabled && !!p.web_key,
|
|
313
|
+
...(p.gallery_enabled ? await publicImages(context.env, p, token) : { images: [], nextCursor: null }),
|
|
314
|
+
}))),
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const name = d.clientName || d.title;
|
|
318
|
+
const work = d.clientName ? d.title : "";
|
|
319
|
+
/* L'anteprima del link (WhatsApp, iMessage) porta il nome del lavoro sempre, la FOTO solo
|
|
320
|
+
se la consegna non è protetta: un'immagine nei metadati la vede chiunque riceva o
|
|
321
|
+
inoltri il link, e i server che generano l'anteprima, senza passare dal codice. */
|
|
322
|
+
const ogImage = !access.protectedLink && heroes.length
|
|
323
|
+
? new URL(heroes[0], context.request.url).toString() : "";
|
|
324
|
+
|
|
325
|
+
return shellResponse(context, {
|
|
326
|
+
DC_TITLE: [name, work].filter(Boolean).join(" – ") + " — " + (context.env.DELIVERY_BRAND || "Transfer"),
|
|
327
|
+
DC_DESC: locked
|
|
328
|
+
? "Consegna file protetta da un codice di accesso."
|
|
329
|
+
: (d.clientName ? `File per ${d.clientName}` : "Consegna file"),
|
|
330
|
+
DC_IMAGE: ogImage,
|
|
331
|
+
DC_EYEBROW: "Consegna file",
|
|
332
|
+
DC_CLIENT: name,
|
|
333
|
+
DC_WORK: work,
|
|
334
|
+
}, data, 200);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function message(context, { title, body, eyebrow }, status) {
|
|
338
|
+
return shellResponse(context, {
|
|
339
|
+
DC_TITLE: `${title} — ${context.env.DELIVERY_BRAND || "Transfer"}`,
|
|
340
|
+
DC_DESC: body,
|
|
341
|
+
DC_IMAGE: "",
|
|
342
|
+
DC_EYEBROW: "",
|
|
343
|
+
DC_CLIENT: "",
|
|
344
|
+
DC_WORK: "",
|
|
345
|
+
}, {
|
|
346
|
+
state: "message",
|
|
347
|
+
message: { eyebrow: eyebrow || "Consegna file", title, body },
|
|
348
|
+
}, status);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function shellResponse(context, values, data, status) {
|
|
352
|
+
const shell = await shellHtml(context);
|
|
353
|
+
const encoded = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
354
|
+
const script = `<script id="dc-data" type="application/json">${encoded}</script>`;
|
|
355
|
+
const html = shell
|
|
356
|
+
? fill(shell, { ...values, DC_DATA: script })
|
|
357
|
+
: bareShell(values, script);
|
|
358
|
+
return new Response(html, { status, headers: securityHeaders("text/html; charset=utf-8") });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function shellHtml({ env, request }) {
|
|
362
|
+
if (shellCache) return shellCache;
|
|
363
|
+
const url = new URL(SHELL, request.url).toString();
|
|
364
|
+
let response = null;
|
|
365
|
+
try {
|
|
366
|
+
if (env.ASSETS?.fetch) response = await env.ASSETS.fetch(new Request(url));
|
|
367
|
+
if (!env.ASSETS?.fetch) response = await fetch(url);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
console.error("delivery shell:", e);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
if (!response?.ok) return null;
|
|
373
|
+
const text = await response.text();
|
|
374
|
+
if (!text.includes("%DC_DATA%")) return null;
|
|
375
|
+
shellCache = text;
|
|
376
|
+
return shellCache;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* Sostituzione in UN passaggio solo: ciò che viene inserito non viene riletto, quindi un
|
|
380
|
+
nome cliente che contenesse `%DC_...%` non può innescare una seconda sostituzione.
|
|
381
|
+
Senza immagine social, i due <meta> che la portano vengono tolti interi — li si trova
|
|
382
|
+
dal segnaposto, così non serve sapere come il minificatore ha virgolettato gli attributi. */
|
|
383
|
+
function fill(shell, values) {
|
|
384
|
+
const html = values.DC_IMAGE ? shell : shell.replace(/<meta[^>]*%DC_IMAGE%[^>]*>/g, "");
|
|
385
|
+
return html.replace(/%DC_([A-Z_]+)%/g, (whole, key) => {
|
|
386
|
+
const value = values[`DC_${key}`];
|
|
387
|
+
if (value === undefined) return "";
|
|
388
|
+
return key === "DATA" ? value : escapeHtml(value);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* Ripiego se il guscio non è raggiungibile (deploy a metà, asset mancante): la pagina resta
|
|
393
|
+
leggibile e i download funzionano, ma senza monogramma né menu. */
|
|
394
|
+
function bareShell(values, script) {
|
|
395
|
+
return `<!doctype html><html lang="it"><head><meta charset="utf-8">
|
|
396
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
397
|
+
<meta name="robots" content="noindex,nofollow,noarchive">
|
|
398
|
+
<title>${escapeHtml(values.DC_TITLE)}</title>
|
|
399
|
+
<link rel="stylesheet" href="/css/delivery.css">
|
|
400
|
+
</head><body class="is-delivery"><main>
|
|
401
|
+
<div class="dc" data-dc hidden><div class="dc-info">
|
|
402
|
+
<p class="dc-eyebrow">${escapeHtml(values.DC_EYEBROW)}</p>
|
|
403
|
+
<h1 class="dc-client">${escapeHtml(values.DC_CLIENT)}</h1>
|
|
404
|
+
<p class="dc-work">${escapeHtml(values.DC_WORK)}</p>
|
|
405
|
+
<p class="dc-intro" data-dc-intro hidden></p>
|
|
406
|
+
<div class="dc-notice" data-dc-notice role="status" hidden></div>
|
|
407
|
+
<form class="dc-gate" data-dc-gate hidden><label class="dc-label" for="dc-pin">Codice di accesso</label>
|
|
408
|
+
<div class="dc-gate-row"><input class="dc-gate-input" id="dc-pin" name="pin" type="text"
|
|
409
|
+
autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" readonly>
|
|
410
|
+
<button class="dc-btn dc-btn-solid" type="submit">Entra</button></div>
|
|
411
|
+
<p class="dc-error" data-dc-gate-error role="alert" hidden></p></form>
|
|
412
|
+
<div class="dc-downloads" data-dc-downloads hidden></div>
|
|
413
|
+
<p class="dc-expiry" data-dc-expiry hidden></p>
|
|
414
|
+
</div><div class="dc-stage" data-dc-stage aria-hidden="true"></div></div>
|
|
415
|
+
<section class="dc-gallery" data-dc-gallery hidden><p class="dc-label">Le fotografie</p>
|
|
416
|
+
<div class="dc-grid" data-dc-grid></div></section>
|
|
417
|
+
<section class="dc-close" data-dc-close hidden><div class="dc-notes" data-dc-notes hidden>
|
|
418
|
+
<p class="dc-label">Utilizzo</p><div class="dc-notes-body" data-dc-notes-body></div></div>
|
|
419
|
+
<a class="dc-btn dc-btn-line" data-dc-final target="_blank" rel="noopener noreferrer" hidden></a></section>
|
|
420
|
+
<section class="dc-msg" data-dc-msg hidden><p class="dc-eyebrow" data-dc-msg-eyebrow></p>
|
|
421
|
+
<h1 data-dc-msg-title></h1><p class="dc-msg-body" data-dc-msg-body></p>
|
|
422
|
+
<a class="dc-btn dc-btn-line" href="/">Torna alla pagina iniziale</a></section>
|
|
423
|
+
</main>
|
|
424
|
+
<script src="/js/delivery/client.js" defer></script>
|
|
425
|
+
<script src="/js/delivery/stage.js" defer></script>
|
|
426
|
+
<script src="/js/delivery/gallery.js" defer></script>
|
|
427
|
+
${script}
|
|
428
|
+
</body></html>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* ---- file ----------------------------------------------------------------------------- */
|
|
432
|
+
|
|
433
|
+
async function download(env, request, access, packageId, variant) {
|
|
434
|
+
const ticket = await verifyValue(env.DELIVERY_SECRET, new URL(request.url).searchParams.get("ticket") || "");
|
|
435
|
+
if (!ticket || ticket.kind !== "download" || ticket.linkId !== access.link.id ||
|
|
436
|
+
ticket.deliveryId !== access.delivery.id || ticket.packageId !== packageId ||
|
|
437
|
+
ticket.variant !== variant) {
|
|
438
|
+
return new Response("Download non autorizzato", { status: 401, headers: securityHeaders("text/plain; charset=utf-8") });
|
|
439
|
+
}
|
|
440
|
+
const pkg = access.packages.find((p) => p.id === packageId);
|
|
441
|
+
const key = variant === "web" ? pkg?.web_key : pkg?.originals_key;
|
|
442
|
+
if (!pkg || !key) {
|
|
443
|
+
return new Response("Non disponibile", { status: 404, headers: securityHeaders("text/plain; charset=utf-8") });
|
|
444
|
+
}
|
|
445
|
+
const filename = `${safeFilename(pkg.label)}${variant === "web" ? "-web" : ""}.zip`;
|
|
446
|
+
const direct = await b2PresignGetObject(env, key, {
|
|
447
|
+
contentDisposition: disposition(filename),
|
|
448
|
+
contentType: "application/zip",
|
|
449
|
+
expires: directDownloadSeconds(access),
|
|
450
|
+
});
|
|
451
|
+
return new Response(null, {
|
|
452
|
+
status: 302,
|
|
453
|
+
headers: {
|
|
454
|
+
"Location": direct,
|
|
455
|
+
"Cache-Control": "private, no-store",
|
|
456
|
+
"Referrer-Policy": "no-referrer",
|
|
457
|
+
"X-Robots-Tag": "noindex, nofollow, noarchive",
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function image(env, request, access, packageId, variant, index) {
|
|
463
|
+
const pkg = access.packages.find((p) => p.id === packageId);
|
|
464
|
+
const entry = pkg ? await readManifestEntry(env, pkg, index) : null;
|
|
465
|
+
if (!["thumb", "web", "original"].includes(variant) || (variant === "original" && access.delivery.status === "archived")) return new Response("Not found", { status: 404 });
|
|
466
|
+
const member = variant === "thumb" ? entry?.thumb : variant === "web" ? entry?.web : entry?.original;
|
|
467
|
+
const key = variant === "thumb" ? pkg?.thumbs_key : variant === "web" ? pkg?.web_key : pkg?.originals_key;
|
|
468
|
+
if (!pkg || !entry || !member || !key) return new Response("Not found", { status: 404 });
|
|
469
|
+
if (member.length === 0) return new Response(null, { headers: { "Content-Length": "0", "Cache-Control": "private, no-store" } });
|
|
470
|
+
const upstream = await b2Get(env, key, `bytes=${member.offset}-${member.offset + member.length - 1}`);
|
|
471
|
+
if (upstream.ok && (upstream.status !== 206 || upstream.headers.get("Content-Range")?.split("/")[0] !== `bytes ${member.offset}-${member.offset + member.length - 1}` || Number(upstream.headers.get("Content-Length")) !== member.length)) {
|
|
472
|
+
await upstream.body?.cancel();
|
|
473
|
+
return new Response("Risposta archivio non valida", { status: 502 });
|
|
474
|
+
}
|
|
475
|
+
if (!upstream.ok) return new Response("Not found", { status: upstream.status });
|
|
476
|
+
const headers = new Headers({
|
|
477
|
+
"Content-Type": variant === "original" ? (entry.type || "application/octet-stream") : variant === "web" && pkg.web_format === "jpg" ? "image/jpeg" : "image/webp",
|
|
478
|
+
"Content-Length": String(member.length),
|
|
479
|
+
"Cache-Control": "private, max-age=86400",
|
|
480
|
+
"X-Content-Type-Options": "nosniff",
|
|
481
|
+
});
|
|
482
|
+
if (new URL(request.url).searchParams.get("download") === "1") {
|
|
483
|
+
const name = variant === "original" ? entry.name.split("/").pop() : entry.name.replace(/\.[^.]+$/, "") + (variant === "web" && pkg.web_format === "jpg" ? ".jpg" : ".webp");
|
|
484
|
+
headers.set("Content-Disposition", disposition(name));
|
|
485
|
+
}
|
|
486
|
+
return new Response(upstream.body, { status: 200, headers });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function hero(env, access, id) {
|
|
490
|
+
const selected = access.heroes.find((item) => item.kind === "object" && item.id === id);
|
|
491
|
+
if (!selected) return new Response("Not found", { status: 404 });
|
|
492
|
+
const upstream = await b2Get(env, selected.objectKey);
|
|
493
|
+
if (!upstream.ok) return new Response("Not found", { status: upstream.status });
|
|
494
|
+
const headers = new Headers({
|
|
495
|
+
"Content-Type": "image/webp",
|
|
496
|
+
"Cache-Control": "private, max-age=86400",
|
|
497
|
+
"X-Content-Type-Options": "nosniff",
|
|
498
|
+
});
|
|
499
|
+
const length = upstream.headers.get("content-length") || (selected.bytes ? String(selected.bytes) : "");
|
|
500
|
+
if (length) headers.set("Content-Length", length);
|
|
501
|
+
return new Response(upstream.body, { headers });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function resolveHeroes(env, value, packages) {
|
|
505
|
+
const out = [];
|
|
506
|
+
for (const ref of parseArray(value).slice(0, 8)) {
|
|
507
|
+
if (ref?.type === "package") {
|
|
508
|
+
const pkg = packages.find((item) => item.id === String(ref.packageId || ""));
|
|
509
|
+
const index = Number(ref.index);
|
|
510
|
+
if (pkg && (await readManifestEntry(env, pkg, index))?.web && pkg.web_key) {
|
|
511
|
+
out.push({ kind: "package", packageId: pkg.id, index });
|
|
512
|
+
}
|
|
513
|
+
} else if (ref?.type === "object") {
|
|
514
|
+
const row = await env.DB.prepare(`
|
|
515
|
+
SELECT id,object_key,bytes FROM delivery_hero_objects
|
|
516
|
+
WHERE id=? AND state='ready'
|
|
517
|
+
`).bind(String(ref.id || "")).first();
|
|
518
|
+
if (row) out.push({
|
|
519
|
+
kind: "object", id: row.id, objectKey: row.object_key, bytes: Number(row.bytes || 0),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (out.length) return out;
|
|
524
|
+
const setting = await env.DB.prepare(
|
|
525
|
+
"SELECT value_json FROM delivery_settings WHERE key='default_heroes'"
|
|
526
|
+
).first();
|
|
527
|
+
return parseArray(setting?.value_json).filter((url) =>
|
|
528
|
+
typeof url === "string" && /^\/media\/[^?#]+$/.test(url)
|
|
529
|
+
).slice(0, 8).map((url) => ({ kind: "media", url }));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function purgeExpiredBytes(env, deliveryId) {
|
|
533
|
+
const now = new Date().toISOString();
|
|
534
|
+
await env.DB.batch([
|
|
535
|
+
env.DB.prepare(`
|
|
536
|
+
UPDATE delivery_packages SET state='deleted',deleted_at=?,purge_after=?,updated_at=?
|
|
537
|
+
WHERE delivery_id=? AND state!='deleted' AND EXISTS(SELECT 1 FROM deliveries WHERE id=delivery_id AND files_expires_at<=?)
|
|
538
|
+
`).bind(now, now, now, deliveryId, now),
|
|
539
|
+
env.DB.prepare(`
|
|
540
|
+
UPDATE delivery_hero_objects SET state='deleted',purge_after=?,updated_at=?
|
|
541
|
+
WHERE delivery_id=? AND state!='deleted' AND EXISTS(SELECT 1 FROM deliveries WHERE id=delivery_id AND files_expires_at<=?)
|
|
542
|
+
`).bind(now, now, deliveryId, now),
|
|
543
|
+
]);
|
|
544
|
+
await purgeDeliveryNow(env, deliveryId);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* ---- utilità -------------------------------------------------------------------------- */
|
|
548
|
+
|
|
549
|
+
function securityHeaders(contentType) {
|
|
550
|
+
return {
|
|
551
|
+
"Content-Type": contentType,
|
|
552
|
+
"Cache-Control": "private, no-store",
|
|
553
|
+
"X-Robots-Tag": "noindex, nofollow, noarchive",
|
|
554
|
+
"X-Content-Type-Options": "nosniff",
|
|
555
|
+
"Referrer-Policy": "no-referrer",
|
|
556
|
+
/* `style-src` ammette anche l'inline: il monogramma condiviso porta `style="--i:N"`
|
|
557
|
+
su ogni lettera e su ogni voce del menu (è l'indice dello stagger), e senza questo
|
|
558
|
+
il menu del sito qui si comporterebbe diversamente che altrove. Quella che conta —
|
|
559
|
+
`script-src 'self'` — resta stretta: in pagina non c'è un solo script inline. */
|
|
560
|
+
"Content-Security-Policy": "default-src 'self'; img-src 'self' data: blob:; " +
|
|
561
|
+
"style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'none'; " +
|
|
562
|
+
"frame-ancestors 'none'; form-action 'self'",
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function isReady(access) {
|
|
567
|
+
return access.delivery.status !== "uploading" && access.packages.length > 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/* Un browser vero manda `Sec-Fetch-Mode: navigate` quando apre una pagina; i generatori di
|
|
571
|
+
anteprima dei link non mandano gli header Sec-Fetch- del tutto. Chi non li manda passa
|
|
572
|
+
solo se non si dichiara robot. */
|
|
573
|
+
function looksHuman(request) {
|
|
574
|
+
const mode = request.headers.get("Sec-Fetch-Mode");
|
|
575
|
+
if (mode) return mode === "navigate";
|
|
576
|
+
const agent = request.headers.get("User-Agent") || "";
|
|
577
|
+
if (!agent) return false;
|
|
578
|
+
return !/bot|crawl|spider|preview|facebookexternalhit|whatsapp|telegram|slack|discord|twitter|linkedin|embed|curl|wget|python|node-fetch|go-http|okhttp|headless|monitor/i.test(agent);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function json(data, status = 200) {
|
|
582
|
+
return new Response(JSON.stringify(data), {
|
|
583
|
+
status, headers: { ...securityHeaders("application/json"), "Cache-Control": "no-store" },
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function segments(value) {
|
|
587
|
+
return (Array.isArray(value) ? value : String(value || "").split("/")).filter(Boolean).map(decodeURIComponent);
|
|
588
|
+
}
|
|
589
|
+
function parseManifest(value) {
|
|
590
|
+
try { return JSON.parse(value || "{}"); } catch { return { entries: [] }; }
|
|
591
|
+
}
|
|
592
|
+
function parseArray(value) {
|
|
593
|
+
try {
|
|
594
|
+
const parsed = JSON.parse(value || "[]");
|
|
595
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
596
|
+
} catch { return []; }
|
|
597
|
+
}
|
|
598
|
+
function earliest(a, b) {
|
|
599
|
+
if (!a) return b || null;
|
|
600
|
+
if (!b) return a;
|
|
601
|
+
return new Date(a) < new Date(b) ? a : b;
|
|
602
|
+
}
|
|
603
|
+
function expirySeconds(access) {
|
|
604
|
+
if (!access.expiry) return 6 * 3600;
|
|
605
|
+
return Math.floor((new Date(access.expiry).getTime() - Date.now()) / 1000);
|
|
606
|
+
}
|
|
607
|
+
function directDownloadSeconds(access) {
|
|
608
|
+
if (!access.expiry) return 24 * 3600;
|
|
609
|
+
return Math.max(60, Math.min(24 * 3600, expirySeconds(access)));
|
|
610
|
+
}
|
|
611
|
+
function getCookie(request, name) {
|
|
612
|
+
for (const part of (request.headers.get("Cookie") || "").split(/;\s*/)) {
|
|
613
|
+
const at = part.indexOf("=");
|
|
614
|
+
if (at > 0 && part.slice(0, at) === name) return decodeURIComponent(part.slice(at + 1));
|
|
615
|
+
}
|
|
616
|
+
return "";
|
|
617
|
+
}
|
|
618
|
+
function normalizePin(value) { return String(value || "").toUpperCase().replace(/[\s-]+/g, ""); }
|
|
619
|
+
function safeEqual(a, b) {
|
|
620
|
+
if (a.length !== b.length) return false;
|
|
621
|
+
let n = 0;
|
|
622
|
+
for (let i = 0; i < a.length; i++) n |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
623
|
+
return n === 0;
|
|
624
|
+
}
|
|
625
|
+
function disposition(name) {
|
|
626
|
+
const safe = String(name).replace(/["\\\r\n]/g, "_");
|
|
627
|
+
return `attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
628
|
+
}
|
|
629
|
+
function safeFilename(value) {
|
|
630
|
+
return String(value || "file").normalize("NFKD").replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
631
|
+
.replace(/^-+|-+$/g, "") || "file";
|
|
632
|
+
}
|
|
633
|
+
function escapeHtml(value) {
|
|
634
|
+
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
|
|
635
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
636
|
+
})[c]);
|
|
637
|
+
}
|