@cardinali-studio/adminkit 2.2.25 → 2.2.33

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 (42) hide show
  1. package/dist/app/app.css +3 -3
  2. package/dist/app/delivery.d.ts +11 -0
  3. package/dist/app/delivery.js +46 -0
  4. package/dist/app/index.d.ts +4 -1
  5. package/dist/app/index.js +3 -2
  6. package/dist/app/lib/deliveries.d.ts +8 -0
  7. package/dist/app/lib/delivery-network.d.ts +25 -0
  8. package/dist/app/lib/delivery-network.js +159 -0
  9. package/dist/app/lib/delivery-upload.d.ts +29 -11
  10. package/dist/app/lib/delivery-upload.js +408 -131
  11. package/dist/app/lib/upload-queue.d.ts +21 -3
  12. package/dist/app/lib/upload-queue.js +76 -19
  13. package/dist/app/routes/Deliveries.js +108 -29
  14. package/dist/app/routes/UploadDock.js +6 -2
  15. package/dist/direct-publication.d.ts +20 -0
  16. package/dist/direct-publication.js +75 -0
  17. package/dist/functions/_shared/b2.js +22 -3
  18. package/dist/functions/_shared/delivery-crypto.js +5 -0
  19. package/dist/functions/_shared/delivery-limits.d.ts +9 -0
  20. package/dist/functions/_shared/delivery-limits.js +29 -0
  21. package/dist/functions/_shared/delivery-manifest-v2.sql +49 -0
  22. package/dist/functions/_shared/delivery-manifest.js +392 -0
  23. package/dist/functions/_shared/delivery-purge.js +17 -1
  24. package/dist/functions/api/deliveries.js +177 -49
  25. package/dist/functions/api/delivery/image.js +11 -6
  26. package/dist/functions/api/delivery/multipart.js +196 -64
  27. package/dist/functions/delivery-public/assets/client.js +246 -0
  28. package/dist/functions/delivery-public/assets/delivery.css +297 -0
  29. package/dist/functions/delivery-public/assets/gallery.js +243 -0
  30. package/dist/functions/delivery-public/assets/stage.js +107 -0
  31. package/dist/functions/delivery-public/index.js +637 -0
  32. package/dist/functions/delivery-public/migrations/0002_deliveries.sql +128 -0
  33. package/dist/functions/delivery-public/migrations/0003_delivery_quick_hero.sql +34 -0
  34. package/dist/functions/delivery-public/migrations/0004_delivery_file_expiry.sql +9 -0
  35. package/dist/functions/delivery-public/migrations/0006_delivery_manifest_v2.sql +49 -0
  36. package/dist/functions/delivery-public/migrations/0007_delivery_link_aliases.sql +8 -0
  37. package/dist/functions/delivery-public/migrations/0008_delivery_web_format.sql +2 -0
  38. package/dist/functions/delivery-public/migrations/0009_delivery_download_labels.sql +2 -0
  39. package/dist/functions/index.js +3 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +1 -0
  42. package/package.json +10 -3
@@ -6,14 +6,20 @@
6
6
 
7
7
  import {
8
8
  b2AbortMultipart, b2CompleteMultipart, b2CreateMultipart, b2ListParts,
9
- b2ObjectKey, b2PresignPutObject, b2PresignUploadPart,
9
+ b2ObjectKey, b2PresignPutObject, b2PresignUploadPart, b2Head, multipartEtag,
10
10
  } from "../../_shared/b2.js";
11
11
 
12
+ import { validateDeliverySourceBytes, DELIVERY_PART_BYTES, DELIVERY_MAX_ARCHIVE_BYTES } from "../../_shared/delivery-limits.js";
13
+
14
+ import { manifestVersion, assertManifestWriter, assertCheckpointParts } from "../../_shared/delivery-manifest.js";
15
+
12
16
  const FILENAMES = {
13
17
  originals: "originals.zip",
14
18
  web: "web.zip",
15
19
  thumbs: "thumbs.zip",
16
20
  };
21
+ const stableOriginals = upload => upload.variant === 'originals' && manifestVersion(upload) === 2;
22
+ const confirmedDigest = value => /^[a-f0-9]{64}$/.test(value || '');
17
23
 
18
24
  export async function onRequestPost({ request, env }) {
19
25
  if (!env.DB) return json({ ok: false, error: "D1 non configurato" }, 500);
@@ -28,14 +34,14 @@ export async function onRequestPost({ request, env }) {
28
34
  case "heroPresign": return json({ ok: true, ...(await heroPresign(env, body)) });
29
35
  case "presign": return json({ ok: true, ...(await presign(env, body)) });
30
36
  case "record": return json({ ok: true, ...(await record(env, body)) });
31
- case "reconcile": return json({ ok: true, parts: await reconcile(env, body) });
37
+ case "reconcile": return json({ ok: true, ...(await reconcile(env, body)) });
32
38
  case "complete": return json({ ok: true, ...(await complete(env, body)) });
33
39
  case "abort": return json({ ok: true, ...(await abort(env, body)) });
34
40
  default: return json({ ok: false, error: "Azione multipart non valida" }, 400);
35
41
  }
36
42
  } catch (e) {
37
43
  console.error("delivery multipart:", e);
38
- return json({ ok: false, error: e instanceof Error ? e.message : String(e) }, 500);
44
+ return json({ ok: false, error: e instanceof Error ? e.message : String(e), retryable: e.retryable !== false }, e.status || 500);
39
45
  }
