@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
@@ -12,7 +12,8 @@
12
12
  * nudo, con le sole `env.DB` e le variabili B2.
13
13
  */
14
14
 
15
- import { b2AbortMultipart, b2Delete } from "./b2.js";
15
+ import { manifestVersion } from "./delivery-manifest.js";
16
+ import { b2AbortMultipart, b2Delete, b2ObjectKey } from "./b2.js";
16
17
 
17
18
  const DAY = 86_400_000;
18
19
 
@@ -42,8 +43,23 @@ async function purgePackages(env, rows, calls) {
42
43
  await b2AbortMultipart(env, upload.object_key, upload.b2_upload_id);
43
44
  }
44
45
  }
46
+ // Una PUT firmata prima dell’annullamento può ancora terminare: conserva la tombstone.
47
+ if (pkg.purge_after && pkg.purge_after > new Date().toISOString()) return false;
48
+ if (manifestVersion(pkg) === 2) {
49
+ const pages = await env.DB.prepare("SELECT page_number,object_key FROM delivery_manifest_objects WHERE package_id=? LIMIT ?")
50
+ .bind(pkg.id, Math.max(0, calls.left)).all();
51
+ for (const page of pages.results || []) {
52
+ calls.left -= 1;
53
+ await b2Delete(env, page.object_key);
54
+ await env.DB.prepare("DELETE FROM delivery_manifest_objects WHERE package_id=? AND page_number=?")
55
+ .bind(pkg.id, page.page_number).run();
56
+ }
57
+ const remaining = await env.DB.prepare("SELECT 1 n FROM delivery_manifest_objects WHERE package_id=? LIMIT 1").bind(pkg.id).first();
58
+ if (remaining || calls.left <= 0) return false;
59
+ }
45
60
  const keys = new Set([
46
61
  pkg.originals_key, pkg.web_key, pkg.thumbs_key, pkg.manifest_key,
62
+ ...["originals.zip", "web.zip", "thumbs.zip", "manifest.json"].map((name) => b2ObjectKey(pkg.storage_prefix, name)),
47
63
  ...(uploads.results || []).map((upload) => upload.object_key),
48
64
  ].filter(Boolean));