40
46
  }
41
47
 
@@ -64,26 +70,46 @@ async function heroPresign(env, body) {
64
70
 
65
71
  async function putPresign(env, body) {
66
72
  const pkg = await packageFor(env, body);
73
+ if (manifestVersion(pkg) === 2) throw invalid("Il formato v2 richiede multipart anche per archivi piccoli");
67
74
  const variant = String(body.variant || "");
68
- if (!FILENAMES[variant]) throw new Error("variante upload non valida");
75
+ if (!FILENAMES[variant]) throw invalid("variante upload non valida");
69
76
  if (variant === "web" && !pkg.web_enabled && !pkg.gallery_enabled) {
70
- throw new Error("versione web non richiesta");
77
+ throw invalid("versione web non richiesta");
71
78
  }
72
- if (variant === "thumbs" && !pkg.gallery_enabled) throw new Error("gallery non richiesta");
79
+ if (variant === "thumbs" && !pkg.gallery_enabled) throw invalid("gallery non richiesta");
73
80
  const key = b2ObjectKey(pkg.storage_prefix, FILENAMES[variant]);
81
+ // La chiave deve essere nota alla pulizia anche se PUT o putComplete perdono la risposta.
82
+ const now = new Date().toISOString();
83
+ await env.DB.prepare(`
84
+ INSERT INTO delivery_uploads
85
+ (id,delivery_id,package_id,variant,object_key,b2_upload_id,bytes,state,created_at,updated_at)
86
+ SELECT ?,?,?,?,?,'single',0,'uploading',?,?
87
+ WHERE NOT EXISTS (SELECT 1 FROM delivery_uploads WHERE package_id=? AND variant=?)
88
+ `).bind(crypto.randomUUID(), pkg.delivery_id, pkg.id, variant, key, now, now, pkg.id, variant).run();
89
+ const previous = await env.DB.prepare("SELECT * FROM delivery_uploads WHERE package_id=? AND variant=?")
90
+ .bind(pkg.id, variant).first();
91
+ if (previous?.b2_upload_id !== "single") throw invalid("La variante usa già un caricamento multipart");
74
92
  return { url: await b2PresignPutObject(env, key), key, variant, expiresIn: 900 };
75
93
  }
76
94
 
77
95
  async function putComplete(env, body) {
78
96
  const pkg = await packageFor(env, body);
97
+ if (manifestVersion(pkg) === 2) throw invalid("Il formato v2 richiede multipart");
79
98
  const variant = String(body.variant || "");
80
- if (!FILENAMES[variant]) throw new Error("variante upload non valida");
99
+ if (!FILENAMES[variant]) throw invalid("variante upload non valida");
81
100
  const key = b2ObjectKey(pkg.storage_prefix, FILENAMES[variant]);
101
+ const head = await b2Head(env, key);
102
+ const expectedBytes = Number(body.bytes);
103
+ if (expectedBytes > DELIVERY_PART_BYTES) throw invalid("Dimensione non valida per un caricamento singolo");
104
+ const etag = String(body.etag || "").replaceAll('"', "");
105
+ if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 1 || !head || head.bytes !== expectedBytes ||
106
+ (etag && head.etag !== etag)) throw invalid("L’oggetto caricato non corrisponde ai byte confermati");
82
107
  const id = crypto.randomUUID();
83
108
  const now = new Date().toISOString();
84
109
  const previous = await env.DB.prepare(
85
- "SELECT id FROM delivery_uploads WHERE package_id=? AND variant=?"
110
+ "SELECT * FROM delivery_uploads WHERE package_id=? AND variant=?"
86
111
  ).bind(pkg.id, variant).first();
112
+ if (previous?.b2_upload_id && previous.b2_upload_id !== "single") throw invalid("Variante multipart incompatibile");
87
113
  if (previous) {
88
114
  await env.DB.batch([
89
115
  env.DB.prepare("DELETE FROM delivery_upload_parts WHERE upload_id=?").bind(previous.id),
@@ -105,15 +131,16 @@ async function putComplete(env, body) {
105
131
  async function create(env, body) {
106
132
  const pkg = await packageFor(env, body);
107
133
  const variant = String(body.variant || "");
108
- if (!FILENAMES[variant]) throw new Error("variante multipart non valida");
134
+ if (!FILENAMES[variant]) throw invalid("variante multipart non valida");
109
135
  if (variant === "web" && !pkg.web_enabled && !pkg.gallery_enabled) {
110
- throw new Error("versione web non richiesta");
136
+ throw invalid("versione web non richiesta");
111
137
  }
112
- if (variant === "thumbs" && !pkg.gallery_enabled) throw new Error("gallery non richiesta");
138
+ if (variant === "thumbs" && !pkg.gallery_enabled) throw invalid("gallery non richiesta");
113
139
 
114
140
  const previous = await env.DB.prepare(
115
141
  "SELECT * FROM delivery_uploads WHERE package_id=? AND variant=?"
116
142
  ).bind(pkg.id, variant).first();
143
+ if (previous?.b2_upload_id === "single") throw invalid("La variante usa già un caricamento singolo");
117
144
  if (previous?.state === "uploading") {
118
145
  return normalizeUpload(previous);
119
146
  }
@@ -124,29 +151,46 @@ async function create(env, body) {
124
151
  const b2UploadId = await b2CreateMultipart(env, key);
125
152
  const now = new Date().toISOString();
126
153
  const id = previous?.id || crypto.randomUUID();
127
- if (previous) {
128
- await env.DB.batch([
129
- env.DB.prepare("DELETE FROM delivery_upload_parts WHERE upload_id=?").bind(id),
130
- env.DB.prepare(`
131
- UPDATE delivery_uploads SET object_key=?,b2_upload_id=?,state='uploading',
132
- created_at=?,updated_at=?,completed_at=NULL WHERE id=?
133
- `).bind(key, b2UploadId, now, now, id),
134
- ]);
135
- } else {
136
- await env.DB.prepare(`
137
- INSERT INTO delivery_uploads
138
- (id,delivery_id,package_id,variant,object_key,b2_upload_id,state,created_at,updated_at)
139
- VALUES (?,?,?,?,?,?,'uploading',?,?)
140
- `).bind(id, pkg.delivery_id, pkg.id, variant, key, b2UploadId, now, now).run();
154
+ try {
155
+ if (previous) {
156
+ await env.DB.batch([
157
+ env.DB.prepare("DELETE FROM delivery_upload_parts WHERE upload_id=? AND EXISTS (SELECT 1 FROM delivery_uploads WHERE id=? AND state='aborted')").bind(id, id),
158
+ env.DB.prepare(`UPDATE delivery_uploads SET object_key=?,b2_upload_id=?,state='uploading',
159
+ bytes=0,created_at=?,updated_at=?,completed_at=NULL WHERE id=? AND state='aborted'`)
160
+ .bind(key, b2UploadId, now, now, id),
161
+ ]);
162
+ } else {
163
+ await env.DB.prepare(`
164
+ INSERT INTO delivery_uploads
165
+ (id,delivery_id,package_id,variant,object_key,b2_upload_id,state,created_at,updated_at)
166
+ SELECT ?,?,?,?,?,?,'uploading',?,?
167
+ WHERE NOT EXISTS (SELECT 1 FROM delivery_uploads WHERE package_id=? AND variant=?)
168
+ `).bind(id, pkg.delivery_id, pkg.id, variant, key, b2UploadId, now, now, pkg.id, variant).run();
169
+ }
170
+ } catch (error) {
171
+ // Se la risposta D1 è incerta, non abortire una sessione effettivamente registrata.
172
+ const saved = await env.DB.prepare("SELECT * FROM delivery_uploads WHERE package_id=? AND variant=?")
173
+ .bind(pkg.id, variant).first();
174
+ if (saved?.b2_upload_id === b2UploadId) return normalizeUpload(saved);
175
+ await b2AbortMultipart(env, key, b2UploadId);
176
+ throw error;
141
177
  }
142
- return { id, deliveryId: pkg.delivery_id, packageId: pkg.id, variant, key, state: "uploading" };
178
+ const saved = await env.DB.prepare("SELECT * FROM delivery_uploads WHERE package_id=? AND variant=?")
179
+ .bind(pkg.id, variant).first();
180
+ if (saved?.b2_upload_id !== b2UploadId) await b2AbortMultipart(env, key, b2UploadId);
181
+ if (!saved) throw new Error("Sessione non registrata");
182
+ return normalizeUpload(saved);
143
183
  }
144
184
 
145
185
  async function presign(env, body) {
146
186
  const upload = await uploadFor(env, body);
147
187
  const partNumber = Number(body.partNumber);
148
188
  if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > 10_000) {
149
- throw new Error("numero parte non valido");
189
+ throw invalid("numero parte non valido");
190
+ }
191
+ if (stableOriginals(upload)) {
192
+ const part = await env.DB.prepare('SELECT digest FROM delivery_upload_parts WHERE upload_id=? AND part_number=?').bind(upload.id, partNumber).first();
193
+ if (confirmedDigest(part?.digest)) throw invalid('Parte originale già confermata: non può essere sovrascritta');
150
194
  }
151
195
  return {
152
196
  url: await b2PresignUploadPart(env, upload.object_key, upload.b2_upload_id, partNumber),
@@ -161,10 +205,29 @@ async function record(env, body) {
161
205
  const size = Number(body.size);
162
206
  const etag = String(body.etag || "").trim();
163
207
  const digest = String(body.digest || "").trim();
164
- if (!Number.isInteger(partNumber) || partNumber < 1 || !Number.isFinite(size) || size < 1 || !etag) {
165
- throw new Error("metadati parte non validi");
208
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > 10_000 || !Number.isSafeInteger(size) || size < 1 || size > DELIVERY_PART_BYTES || !etag) {
209
+ throw invalid("metadati parte non validi");
166
210
  }
167
211
  const now = new Date().toISOString();
212
+ if (stableOriginals(upload)) {
213
+ if (!confirmedDigest(digest)) throw invalid('Digest della parte originale non valido');
214
+ const matches = part => part?.etag === etag && part.size === size && part.digest === digest;
215
+ const query = () => env.DB.prepare('SELECT etag,size,digest FROM delivery_upload_parts WHERE upload_id=? AND part_number=?').bind(upload.id, partNumber).first();
216
+ const previous = await query();
217
+ if (matches(previous)) return { partNumber, etag, size };
218
+ if (confirmedDigest(previous?.digest)) throw invalid('Parte originale già confermata con contenuto diverso');
219
+ await env.DB.prepare(`INSERT INTO delivery_upload_parts(upload_id,part_number,etag,digest,size,created_at)
220
+ SELECT ?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM delivery_uploads u JOIN delivery_packages p ON p.id=u.package_id
221
+ JOIN delivery_manifest_state s ON s.package_id=p.id
222
+ WHERE u.id=? AND u.state='uploading' AND p.state='uploading' AND s.writer_token=?)
223
+ ON CONFLICT(upload_id,part_number) DO UPDATE SET digest=excluded.digest
224
+ WHERE (delivery_upload_parts.digest IS NULL OR delivery_upload_parts.digest='')
225
+ AND delivery_upload_parts.etag=excluded.etag AND delivery_upload_parts.size=excluded.size`)
226
+ .bind(upload.id, partNumber, etag, digest, size, now, upload.id, body.writerToken).run();
227
+ if (!matches(await query())) throw invalid('Parte originale cambiata o caricamento annullato');
228
+ await env.DB.prepare("UPDATE delivery_uploads SET updated_at=? WHERE id=? AND state='uploading'").bind(now, upload.id).run();
229
+ return { partNumber, etag, size };
230
+ }
168
231
  await env.DB.batch([
169
232
  env.DB.prepare(`
170
233
  INSERT INTO delivery_upload_parts(upload_id,part_number,etag,digest,size,created_at)
@@ -177,45 +240,106 @@ async function record(env, body) {
177
240
  return { partNumber, etag, size };
178
241
  }
179
242
 
243
+ async function savedParts(env, upload) {
244
+ const { results } = await env.DB.prepare(
245
+ "SELECT part_number,etag,digest,size FROM delivery_upload_parts WHERE upload_id=? ORDER BY part_number"
246
+ ).bind(upload.id).all();
247
+ return (results || []).map((p) => ({ partNumber: Number(p.part_number), etag: p.etag,
248
+ digest: p.digest || "", size: Number(p.size) }));
249
+ }
250
+
251
+ async function markCompleted(env, upload, bytes) {
252
+ const now = new Date().toISOString();
253
+ await env.DB.prepare(`UPDATE delivery_uploads SET state='completed',bytes=?,completed_at=?,updated_at=?
254
+ WHERE id=? AND state='uploading'
255
+ AND EXISTS (SELECT 1 FROM delivery_packages WHERE id=delivery_uploads.package_id AND state='uploading')`)
256
+ .bind(bytes, now, now, upload.id).run();
257
+ }
258
+
259
+ async function recoverCompleted(env, upload) {
260
+ const parts = await savedParts(env, upload);
261
+ if (!parts.length || parts.some((p, n) => p.partNumber !== n + 1)) return null;
262
+ if (stableOriginals(upload) && parts.some(p => !confirmedDigest(p.digest))) return null;
263
+ const bytes = parts.reduce((sum, p) => sum + p.size, 0);
264
+ const head = await b2Head(env, upload.object_key);
265
+ const etag = await multipartEtag(parts);
266
+ if (!head || !etag || head.bytes !== bytes || head.etag !== etag) return null;
267
+ await assertCheckpointParts(env, upload, parts);
268
+ await markCompleted(env, upload, bytes);
269
+ return { parts, completed: true, bytes };
270
+ }
271
+
180
272
  async function reconcile(env, body) {
181
- const upload = await uploadFor(env, body);
182
- const parts = await b2ListParts(env, upload.object_key, upload.b2_upload_id);
273
+ const upload = await uploadFor(env, body, true, true);
274
+ if (upload.state === "completed") return { parts: await savedParts(env, upload), completed: true, bytes: Number(upload.bytes) };
275
+ let parts;
276
+ try { parts = await b2ListParts(env, upload.object_key, upload.b2_upload_id); }
277
+ catch (error) {
278
+ if (error.status !== 404) throw error;
279
+ const recovered = await recoverCompleted(env, upload);
280
+ if (recovered) return recovered;
281
+ throw invalid("Sessione non disponibile e archivio non verificabile. Crea un nuovo caricamento.");
282
+ }
283
+ const previous = new Map((await savedParts(env, upload)).map((p) => [p.partNumber, p]));
183
284
  const now = new Date().toISOString();
184
- for (let at = 0; at < parts.length; at += 50) {
185
- await env.DB.batch(parts.slice(at, at + 50).map((part) => env.DB.prepare(`
285
+ const present = new Set(parts.map((p) => p.partNumber));
286
+ const missing = [...previous.values()].filter((p) => !present.has(p.partNumber));
287
+ const stable = stableOriginals(upload);
288
+ const remote = new Map(parts.map(p => [p.partNumber, p]));
289
+ const assertConfirmed = saved => {
290
+ if (stable && saved.some(p => confirmedDigest(p.digest) &&
291
+ (remote.get(p.partNumber)?.etag !== p.etag || remote.get(p.partNumber)?.size !== p.size))) {
292
+ throw invalid('Le parti originali confermate non corrispondono a Backblaze');
293
+ }
294
+ };
295
+ assertConfirmed([...previous.values()]);
296
+ for (let at = 0; at < missing.length; at += 50) {
297
+ await env.DB.batch(missing.slice(at, at + 50).map((part) => env.DB.prepare(
298
+ `DELETE FROM delivery_upload_parts WHERE upload_id=? AND part_number=? AND etag=?
299
+ ${stable ? "AND (digest IS NULL OR digest='')" : ''}`
300
+ ).bind(upload.id, part.partNumber, part.etag)));
301
+ }
302
+ const changed = parts.filter((p) => previous.get(p.partNumber)?.etag !== p.etag || previous.get(p.partNumber)?.size !== p.size);
303
+ for (let at = 0; at < changed.length; at += 50) {
304
+ await env.DB.batch(changed.slice(at, at + 50).map((part) => env.DB.prepare(`
186
305
  INSERT INTO delivery_upload_parts(upload_id,part_number,etag,size,created_at)
187
306
  VALUES (?,?,?,?,?)
188
307
  ON CONFLICT(upload_id,part_number) DO UPDATE SET
189
- etag=excluded.etag,size=excluded.size,created_at=excluded.created_at
308
+ digest=NULL,etag=excluded.etag,size=excluded.size,created_at=excluded.created_at
309
+ ${stable ? "WHERE delivery_upload_parts.digest IS NULL OR delivery_upload_parts.digest=''" : ''}
190
310
  `).bind(upload.id, part.partNumber, part.etag, part.size, now)));
191
311
  }
192
- const saved = await env.DB.prepare(
193
- "SELECT part_number,etag,digest,size FROM delivery_upload_parts WHERE upload_id=? ORDER BY part_number"
194
- ).bind(upload.id).all();
195
- return (saved.results || []).map((part) => ({
196
- partNumber: Number(part.part_number), etag: part.etag,
197
- digest: part.digest || "", size: Number(part.size),
198
- }));
312
+ if (stable) {
313
+ // A record may have committed while ListParts was in flight. Never clear its
314
+ // digest using the older snapshot, and never silently accept a remote change.
315
+ const saved = await savedParts(env, upload);
316
+ assertConfirmed(saved);
317
+ const current = new Map(saved.map(p => [p.partNumber, p]));
318
+ return { parts: parts.map(p => ({ ...p, digest:
319
+ current.get(p.partNumber)?.etag === p.etag && current.get(p.partNumber)?.size === p.size
320
+ ? current.get(p.partNumber).digest : '' })), completed: false };
321
+ }
322
+ // Solo le parti remote sono autorevoli; le righe locali obsolete non entrano nel completamento.
323
+ return { parts: parts.map((p) => ({ ...p, digest:
324
+ previous.get(p.partNumber)?.etag === p.etag && previous.get(p.partNumber)?.size === p.size
325
+ ? previous.get(p.partNumber).digest : "" })), completed: false };
199
326
  }
200
327
 
201
328
  async function complete(env, body) {
202
- const upload = await uploadFor(env, body);
203
- /* ListParts è la fonte autorevole: una PUT può riuscire e la successiva chiamata
204
- `record` interrompersi. Riconciliare sempre evita di lasciare fuori quella parte. */
205
- await reconcile(env, body);
206
- const { results } = await env.DB.prepare(
207
- "SELECT part_number,etag,size FROM delivery_upload_parts WHERE upload_id=? ORDER BY part_number"
208
- ).bind(upload.id).all();
209
- if (!results?.length) throw new Error("nessuna parte caricata");
210
- const parts = results.map((p) => ({
211
- partNumber: Number(p.part_number), etag: p.etag, size: Number(p.size),
212
- }));
213
- const answer = await b2CompleteMultipart(env, upload.object_key, upload.b2_upload_id, parts);
214
- const now = new Date().toISOString();
215
- await env.DB.prepare(
216
- "UPDATE delivery_uploads SET state='completed',bytes=?,completed_at=?,updated_at=? WHERE id=?"
217
- ).bind(parts.reduce((n, p) => n + p.size, 0), now, now, upload.id).run();
218
- return { uploadId: upload.id, key: upload.object_key, bytes: parts.reduce((n, p) => n + p.size, 0), ...answer };
329
+ if (!Number.isSafeInteger(body.bytes) || body.bytes < 1 || body.bytes > DELIVERY_MAX_ARCHIVE_BYTES) throw invalid("Dimensione archivio non valida");
330
+ const upload = await uploadFor(env, body, true, true);
331
+ const reconciled = await reconcile(env, body);
332
+ const parts = reconciled.parts;
333
+ const bytes = parts.reduce((n, p) => n + p.size, 0);
334
+ if (!parts.length || parts.some((p, n) => p.partNumber !== n + 1) ||
335
+ (body.partCount != null && Number(body.partCount) !== parts.length) ||
336
+ (body.bytes != null && Number(body.bytes) !== bytes)) throw invalid("L’archivio è incompleto: riprendi il caricamento");
337
+ if (reconciled.completed) return { uploadId: upload.id, key: upload.object_key, bytes };
338
+ if (stableOriginals(upload) && parts.some(p => !confirmedDigest(p.digest))) throw invalid('Parti originali non ancora confermate');
339
+ await assertCheckpointParts(env, upload, parts);
340
+ await b2CompleteMultipart(env, upload.object_key, upload.b2_upload_id, parts);
341
+ await markCompleted(env, upload, bytes);
342
+ return { uploadId: upload.id, key: upload.object_key, bytes };
219
343
  }
220
344
 
221
345
  async function abort(env, body) {
@@ -232,28 +356,36 @@ async function packageFor(env, body) {
232
356
  SELECT p.* FROM delivery_packages p JOIN deliveries d ON d.id=p.delivery_id
233
357
  WHERE p.id=? AND p.delivery_id=? AND p.state='uploading' AND d.status!='deleted'
234
358
  `).bind(String(body.packageId || ""), String(body.id || "")).first();
235
- if (!pkg) throw new Error("pacchetto in caricamento non trovato");
359
+ if (!pkg) throw invalid("pacchetto in caricamento non trovato");
360
+ validateDeliverySourceBytes(pkg.source_bytes ?? 0);
361
+ if (manifestVersion(pkg) === 2) await assertManifestWriter(env, pkg.id, body.writerToken);
236
362
  return pkg;
237
363
  }
238
364
 
239
- async function uploadFor(env, body, required = true) {
365
+ async function uploadFor(env, body, required = true, allowCompleted = false) {
240
366
  const upload = await env.DB.prepare(`
241
- SELECT u.* FROM delivery_uploads u
367
+ SELECT u.*,p.manifest_json,p.source_bytes declared_source_bytes FROM delivery_uploads u
242
368
  JOIN delivery_packages p ON p.id=u.package_id
243
369
  JOIN deliveries d ON d.id=u.delivery_id
244
- WHERE u.id=? AND u.state='uploading' AND p.state='uploading' AND d.status!='deleted'
370
+ WHERE u.id=? AND u.state IN (${allowCompleted ? "'uploading','completed'" : "'uploading'"})
371
+ AND p.state IN (${allowCompleted ? "'uploading','ready'" : "'uploading'"}) AND d.status!='deleted'
245
372
  `).bind(String(body.uploadId || "")).first();
246
- if (!upload && required) throw new Error("sessione multipart non trovata");
373
+ if (!upload && required) throw invalid("sessione multipart non trovata");
374
+ // L'annullamento deve restare disponibile anche per vecchie sessioni fuori limite.
375
+ if (upload && body.action !== "abort") validateDeliverySourceBytes(upload.declared_source_bytes ?? 0);
376
+ if (upload && manifestVersion(upload) === 2 && body.action !== "abort") await assertManifestWriter(env, upload.package_id, body.writerToken);
247
377
  return upload;
248
378
  }
249
379
 
250
380
  function normalizeUpload(row) {
251
381
  return {
252
382
  id: row.id, deliveryId: row.delivery_id, packageId: row.package_id,
253
- variant: row.variant, key: row.object_key, state: row.state,
383
+ variant: row.variant, key: row.object_key, state: row.state, bytes: Number(row.bytes || 0),
254
384
  };
255
385
  }
256
386
 
387
+ function invalid(message) { return Object.assign(new Error(message), { status: 409, retryable: false }); }
388
+
257
389
  function json(data, status = 200) {
258
390
  return new Response(JSON.stringify(data), {
259
391
  status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
@@ -0,0 +1,246 @@
1
+ /* Pagina di consegna — il guscio.
2
+ *
3
+ * Il guscio HTML lo genera Hugo (layouts/index.deliveryshell.html) e la Function
4
+ * functions/c/[[path]].js ci inietta i dati come <script type="application/json">. Qui si
5
+ * accende ciò che serve e si spegne il resto: codice di accesso, download, scadenze,
6
+ * messaggi. Le fotografie le montano stage.js (la colonna destra) e gallery.js (sotto):
7
+ * girano DOPO questo file — gli script `defer` eseguono nell'ordine del documento — e
8
+ * leggono `window.DC.data`, che viene messo qui.
9
+ *
10
+ * Niente stato locale, niente cronologia: dopo lo sblocco si ricarica. È il server a
11
+ * decidere cosa può essere visto, e ricaricare è l'unico modo per chiederglielo davvero.
12
+ */
13
+ (function () {
14
+ "use strict";
15
+ var source = document.getElementById("dc-data");
16
+ var data = null;
17
+ try { data = JSON.parse((source && source.textContent) || "null"); } catch (e) { data = null; }
18
+ if (!data) data = { state: "message", message: { title: "Consegna non disponibile", body: "Riprova fra poco." } };
19
+ window.DC = { data: data, base: location.pathname.replace(/\/+$/, "") };
20
+
21
+ var el = {
22
+ shell: document.querySelector("[data-dc]"),
23
+ intro: document.querySelector("[data-dc-intro]"),
24
+ notice: document.querySelector("[data-dc-notice]"),
25
+ gate: document.querySelector("[data-dc-gate]"),
26
+ gateError: document.querySelector("[data-dc-gate-error]"),
27
+ downloads: document.querySelector("[data-dc-downloads]"),
28
+ expiry: document.querySelector("[data-dc-expiry]"),
29
+ close: document.querySelector("[data-dc-close]"),
30
+ notes: document.querySelector("[data-dc-notes]"),
31
+ notesBody: document.querySelector("[data-dc-notes-body]"),
32
+ final: document.querySelector("[data-dc-final]"),
33
+ msg: document.querySelector("[data-dc-msg]"),
34
+ msgEyebrow: document.querySelector("[data-dc-msg-eyebrow]"),
35
+ msgTitle: document.querySelector("[data-dc-msg-title]"),
36
+ msgBody: document.querySelector("[data-dc-msg-body]")
37
+ };
38
+
39
+ if (data.state === "message") renderMessage(data.message || {});
40
+ else renderDelivery();
41
+
42
+ // Il monogramma è montato con `manual`: si accende adesso, quando la pagina ha i suoi
43
+ // contenuti. `dark` vale sempre: questa pagina è nera dappertutto, quindi a menu aperto
44
+ // (blend sospeso) le lettere devono restare bianche.
45
+ if (window.BrandMenu) {
46
+ window.BrandMenu.config({ dark: function () { return true; } });
47
+ window.BrandMenu.show();
48
+ }
49
+
50
+ /* ---- messaggio a tutta pagina ------------------------------------------------------ */
51
+ function renderMessage(message) {
52
+ document.body.classList.add("is-message");
53
+ show(el.msg);
54
+ text(el.msgEyebrow, message.eyebrow || "Consegna file");
55
+ text(el.msgTitle, message.title || "Consegna non disponibile");
56
+ text(el.msgBody, message.body || "");
57
+ }
58
+
59
+ /* ---- la consegna ------------------------------------------------------------------- */
60
+ function renderDelivery() {
61
+ show(el.shell);
62
+
63
+ if (data.intro) { text(el.intro, data.intro); show(el.intro); }
64
+ // La chiusura (note d'uso + recensione) esiste solo se ha qualcosa da dire.
65
+ if (data.notes) { text(el.notesBody, data.notes); show(el.notes); show(el.close); }
66
+ if (data.final && data.final.url) {
67
+ el.final.href = data.final.url;
68
+ text(el.final, data.final.label || "Lascia una recensione");
69
+ show(el.final); show(el.close);
70
+ }
71
+
72
+ var notice = noticeText();
73
+ if (notice) { text(el.notice, notice); show(el.notice); }
74
+ renderExpiry();
75
+
76
+ if (data.state === "locked") {
77
+ // Prima dello sblocco non c'è nessuna fotografia da mostrare: la pagina torna a una
78
+ // colonna sola invece di lasciare metà schermo vuoto (il CSS fa il resto).
79
+ document.body.classList.add("is-locked");
80
+ setupGate();
81
+ return;
82
+ }
83
+ renderDownloads();
84
+ if (data.preparing) pollUntilReady();
85
+ }
86
+
87
+ function noticeText() {
88
+ if (data.filesExpired) {
89
+ return "I file di questa consegna hanno superato la data di scadenza e non sono più " +
90
+ "scaricabili. Scrivi a Emanuele Cardinali se ti servono ancora.";
91
+ }
92
+ if (data.preparing) {
93
+ return "I file stanno arrivando. Il link è già valido: questa pagina si " +
94
+ "aggiorna da sola appena sono pronte.";
95
+ }
96
+ if (data.archived) {
97
+ return "Gli originali sono stati archiviati per liberare spazio. Scrivi a Emanuele Cardinali " +
98
+ "per richiederne il ripristino.";
99
+ }
100
+ return "";
101
+ }
102
+
103
+ function renderExpiry() {
104
+ var when = data.expiry || {};
105
+ if (!when.filesAt && !when.linkAt) return;
106
+ if (when.filesAt && !data.filesExpired) {
107
+ var days = (new Date(when.filesAt).getTime() - Date.now()) / 86400000;
108
+ text(el.expiry, "Scaricabili fino al " + formatDate(when.filesAt) +
109
+ (days <= 7 ? " · ultimi giorni" : ""));
110
+ if (days <= 7) el.expiry.classList.add("is-soon");
111
+ } else if (when.filesAt) {
112
+ text(el.expiry, "File scaduti il " + formatDate(when.filesAt));
113
+ } else {
114
+ text(el.expiry, "Link valido fino al " + formatDate(when.linkAt));
115
+ }
116
+ show(el.expiry);
117
+ }
118
+
119
+ /* ---- codice di accesso -------------------------------------------------------------- */
120
+ function setupGate() {
121
+ show(el.gate);
122
+ var input = el.gate.querySelector(".dc-gate-input");
123
+ var button = el.gate.querySelector("button");
124
+ /* Il campo parte `readonly` (vedi il commento nel guscio): l'autofill non tocca un
125
+ campo di sola lettura, così non può rimandare un codice vecchio al posto di quello
126
+ appena digitato. Diventa scrivibile — e vuoto — appena lo si tocca. */
127
+ var wake = function () { input.value = ""; input.removeAttribute("readonly"); };
128
+ input.addEventListener("focus", wake);
129
+ input.addEventListener("pointerdown", wake);
130
+
131
+ el.gate.addEventListener("submit", function (event) {
132
+ event.preventDefault();
133
+ var label = button.textContent;
134
+ button.disabled = true; button.textContent = "Verifico…";
135
+ hide(el.gateError);
136
+ post("/unlock", { pin: input.value }).then(function (out) {
137
+ if (out.ok) { location.reload(); return; }
138
+ text(el.gateError, out.body.error || "Codice non corretto.");
139
+ show(el.gateError);
140
+ button.disabled = false; button.textContent = label;
141
+ input.select();
142
+ });
143
+ });
144
+ }
145
+
146
+ /* ---- download ---------------------------------------------------------------------- */
147
+ function renderDownloads() {
148
+ var labels = data.downloadLabels || {};
149
+ var packages = (data.packages || []).filter(function (pkg) {
150
+ return pkg.originals || pkg.light; // una scheda senza niente da premere non si disegna
151
+ });
152
+ if (!packages.length) return;
153
+ packages.forEach(function (pkg) {
154
+ var box = document.createElement("div");
155
+ box.className = "dc-pack";
156
+ if (packages.length > 1 && pkg.label) {
157
+ var name = document.createElement("p");
158
+ name.className = "dc-pack-name";
159
+ name.textContent = pkg.label;
160
+ box.appendChild(name);
161
+ }
162
+ var actions = document.createElement("div");
163
+ actions.className = "dc-pack-actions";
164
+ // Gli originali sono ciò per cui il cliente è qui: sono il pulsante pieno. La
165
+ // versione leggera diventa primaria solo se gli originali non ci sono più.
166
+ if (pkg.originals) actions.appendChild(button(pkg, "originals", labels.originals || "Scarica i file", true));
167
+ if (pkg.light) actions.appendChild(button(pkg, "web", labels.web || "Versione leggera", !pkg.originals));
168
+ box.appendChild(actions);
169
+
170
+ var note = document.createElement("p");
171
+ note.className = "dc-dl-note";
172
+ note.textContent = [
173
+ pkg.fileCount ? pkg.fileCount + " file" : "",
174
+ pkg.bytes ? bytes(pkg.bytes) : ""
175
+ ].filter(Boolean).join(" · ");
176
+ if (note.textContent) box.appendChild(note);
177
+
178
+ el.downloads.appendChild(box);
179
+ });
180
+ show(el.downloads);
181
+ }
182
+
183
+ function button(pkg, variant, label, primary) {
184
+ var node = document.createElement("button");
185
+ node.type = "button";
186
+ node.className = "dc-btn " + (primary ? "dc-btn-solid" : "dc-btn-line");
187
+ node.textContent = label;
188
+ node.addEventListener("click", function () {
189
+ var box = node.closest(".dc-pack");
190
+ var previous = box.querySelector(".dc-error");
191
+ if (previous) previous.remove();
192
+ node.disabled = true; node.textContent = "Preparo…";
193
+ post("/ticket", { packageId: pkg.id, variant: variant }).then(function (out) {
194
+ if (out.ok && out.body.url) { location.href = out.body.url; return; }
195
+ var error = document.createElement("p");
196
+ error.className = "dc-error";
197
+ error.textContent = out.body.error || "Non è stato possibile avviare il download.";
198
+ box.appendChild(error);
199
+ }).then(function () {
200
+ node.disabled = false; node.textContent = label;
201
+ });
202
+ });
203
+ return node;
204
+ }
205
+
206
+ /* ---- transfer in preparazione ------------------------------------------------------- */
207
+ /* Il link è valido prima che i file siano finiti: invece di chiedere al cliente di
208
+ ricaricare, si chiede al server. La sonda non incrementa nessun contatore. */
209
+ function pollUntilReady() {
210
+ var timer = setInterval(check, 20000);
211
+ document.addEventListener("visibilitychange", function () { if (!document.hidden) check(); });
212
+ function check() {
213
+ if (document.hidden) return;
214
+ fetch(window.DC.base + "/state", { credentials: "same-origin", cache: "no-store" })
215
+ .then(function (res) { return res.ok ? res.json() : null; })
216
+ .then(function (out) { if (out && out.ready) { clearInterval(timer); location.reload(); } })
217
+ .catch(function () {});
218
+ }
219
+ }
220
+
221
+ /* ---- utilità ------------------------------------------------------------------------ */
222
+ function post(path, body) {
223
+ return fetch(window.DC.base + path, {
224
+ method: "POST", headers: { "Content-Type": "application/json" },
225
+ credentials: "same-origin", body: JSON.stringify(body)
226
+ }).then(function (res) {
227
+ return res.json().catch(function () { return {}; }).then(function (parsed) {
228
+ return { ok: res.ok && parsed.ok !== false, body: parsed };
229
+ });
230
+ }).catch(function () {
231
+ return { ok: false, body: { error: "Connessione assente. Riprova." } };
232
+ });
233
+ }
234
+ function show(node) { if (node) node.hidden = false; }
235
+ function hide(node) { if (node) node.hidden = true; }
236
+ function text(node, value) { if (node) node.textContent = value; }
237
+ function bytes(value) {
238
+ var units = ["B", "KB", "MB", "GB", "TB"], n = Number(value) || 0, i = 0;
239
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
240
+ return (n < 10 && i ? n.toFixed(1) : Math.round(n)) + " " + units[i];
241
+ }
242
+ function formatDate(value) {
243
+ return new Intl.DateTimeFormat("it-IT", { day: "numeric", month: "long", year: "numeric" })
244
+ .format(new Date(value));
245
+ }
246
+ })();