49
65
  for (const key of keys) {
@@ -1,19 +1,22 @@
1
1
  /* CRUD immediato delle consegne.
2
2
  *
3
3
  * Sta sotto /admin/: Access + verifica JWT sono già applicati dal middleware.
4
- * Nessun salvataggio tocca Git. Le operazioni sui byte sono versionate e i delete
5
- * cancellano subito, nella stessa richiesta: non c'è un cestino dei transfer, e
6
- * rimandare non serviva a nessuno. La tombstone D1 sopravvive solo il tempo del
7
- * lavoro su B2, come punto di ripresa se una chiamata fallisce.
4
+ * Nessun salvataggio tocca Git. Le operazioni sui byte sono versionate. I delete
5
+ * nascondono subito i pacchetti; quelli ancora in upload conservano una tombstone
6
+ * per un'ora, così la pulizia può raccogliere scritture tardive. La tombstone resta
7
+ * anche il punto di ripresa se una chiamata a B2 fallisce.
8
8
  */
9
9
 
10
10
  import { b2ObjectKey, b2PutSmall } from "../_shared/b2.js";
11
- import { randomToken, seal, sha256, unseal } from "../_shared/delivery-crypto.js";
11
+ import { validateDeliverySourceBytes, validateDeliveryManifestV1 } from "../_shared/delivery-limits.js";
12
+ import { DELIVERY_LINK_MIN_LENGTH, DELIVERY_LINK_TOKEN_BYTES, randomToken, seal, sha256, unseal } from "../_shared/delivery-crypto.js";
12
13
  /* Il lavoro di cancellazione sta in _shared perché lo condivide con il cron:
13
14
  * workers/delivery-housekeeping. Qui si usa nelle due direzioni — subito dopo
14
15
  * una delete, e in coda a una GET per finire ciò che si fosse fermato. */
15
16
  import { purgeDeliveryNow, purgeDue, purgePackageNow } from "../_shared/delivery-purge.js";
16
17
 
18
+ import { claimManifestWriter, manifestStatus, manifestDraftPage, saveManifestCheckpoint, manifestVersion, readManifestEntry, readManifestPage, ingestManifest, finalizeManifestStep, v2Enabled, MANIFEST_MAX_ENTRIES } from "../_shared/delivery-manifest.js";
19
+
17
20
  const DAY = 86_400_000;
18
21
 
19
22
  export async function onRequestGet({ request, env, waitUntil }) {
@@ -23,6 +26,7 @@ export async function onRequestGet({ request, env, waitUntil }) {
23
26
  }
24
27
  const id = new URL(request.url).searchParams.get("id");
25
28
  try {
29
+ if (new URL(request.url).searchParams.get("capabilities") === "1") return json({ ok: true, manifestVersion: v2Enabled(env) ? 2 : 1, jpegEnabled: env.DELIVERY_JPEG_ENABLED === "true", downloadLabelsEnabled: env.DELIVERY_DOWNLOAD_LABELS === "true" });
26
30
  if (id) {
27
31
  const delivery = await deliveryDetail(env, id, new URL(request.url).origin);
28
32
  return delivery ? json({ ok: true, delivery }) : json({ ok: false, error: "Transfer non trovato" }, 404);
@@ -57,6 +61,7 @@ export async function onRequestPost({ request, env }) {
57
61
  const action = String(body?.action || "");
58
62
  try {
59
63
  switch (action) {
64
+ case "capabilities": return json({ ok: true, manifestVersion: v2Enabled(env) ? 2 : 1, jpegEnabled: env.DELIVERY_JPEG_ENABLED === "true", downloadLabelsEnabled: env.DELIVERY_DOWNLOAD_LABELS === "true" });
60
65
  case "create": return json({ ok: true, delivery: await createDelivery(env, body, new URL(request.url).origin) });
61
66
  case "quickCreate": return json({ ok: true, delivery: await createQuickDelivery(env, body, new URL(request.url).origin) });
62
67
  case "update": return json({ ok: true, delivery: await updateDelivery(env, body, new URL(request.url).origin) });
@@ -66,35 +71,50 @@ export async function onRequestPost({ request, env }) {
66
71
  case "duplicate": return json({ ok: true, delivery: await duplicateDelivery(env, body, new URL(request.url).origin) });
67
72
  case "delete": return json({ ok: true, ...(await deleteDelivery(env, body)) });
68
73
  case "createPackage": return json({ ok: true, package: await createPackage(env, body) });
74
+ case "manifestClaim": return json({ ok: true, ...(await claimManifestWriter(env, body)) });
75
+ case "manifestStatus": return json({ ok: true, ...(await manifestStatus(env, body)) });
76
+ case "manifestDraftPage": return json({ ok: true, ...(await manifestDraftPage(env, body)) });
77
+ case "manifestCheckpoint": return json({ ok: true, ...(await saveManifestCheckpoint(env, body)) });
78
+ case "manifestBatch": return json({ ok: true, ...(await ingestManifest(env, body)) });
79
+ case "manifestFinalize": return json({ ok: true, ...(await finalizeManifestStep(env, body)) });
80
+ case "manifestPage": {
81
+ const pkg = await env.DB.prepare("SELECT * FROM delivery_packages WHERE id=? AND delivery_id=? AND state='ready'").bind(body.packageId, body.id).first();
82
+ if (!pkg) throw new InputError("Pacchetto non disponibile");
83
+ const page = await readManifestPage(env, pkg, body.after ?? -1);
84
+ return json({ ok: true, images: page.entries.map(({ entry, index }) => packageImage(pkg.id, entry, index)), nextCursor: page.nextCursor });
85
+ }
69
86
  case "finalizePackage": return json({ ok: true, package: await finalizePackage(env, body) });
70
87
  case "deletePackage": return json({ ok: true, ...(await deletePackage(env, body)) });
71
88
  case "commitHeroes": return json({ ok: true, delivery: await commitHeroes(env, body, new URL(request.url).origin) });
72
89
  case "saveSettings": return json({ ok: true, settings: await saveSettings(env, body) });
73
90
  case "createLink": return json({ ok: true, link: await createLink(env, body, new URL(request.url).origin) });
91
+ case "shortenLink": return json({ ok: true, link: await shortenLink(env, body, new URL(request.url).origin) });
74
92
  case "updateLink": return json({ ok: true, link: await updateLink(env, body, new URL(request.url).origin) });
75
93
  case "revokeLink": return json({ ok: true, ...(await revokeLink(env, body)) });
76
94
  default: return json({ ok: false, error: "Azione non valida" }, 400);
77
95
  }
78
96
  } catch (e) {
79
97
  console.error(`deliveries ${action}:`, e);
80
- const status = e instanceof InputError ? 400 : e instanceof ConflictError ? 409 : 500;
81
- return json({ ok: false, error: message(e), code: status === 409 ? "conflict" : "invalid" }, status);
98
+ const status = ["delivery_size_limit", "delivery_manifest_limit", "delivery_manifest"].includes(e.code) ? e.status : e instanceof InputError ? 400 : e instanceof ConflictError ? 409 : 500;
99
+ return json({ ok: false, error: message(e), code: e.code || (status === 409 ? "conflict" : "invalid"), retryable: status >= 500 }, status);
82
100
  }
83
101
  }
84
102
 
85
103
  async function createQuickDelivery(env, body, origin) {
104
+ const labels = downloadLabelFields(env, body);
86
105
  const title = required(body.title, "Il nome del transfer è obbligatorio");
87
106
  const id = crypto.randomUUID();
88
107
  const now = new Date().toISOString();
89
108
  await env.DB.prepare(`
90
109
  INSERT INTO deliveries
91
- (id,client_name,title,files_expires_at,status,created_at,updated_at)
92
- VALUES (?,'',?,?,'uploading',?,?)
93
- `).bind(id, title, nullableDate(body.filesExpiresAt), now, now).run();
110
+ (id,client_name,title,files_expires_at,status,created_at,updated_at${labels.columns.map(c => ',' + c).join('')})
111
+ VALUES (?,'',?,?,'uploading',?,?${labels.columns.map(() => ',?').join('')})
112
+ `).bind(id, title, nullableDate(body.filesExpiresAt), now, now, ...labels.values).run();
94
113
  return deliveryDetail(env, id, origin);
95
114
  }
96
115
 
97
116
  async function createDelivery(env, body, origin) {
117
+ const labels = downloadLabelFields(env, body);
98
118
  const client = required(body.clientName, "Il cliente è obbligatorio");
99
119
  const title = required(body.title, "Il transfer ha bisogno di un nome");
100
120
  const id = crypto.randomUUID();
@@ -102,18 +122,19 @@ async function createDelivery(env, body, origin) {
102
122
  await env.DB.prepare(`
103
123
  INSERT INTO deliveries
104
124
  (id,client_name,title,description,license_notes,cta_enabled,cta_label,cta_url,
105
- expires_at,files_expires_at,status,created_at,updated_at)
106
- VALUES (?,?,?,?,?,?,?,?,?,?,'draft',?,?)
125
+ expires_at,files_expires_at,status,created_at,updated_at${labels.columns.map(c => ',' + c).join('')})
126
+ VALUES (?,?,?,?,?,?,?,?,?,?,'draft',?,?${labels.columns.map(() => ',?').join('')})
107
127
  `).bind(
108
128
  id, client, title, text(body.description), text(body.licenseNotes),
109
129
  bool(body.ctaEnabled), text(body.ctaLabel) || "Lascia una recensione",
110
130
  optionalHttpUrl(body.ctaUrl), nullableDate(body.expiresAt),
111
- nullableDate(body.filesExpiresAt), now, now,
131
+ nullableDate(body.filesExpiresAt), now, now, ...labels.values,
112
132
  ).run();
113
133
  return deliveryDetail(env, id, origin);
114
134
  }
115
135
 
116
136
  async function updateDelivery(env, body, origin) {
137
+ const labels = downloadLabelFields(env, body);
117
138
  const id = required(body.id, "id del transfer mancante");
118
139
  const current = await env.DB.prepare("SELECT version,status FROM deliveries WHERE id=? AND status!='deleted'")
119
140
  .bind(id).first();
@@ -127,13 +148,13 @@ async function updateDelivery(env, body, origin) {
127
148
  UPDATE deliveries SET
128
149
  client_name=?, title=?, description=?, license_notes=?,
129
150
  cta_enabled=?, cta_label=?, cta_url=?, expires_at=?, files_expires_at=?,
130
- version=version+1, updated_at=?
151
+ version=version+1, updated_at=?${labels.columns.map(c => ',' + c + '=?').join('')}
131
152
  WHERE id=? AND status!='deleted'
132
153
  `).bind(
133
154
  client, title, text(body.description), text(body.licenseNotes),
134
155
  bool(body.ctaEnabled), text(body.ctaLabel) || "Lascia una recensione",
135
156
  optionalHttpUrl(body.ctaUrl), nullableDate(body.expiresAt), nullableDate(body.filesExpiresAt),
136
- new Date().toISOString(), id,
157
+ new Date().toISOString(), ...labels.values, id,
137
158
  ).run();
138
159
  if (body.syncPackageLabel) {
139
160
  await env.DB.prepare(`
@@ -148,6 +169,12 @@ async function updateDelivery(env, body, origin) {
148
169
 
149
170
  async function activateDelivery(env, body, origin) {
150
171
  const id = required(body.id, "id del transfer mancante");
172
+ const packageId = body.packageId ? String(body.packageId) : null;
173
+ if (packageId) {
174
+ const pkg = await env.DB.prepare("SELECT id FROM delivery_packages WHERE id=? AND delivery_id=? AND state='ready'")
175
+ .bind(packageId, id).first();
176
+ if (!pkg) throw new InputError("Caricamento annullato o non ancora pronto");
177
+ }
151
178
  const ready = await env.DB.prepare(
152
179
  "SELECT COUNT(*) n FROM delivery_packages WHERE delivery_id=? AND state='ready' AND originals_key IS NOT NULL"
153
180
  ).bind(id).first();
@@ -156,8 +183,9 @@ async function activateDelivery(env, body, origin) {
156
183
  await env.DB.batch([
157
184
  env.DB.prepare(`
158
185
  UPDATE deliveries SET status='active', activated_at=COALESCE(activated_at,?),
159
- updated_at=?, version=version+1 WHERE id=? AND status!='deleted'
160
- `).bind(now, now, id),
186
+ updated_at=?, version=version+1 WHERE id=? AND status NOT IN ('deleted','active')
187
+ AND (? IS NULL OR EXISTS (SELECT 1 FROM delivery_packages WHERE id=? AND delivery_id=deliveries.id AND state='ready'))
188
+ `).bind(now, now, id, packageId, packageId),
161
189
  env.DB.prepare(`
162
190
  UPDATE delivery_packages SET originals_purge_after=NULL
163
191
  WHERE delivery_id=? AND originals_key IS NOT NULL
@@ -166,7 +194,7 @@ async function activateDelivery(env, body, origin) {
166
194
  const link = await env.DB.prepare(
167
195
  "SELECT id FROM delivery_links WHERE delivery_id=? AND revoked_at IS NULL LIMIT 1"
168
196
  ).bind(id).first();
169
- if (!link) await createLink(env, { id, label: "Link principale" }, origin);
197
+ if (!link) await createLink(env, { id, label: "Link principale", ensurePrimary: true }, origin);
170
198
  return deliveryDetail(env, id, origin);
171
199
  }
172
200
 
@@ -220,6 +248,8 @@ async function duplicateDelivery(env, body, origin) {
220
248
  ctaEnabled: !!source.cta_enabled,
221
249
  ctaLabel: source.cta_label,
222
250
  ctaUrl: source.cta_url,
251
+ downloadOriginalsLabel: source.download_originals_label,
252
+ downloadWebLabel: source.download_web_label,
223
253
  expiresAt: source.expires_at,
224
254
  filesExpiresAt: source.files_expires_at,
225
255
  }, origin);
@@ -239,8 +269,8 @@ async function deleteDelivery(env, body) {
239
269
  .bind(now, now, now, id),
240
270
  env.DB.prepare("UPDATE delivery_links SET revoked_at=COALESCE(revoked_at,?) WHERE delivery_id=?")
241
271
  .bind(now, id),
242
- env.DB.prepare("UPDATE delivery_packages SET state='deleted',deleted_at=?,purge_after=? WHERE delivery_id=? AND state!='deleted'")
243
- .bind(now, now, id),
272
+ env.DB.prepare("UPDATE delivery_packages SET state='deleted',deleted_at=?,purge_after=CASE WHEN state='uploading' THEN ? ELSE ? END WHERE delivery_id=? AND state!='deleted'")
273
+ .bind(now, new Date(Date.now() + 60 * 60_000).toISOString(), now, id),
244
274
  env.DB.prepare("UPDATE delivery_hero_objects SET state='deleted',purge_after=?,updated_at=? WHERE delivery_id=? AND state!='deleted'")
245
275
  .bind(now, now, id),
246
276
  ]);
@@ -248,6 +278,14 @@ async function deleteDelivery(env, body) {
248
278
  }
249
279
 
250
280
  async function createPackage(env, body) {
281
+ if (body.webFormat != null && !["webp", "jpg"].includes(body.webFormat)) throw new InputError("Formato della versione leggera non valido");
282
+ const webFormat = bool(body.webEnabled) && body.webFormat === "jpg" ? "jpg" : "webp";
283
+ if (webFormat === "jpg" && env.DELIVERY_JPEG_ENABLED !== "true") throw new InputError("La versione JPG non è ancora abilitata su questo sito");
284
+ const version = body.manifestVersion === 2 ? 2 : 1;
285
+ if (version === 2 && !v2Enabled(env)) throw new ConflictError("Il formato v2 non è ancora abilitato su questo sito");
286
+ if (version === 2 && body.fileCount > MANIFEST_MAX_ENTRIES) throw new InputError("Troppi file in un singolo invio");
287
+ const sourceBytes = validateDeliverySourceBytes(body.sourceBytes);
288
+ if (!Number.isSafeInteger(body.fileCount) || body.fileCount < 1) throw new InputError("Numero dei file non valido");
251
289
  const deliveryId = required(body.id, "id del transfer mancante");
252
290
  const delivery = await env.DB.prepare("SELECT id,status FROM deliveries WHERE id=? AND status!='deleted'")
253
291
  .bind(deliveryId).first();
@@ -268,10 +306,16 @@ async function createPackage(env, body) {
268
306
  id, deliveryId, text(body.label) || "Fotografie", Number(pos?.n || 0),
269
307
  bool(body.galleryEnabled), bool(body.webEnabled), prefix,
270
308
  required(body.sourceFingerprint, "impronta dei file mancante"),
271
- Number(body.fileCount || 0), Number(body.sourceBytes || 0), now, now,
309
+ body.fileCount, sourceBytes, now, now,
272
310
  ),
311
+ ...(webFormat === "jpg" ? [env.DB.prepare("UPDATE delivery_packages SET web_format='jpg' WHERE id=?").bind(id)] : []),
273
312
  env.DB.prepare("UPDATE deliveries SET status=CASE WHEN status='draft' THEN 'uploading' ELSE status END,updated_at=? WHERE id=?")
274
313
  .bind(now, deliveryId),
314
+ ...(version === 2 ? [
315
+ env.DB.prepare("INSERT INTO delivery_manifest_state(package_id,created_at) VALUES (?,?)").bind(id, now),
316
+ env.DB.prepare("UPDATE delivery_packages SET manifest_json=? WHERE id=? AND state='uploading'")
317
+ .bind(JSON.stringify({ version: 2, generation: id }), id),
318
+ ] : []),
275
319
  ]);
276
320
  return normalizePackage(await env.DB.prepare("SELECT * FROM delivery_packages WHERE id=?").bind(id).first());
277
321
  }
@@ -280,9 +324,16 @@ async function finalizePackage(env, body) {
280
324
  const deliveryId = required(body.id, "id del transfer mancante");
281
325
  const packageId = required(body.packageId, "id dei file mancante");
282
326
  const pkg = await env.DB.prepare(
283
- "SELECT * FROM delivery_packages WHERE id=? AND delivery_id=? AND state='uploading'"
327
+ "SELECT * FROM delivery_packages WHERE id=? AND delivery_id=? AND state IN ('uploading','ready')"
284
328
  ).bind(packageId, deliveryId).first();
285
329
  if (!pkg) throw new InputError("Non trovo il caricamento da completare");
330
+ if (manifestVersion(pkg) === 2) throw new ConflictError("Usa il completamento a passi per questo pacchetto");
331
+ if (pkg.state === "ready") {
332
+ if (JSON.stringify(body.manifest) !== pkg.manifest_json) throw new ConflictError("Il pacchetto è già completo con un manifest diverso");
333
+ return normalizePackage(pkg);
334
+ }
335
+ validateDeliverySourceBytes(pkg.source_bytes ?? 0);
336
+ const sourceBytes = validateDeliverySourceBytes(body.sourceBytes ?? 0);
286
337
  const uploads = await env.DB.prepare(
287
338
  "SELECT variant,object_key,state FROM delivery_uploads WHERE package_id=?"
288
339
  ).bind(packageId).all();
@@ -295,8 +346,17 @@ async function finalizePackage(env, body) {
295
346
 
296
347
  const manifest = body.manifest && typeof body.manifest === "object" ? body.manifest : null;
297
348
  if (!manifest || !Array.isArray(manifest.entries)) throw new InputError("L’elenco delle foto è arrivato incompleto");
349
+ let manifestBytes = 0;
350
+ for (const entry of manifest.entries) {
351
+ manifestBytes = validateDeliverySourceBytes(manifestBytes + validateDeliverySourceBytes(entry?.original?.length));
352
+ }
353
+ if (manifestBytes !== sourceBytes || (pkg.source_bytes != null && sourceBytes !== pkg.source_bytes) ||
354
+ (pkg.file_count != null && manifest.entries.length !== pkg.file_count) ||
355
+ (body.fileCount != null && body.fileCount !== manifest.entries.length)) {
356
+ throw new InputError("Dimensioni o numero dei file diversi dal caricamento dichiarato");
357
+ }
298
358
  const manifestJson = JSON.stringify(manifest);
299
- if (manifestJson.length > 1_800_000) throw new InputError("Troppe foto in un transfer solo: dividile in due invii");
359
+ validateDeliveryManifestV1(manifestJson);
300
360
  const manifestKey = b2ObjectKey(pkg.storage_prefix, "manifest.json");
301
361
  await b2PutSmall(env, manifestKey, manifestJson, "application/json");
302
362
 
@@ -304,15 +364,17 @@ async function finalizePackage(env, body) {
304
364
  await env.DB.prepare(`
305
365
  UPDATE delivery_packages SET state='ready', originals_key=?, web_key=?, thumbs_key=?,
306
366
  manifest_key=?, manifest_json=?, file_count=?, source_bytes=?, stored_bytes=?, updated_at=?
307
- WHERE id=?
367
+ WHERE id=? AND state='uploading'
308
368
  `).bind(
309
369
  byVariant.originals.object_key,
310
370
  byVariant.web?.object_key || null,
311
371
  byVariant.thumbs?.object_key || null,
312
372
  manifestKey, manifestJson, Number(body.fileCount || manifest.entries.length),
313
- Number(body.sourceBytes || 0), Number(body.storedBytes || 0), now, packageId,
373
+ sourceBytes, Number(body.storedBytes || 0), now, packageId,
314
374
  ).run();
315
- return normalizePackage(await env.DB.prepare("SELECT * FROM delivery_packages WHERE id=?").bind(packageId).first());
375
+ const completed = await env.DB.prepare("SELECT * FROM delivery_packages WHERE id=?").bind(packageId).first();
376
+ if (completed?.state !== "ready") throw new ConflictError("Il caricamento è stato annullato durante la finalizzazione");
377
+ return normalizePackage(completed);
316
378
  }
317
379
 
318
380
  async function deletePackage(env, body) {
@@ -320,12 +382,24 @@ async function deletePackage(env, body) {
320
382
  const now = new Date().toISOString();
321
383
  await env.DB.batch([
322
384
  env.DB.prepare(`
323
- UPDATE delivery_packages SET state='deleted',deleted_at=?,purge_after=?,updated_at=? WHERE id=?
324
- `).bind(now, now, now, packageId),
385
+ UPDATE delivery_packages SET state='deleted',deleted_at=?,
386
+ purge_after=CASE WHEN state='uploading' THEN ? ELSE COALESCE(purge_after,?) END,updated_at=? WHERE id=?
387
+ `).bind(now, new Date(Date.now() + 60 * 60_000).toISOString(), now, now, packageId),
325
388
  env.DB.prepare(`
326
389
  UPDATE delivery_hero_objects SET state='deleted',purge_after=?,updated_at=?
327
390
  WHERE package_id=? AND state!='deleted'
328
391
  `).bind(now, now, packageId),
392
+ // L'attivazione può essere già riuscita mentre il client ne attende la risposta.
393
+ // Cancellare l'ultimo pacchetto deve togliere anche lo stato «Pronto».
394
+ env.DB.prepare(`
395
+ UPDATE deliveries SET status=CASE
396
+ WHEN EXISTS (SELECT 1 FROM delivery_packages WHERE delivery_id=deliveries.id AND state='uploading')
397
+ THEN 'uploading' ELSE 'draft' END,updated_at=?,version=version+1
398
+ WHERE id=(SELECT delivery_id FROM delivery_packages WHERE id=?) AND status IN ('active','uploading')
399
+ AND NOT EXISTS (SELECT 1 FROM delivery_packages WHERE delivery_id=deliveries.id AND state='ready')
400
+ AND (status='active' OR NOT EXISTS
401
+ (SELECT 1 FROM delivery_packages WHERE delivery_id=deliveries.id AND state='uploading'))
402
+ `).bind(now, packageId),
329
403
  ]);
330
404
  return { purged: await purgePackageNow(env, packageId) };
331
405
  }
@@ -342,11 +416,11 @@ async function commitHeroes(env, body, origin) {
342
416
  for (const item of requested) {
343
417
  if (item?.type === "package") {
344
418
  const pkg = await env.DB.prepare(`
345
- SELECT manifest_json FROM delivery_packages
419
+ SELECT id,manifest_json FROM delivery_packages
346
420
  WHERE id=? AND delivery_id=? AND state='ready' AND web_key IS NOT NULL
347
421
  `).bind(text(item.packageId), deliveryId).first();
348
422
  const index = Number(item.index);
349
- const entry = parseManifest(pkg?.manifest_json)?.entries?.[index];
423
+ const entry = pkg ? await readManifestEntry(env, pkg, index) : null;
350
424
  if (!pkg || !Number.isInteger(index) || index < 0 || !entry?.web) {
351
425
  throw new InputError("Una delle foto di copertina non è più disponibile");
352
426
  }
@@ -419,19 +493,20 @@ async function createLink(env, body, origin) {
419
493
  const exists = await env.DB.prepare("SELECT id FROM deliveries WHERE id=? AND status!='deleted'")
420
494
  .bind(deliveryId).first();
421
495
  if (!exists) throw new InputError("Transfer non trovato");
422
- const token = randomToken(24);
496
+ const token = randomToken(DELIVERY_LINK_TOKEN_BYTES);
423
497
  const id = crypto.randomUUID();
424
498
  const now = new Date().toISOString();
425
499
  const pin = text(body.pin);
426
500
  await env.DB.prepare(`
427
501
  INSERT INTO delivery_links
428
502
  (id,delivery_id,label,token_hash,token_ciphertext,pin_ciphertext,expires_at,created_at)
429
- VALUES (?,?,?,?,?,?,?,?)
503
+ SELECT ?,?,?,?,?,?,?,?
504
+ WHERE (?=0 OR NOT EXISTS (SELECT 1 FROM delivery_links WHERE delivery_id=? AND revoked_at IS NULL))
430
505
  `).bind(
431
506
  id, deliveryId, text(body.label) || "Link principale", await sha256(token),
432
507
  await seal(env.DELIVERY_SECRET, token),
433
508
  pin ? await seal(env.DELIVERY_SECRET, pin) : null,
434
- nullableDate(body.expiresAt), now,
509
+ nullableDate(body.expiresAt), now, bool(body.ensurePrimary), deliveryId,
435
510
  ).run();
436
511
  return {
437
512
  id, label: text(body.label) || "Link principale", token,
@@ -440,6 +515,38 @@ async function createLink(env, body, origin) {
440
515
  };
441
516
  }
442
517
 
518
+ async function shortenLink(env, body, origin) {
519
+ if (env.DELIVERY_LINK_ALIASES !== "true") throw new InputError("Link brevi preesistenti non ancora abilitati");
520
+ const deliveryId = required(body.id, "id del transfer mancante");
521
+ const linkId = required(body.linkId, "id link mancante");
522
+ const read = () => env.DB.prepare(`
523
+ SELECT l.* FROM delivery_links l JOIN deliveries d ON d.id=l.delivery_id
524
+ WHERE l.id=? AND l.delivery_id=? AND l.revoked_at IS NULL AND d.status!='deleted'
525
+ `).bind(linkId, deliveryId).first();
526
+ let link = await read();
527
+ if (!link) throw new InputError("Link non trovato");
528
+ let token = await unseal(env.DELIVERY_SECRET, link.token_ciphertext);
529
+ if (token.length > DELIVERY_LINK_MIN_LENGTH) {
530
+ const next = randomToken(DELIVERY_LINK_TOKEN_BYTES);
531
+ // Compare-and-swap makes concurrent requests/retries return the one saved
532
+ // token. The alias and replacement commit together, so no old URL is lost.
533
+ await env.DB.batch([
534
+ env.DB.prepare(`INSERT INTO delivery_link_aliases(token_hash,link_id,created_at)
535
+ SELECT token_hash,id,? FROM delivery_links
536
+ WHERE id=? AND delivery_id=? AND token_hash=? AND revoked_at IS NULL
537
+ ON CONFLICT(token_hash) DO NOTHING
538
+ `).bind(new Date().toISOString(), linkId, deliveryId, link.token_hash),
539
+ env.DB.prepare(`UPDATE delivery_links SET token_hash=?,token_ciphertext=?
540
+ WHERE id=? AND delivery_id=? AND token_hash=? AND revoked_at IS NULL
541
+ `).bind(await sha256(next), await seal(env.DELIVERY_SECRET, next), linkId, deliveryId, link.token_hash),
542
+ ]);
543
+ link = await read();
544
+ if (!link) throw new InputError("Link non più disponibile");
545
+ token = await unseal(env.DELIVERY_SECRET, link.token_ciphertext);
546
+ }
547
+ return { id: link.id, token, url: `${origin}/c/${token}` };
548
+ }
549
+
443
550
  async function updateLink(env, body, origin) {
444
551
  const deliveryId = required(body.id, "id del transfer mancante");
445
552
  const linkId = required(body.linkId, "id link mancante");
@@ -500,7 +607,8 @@ async function deliveryDetail(env, id, origin) {
500
607
  }
501
608
  return {
502
609
  ...normalizeDelivery(row),
503
- packages: (packagesResult.results || []).map(packageDetail),
610
+ canShortenLinks: env.DELIVERY_LINK_ALIASES === "true",
611
+ packages: await Promise.all((packagesResult.results || []).map(pkg => packageDetail(env, pkg))),
504
612
  heroes: await normalizeHeroes(env, row.hero_json),
505
613
  links,
506
614
  };
@@ -512,6 +620,8 @@ function normalizeDelivery(row) {
512
620
  description: row.description || "", licenseNotes: row.license_notes || "",
513
621
  ctaEnabled: !!row.cta_enabled, ctaLabel: row.cta_label || "",
514
622
  ctaUrl: row.cta_url || "", expiresAt: row.expires_at,
623
+ downloadOriginalsLabel: row.download_originals_label || "",
624
+ downloadWebLabel: row.download_web_label || "",
515
625
  filesExpiresAt: row.files_expires_at || null,
516
626
  heroes: [],
517
627
  status: row.status, version: Number(row.version || 1),
@@ -524,24 +634,24 @@ function normalizeDelivery(row) {
524
634
  function normalizePackage(row) {
525
635
  return {
526
636
  id: row.id, deliveryId: row.delivery_id, label: row.label, position: Number(row.position || 0),
527
- galleryEnabled: !!row.gallery_enabled, webEnabled: !!row.web_enabled, state: row.state,
528
- sourceFingerprint: row.source_fingerprint || "",
637
+ galleryEnabled: !!row.gallery_enabled, webEnabled: !!row.web_enabled, webFormat: row.web_format === "jpg" ? "jpg" : "webp", state: row.state,
638
+ sourceFingerprint: row.source_fingerprint || "", manifestVersion: manifestVersion(row),
529
639
  fileCount: Number(row.file_count || 0), sourceBytes: Number(row.source_bytes || 0),
530
640
  storedBytes: Number(row.stored_bytes || 0), createdAt: row.created_at, updatedAt: row.updated_at,
531
641
  };
532
642
  }
533
643
 
534
- function packageDetail(row) {
535
- const pkg = normalizePackage(row);
536
- const manifest = parseManifest(row.manifest_json);
537
- return {
538
- ...pkg,
539
- images: (manifest?.entries || []).map((entry, index) => entry?.web ? {
540
- index, name: entry.name || `Immagine ${index + 1}`,
541
- previewUrl: `/admin/api/delivery/image?packageId=${encodeURIComponent(row.id)}` +
542
- `&variant=${entry.thumb ? "thumb" : "web"}&index=${index}`,
543
- } : null).filter(Boolean),
544
- };
644
+ function packageImage(packageId, entry, index) {
645
+ return { index, name: entry.name || `Immagine ${index + 1}`,
646
+ width: entry.width, height: entry.height,
647
+ previewUrl: `/admin/api/delivery/image?packageId=${encodeURIComponent(packageId)}&variant=${entry.thumb ? "thumb" : "web"}&index=${index}` };
648
+ }
649
+ async function packageDetail(env, row) {
650
+ // Metadata can arrive before the ZIP is published. Do not issue image URLs
651
+ // until the image endpoint can actually serve them (otherwise img keeps a 404).
652
+ if (row.state !== "ready") return { ...normalizePackage(row), images: [], nextCursor: null };
653
+ const page = await readManifestPage(env, row);
654
+ return { ...normalizePackage(row), images: page.entries.map(({ entry, index }) => packageImage(row.id, entry, index)), nextCursor: page.nextCursor };
545
655
  }
546
656
 
547
657
  async function normalizeHeroes(env, value) {
@@ -549,11 +659,11 @@ async function normalizeHeroes(env, value) {
549
659
  for (const ref of parseHeroRefs(value)) {
550
660
  if (ref.type === "package") {
551
661
  const pkg = await env.DB.prepare(`
552
- SELECT manifest_json FROM delivery_packages
662
+ SELECT id,manifest_json FROM delivery_packages
553
663
  WHERE id=? AND state='ready' AND web_key IS NOT NULL
554
664
  `).bind(text(ref.packageId)).first();
555
665
  const index = Number(ref.index);
556
- if (pkg && Number.isInteger(index) && parseManifest(pkg.manifest_json)?.entries?.[index]?.web) {
666
+ if (pkg && Number.isInteger(index) && (await readManifestEntry(env, pkg, index))?.web) {
557
667
  out.push({
558
668
  type: "package", packageId: text(ref.packageId), index,
559
669
  previewUrl: `/admin/api/delivery/image?packageId=${encodeURIComponent(ref.packageId)}` +
@@ -595,6 +705,24 @@ function positiveInt(value) {
595
705
  return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
596
706
  }
597
707
 
708
+ // Explicit fields only: an older client saving other metadata must keep custom labels.
709
+ function downloadLabelFields(env, body) {
710
+ const columns = [], values = [];
711
+ for (const [field, column] of [["downloadOriginalsLabel", "download_originals_label"], ["downloadWebLabel", "download_web_label"]]) {
712
+ if (body[field] === undefined) continue;
713
+ if (typeof body[field] !== "string" || body[field].length > 80 || /[\x00-\x1f\x7f]/.test(body[field])) {
714
+ throw new InputError("Il testo dei pulsanti deve contenere al massimo 80 caratteri, su una sola riga");
715
+ }
716
+ const value = body[field].trim();
717
+ if (env.DELIVERY_DOWNLOAD_LABELS !== "true") {
718
+ if (value) throw new InputError("I testi personalizzati dei download non sono ancora abilitati su questo sito");
719
+ continue;
720
+ }
721
+ columns.push(column); values.push(value);
722
+ }
723
+ return { columns, values };
724
+ }
725
+
598
726
  function text(value) { return String(value ?? "").trim(); }
599
727
  function bool(value) { return value ? 1 : 0; }
600
728
  function required(value, error) {
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import { b2Get } from "../../_shared/b2.js";
5
5
 
6
+ import { readManifestEntry } from "../../_shared/delivery-manifest.js";
7
+
6
8
  export async function onRequestGet({ request, env }) {
7
9
  if (!env.DB) return new Response("D1 non configurato", { status: 500 });
8
10
  const url = new URL(request.url);
@@ -10,19 +12,22 @@ export async function onRequestGet({ request, env }) {
10
12
  const variant = url.searchParams.get("variant") === "thumb" ? "thumb" : "web";
11
13
  const index = Number(url.searchParams.get("index"));
12
14
  const pkg = await env.DB.prepare(`
13
- SELECT web_key,thumbs_key,manifest_json FROM delivery_packages
15
+ SELECT * FROM delivery_packages
14
16
  WHERE id=? AND state='ready'
15
17
  `).bind(packageId).first();
16
- const manifest = parseManifest(pkg?.manifest_json);
17
- const entry = Number.isInteger(index) && index >= 0 ? manifest.entries?.[index] : null;
18
+ const entry = pkg ? await readManifestEntry(env, pkg, index) : null;
18
19
  const member = variant === "thumb" ? entry?.thumb : entry?.web;
19
20
  const key = variant === "thumb" ? pkg?.thumbs_key : pkg?.web_key;
20
- if (!member || !key) return new Response("Not found", { status: 404 });
21
+ if (!member || !key) return new Response("Not found", { status: 404, headers: { "Cache-Control": "no-store" } });
21
22
  const upstream = await b2Get(env, key, `bytes=${member.offset}-${member.offset + member.length - 1}`);
22
- if (!upstream.ok) return new Response("Not found", { status: upstream.status });
23
+ 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)) {
24
+ await upstream.body?.cancel();
25
+ return new Response("Risposta archivio non valida", { status: 502, headers: { "Cache-Control": "no-store" } });
26
+ }
27
+ if (!upstream.ok) return new Response("Not found", { status: upstream.status, headers: { "Cache-Control": "no-store" } });
23
28
  return new Response(upstream.body, {
24
29
  headers: {
25
- "Content-Type": "image/webp",
30
+ "Content-Type": variant === "web" && pkg.web_format === "jpg" ? "image/jpeg" : "image/webp",
26
31
  "Content-Length": String(member.length),
27
32
  "Cache-Control": "private, max-age=3600",
28
33
  "X-Content-Type-Options": "nosniff",