@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.
Files changed (59) hide show
  1. package/dist/api.d.ts +19 -0
  2. package/dist/api.js +3 -0
  3. package/dist/app/App.js +22 -8
  4. package/dist/app/app.css +3 -3
  5. package/dist/app/delivery.d.ts +11 -0
  6. package/dist/app/delivery.js +46 -0
  7. package/dist/app/index.d.ts +4 -1
  8. package/dist/app/index.js +3 -2
  9. package/dist/app/lib/bookings.js +5 -0
  10. package/dist/app/lib/deliveries.d.ts +8 -0
  11. package/dist/app/lib/deliveries.js +7 -0
  12. package/dist/app/lib/delivery-network.d.ts +25 -0
  13. package/dist/app/lib/delivery-network.js +159 -0
  14. package/dist/app/lib/delivery-upload.d.ts +29 -11
  15. package/dist/app/lib/delivery-upload.js +408 -131
  16. package/dist/app/lib/home.js +7 -4
  17. package/dist/app/lib/pages.js +2 -1
  18. package/dist/app/lib/publish.d.ts +5 -0
  19. package/dist/app/lib/publish.js +61 -4
  20. package/dist/app/lib/requests.js +6 -0
  21. package/dist/app/lib/settings.js +7 -3
  22. package/dist/app/lib/upload-queue.d.ts +21 -3
  23. package/dist/app/lib/upload-queue.js +76 -19
  24. package/dist/app/routes/Deliveries.js +108 -29
  25. package/dist/app/routes/PageActions.d.ts +1 -1
  26. package/dist/app/routes/PageActions.js +5 -2
  27. package/dist/app/routes/PageEditor.js +6 -4
  28. package/dist/app/routes/PageSeoEditor.js +6 -1
  29. package/dist/app/routes/PagesList.js +5 -1
  30. package/dist/app/routes/UploadDock.js +6 -2
  31. package/dist/app/site.d.ts +13 -0
  32. package/dist/direct-publication.d.ts +20 -0
  33. package/dist/direct-publication.js +75 -0
  34. package/dist/functions/_shared/b2.js +22 -3
  35. package/dist/functions/_shared/delivery-crypto.js +5 -0
  36. package/dist/functions/_shared/delivery-limits.d.ts +9 -0
  37. package/dist/functions/_shared/delivery-limits.js +29 -0
  38. package/dist/functions/_shared/delivery-manifest-v2.sql +49 -0
  39. package/dist/functions/_shared/delivery-manifest.js +392 -0
  40. package/dist/functions/_shared/delivery-purge.js +17 -1
  41. package/dist/functions/api/deliveries.js +177 -49
  42. package/dist/functions/api/delivery/image.js +11 -6
  43. package/dist/functions/api/delivery/multipart.js +196 -64
  44. package/dist/functions/delivery-public/assets/client.js +246 -0
  45. package/dist/functions/delivery-public/assets/delivery.css +297 -0
  46. package/dist/functions/delivery-public/assets/gallery.js +243 -0
  47. package/dist/functions/delivery-public/assets/stage.js +107 -0
  48. package/dist/functions/delivery-public/index.js +637 -0
  49. package/dist/functions/delivery-public/migrations/0002_deliveries.sql +128 -0
  50. package/dist/functions/delivery-public/migrations/0003_delivery_quick_hero.sql +34 -0
  51. package/dist/functions/delivery-public/migrations/0004_delivery_file_expiry.sql +9 -0
  52. package/dist/functions/delivery-public/migrations/0006_delivery_manifest_v2.sql +49 -0
  53. package/dist/functions/delivery-public/migrations/0007_delivery_link_aliases.sql +8 -0
  54. package/dist/functions/delivery-public/migrations/0008_delivery_web_format.sql +2 -0
  55. package/dist/functions/delivery-public/migrations/0009_delivery_download_labels.sql +2 -0
  56. package/dist/functions/index.js +3 -0
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.js +1 -0
  59. package/package.json +10 -3
@@ -6,26 +6,175 @@
6
6
  * - il manifest conserva gli offset dei membri STORE, così la Function pubblica può
7
7
  * servire una singola foto con un GET Range senza duplicarla come oggetto.
8
8
  */
9
- const PART = 16 * 1024 * 1024;
9
+ import { deliveryJson, deliveryPut, deliveryGate, checkCancelled, UploadCancelled } from "./delivery-network.js";
10
+ import { DELIVERY_PART_BYTES, DELIVERY_MAX_ARCHIVE_BYTES, validateDeliverySourceBytes, DELIVERY_V1_MANIFEST_BYTES, DELIVERY_SKIP_REASON_CHARS } from "../../functions/_shared/delivery-limits.js";
11
+ export { UploadCancelled } from "./delivery-network.js";
12
+ const PART = DELIVERY_PART_BYTES;
10
13
  const READ = 4 * 1024 * 1024;
11
14
  const UPLOAD_CONCURRENCY = 4;
12
15
  const IMAGE = /\.(jpe?g|png|webp)$/i;
13
16
  const enc = new TextEncoder();
14
17
  const droppedPaths = new WeakMap();
15
- export class UploadCancelled extends Error {
16
- constructor() { super("Caricamento annullato"); this.name = "UploadCancelled"; }
18
+ /* Il cancello di pausa e annullamento.
19
+ *
20
+ * Chi carica lo passa da fuori (lib/upload-queue.ts) e questo file si limita a bussare: a
21
+ * ogni file e a ogni PARTE. Le parti contano più dei file — un solo video da 20 GB è un
22
+ * file solo e mille parti, e senza il controllo lì la pausa non arriverebbe mai.
23
+ * `gate()` si risolve subito quando non c'è niente da aspettare: sul percorso normale costa
24
+ * una promise già risolta per parte, cioè niente. */
25
+ export async function uploadDeliveryPackage(deliveryId, packageId, selected, options, onProgress, control = { gate: async () => { } }) {
26
+ preflightDeliverySelection(selected, options);
27
+ if (options.manifestVersion !== 2)
28
+ return performDeliveryPackage(deliveryId, packageId, selected, options, onProgress, control);
29
+ if (typeof navigator === "undefined" || !navigator.locks)
30
+ throw new Error("Per la ripresa persistente serve un browser aggiornato con supporto ai blocchi di caricamento.");
31
+ return navigator.locks.request(`adminkit-delivery:${packageId}`, { ifAvailable: true }, async (lock) => {
32
+ if (!lock)
33
+ throw new Error("Il caricamento è già in corso in un’altra scheda.");
34
+ const key = `adminkit-delivery-writer:${packageId}`;
35
+ let token;
36
+ try {
37
+ token = localStorage.getItem(key) || crypto.randomUUID();
38
+ localStorage.setItem(key, token);
39
+ }
40
+ catch {
41
+ throw new Error("Il browser non può conservare l’identità del caricamento. Consenti l’archiviazione locale e riprova.");
42
+ }
43
+ control.writerToken = token;
44
+ await deliveryJson("/admin/api/deliveries", { action: "manifestClaim", id: deliveryId, packageId }, control);
45
+ // Mantiene la proprietà anche durante una pausa; un errore di rete è gestito
46
+ // dalla prossima mutazione, senza tentativi sovrapposti o errori non osservati.
47
+ let renewing = false;
48
+ const timer = setInterval(() => {
49
+ if (renewing || control.signal?.aborted)
50
+ return;
51
+ renewing = true;
52
+ void fetch("/admin/api/deliveries", { method: "POST", credentials: "same-origin",
53
+ headers: { "Content-Type": "application/json" }, signal: control.signal,
54
+ body: JSON.stringify({ action: "manifestClaim", id: deliveryId, packageId, writerToken: token }),
55
+ }).catch(() => { }).finally(() => { renewing = false; });
56
+ }, 5 * 60_000);
57
+ try {
58
+ return await performDeliveryPackage(deliveryId, packageId, selected, options, onProgress, control);
59
+ }
60
+ finally {
61
+ clearInterval(timer);
62
+ }
63
+ });
64
+ }
65
+ async function performDeliveryPackage(deliveryId, packageId, selected, options, onProgress, control) {
66
+ preflightDeliverySelection(selected, options);
67
+ const controller = new AbortController();
68
+ const abort = () => controller.abort();
69
+ control?.signal?.addEventListener("abort", abort, { once: true });
70
+ if (control?.signal?.aborted)
71
+ abort();
72
+ const local = {
73
+ writerToken: control?.writerToken,
74
+ signal: controller.signal,
75
+ networkWaiting: (waiting) => control?.networkWaiting?.(waiting),
76
+ gate: async () => { await deliveryGate({ ...control, signal: controller.signal, gate: () => control?.gate() || Promise.resolve() }); },
77
+ };
78
+ const sinks = [];
79
+ try {
80
+ return await buildDeliveryPackage(deliveryId, packageId, selected, options, onProgress, local, sinks);
81
+ }
82
+ finally {
83
+ controller.abort();
84
+ await Promise.allSettled(sinks.map((sink) => sink.settle()));
85
+ control?.signal?.removeEventListener("abort", abort);
86
+ }
17
87
  }
18
- export async function uploadDeliveryPackage(deliveryId, packageId, selected, options, onProgress, control) {
88
+ async function buildDeliveryPackage(deliveryId, packageId, selected, options, onProgress, control, sinks) {
19
89
  const startedAt = Date.now();
20
- const files = [...selected].sort((a, b) => deliveryRelativeName(a).localeCompare(deliveryRelativeName(b), undefined, { numeric: true, sensitivity: "base" }));
90
+ const files = [...selected].sort((a, b) => options.manifestVersion === 2
91
+ ? (deliveryRelativeName(a) < deliveryRelativeName(b) ? -1 : deliveryRelativeName(a) > deliveryRelativeName(b) ? 1 : 0)
92
+ : deliveryRelativeName(a).localeCompare(deliveryRelativeName(b), undefined, { numeric: true, sensitivity: "base" }));
21
93
  if (!files.length)
22
94
  throw new Error("Nessun file selezionato");
23
95
  const sourceBytes = files.reduce((n, f) => n + f.size, 0);
24
96
  const entries = [];
25
- let processed = 0;
97
+ let checkpoint = null;
98
+ if (options.manifestVersion === 2) {
99
+ const status = await deliveryJson("/admin/api/deliveries", {
100
+ action: "manifestStatus", id: deliveryId, packageId,
101
+ }, control);
102
+ if (status.state === "sealed" || status.state === "ready")
103
+ return {
104
+ manifest: { version: 2, createdAt: "", entries: [], skipped: [] }, fileCount: files.length, sourceBytes, storedBytes: 0,
105
+ };
106
+ checkpoint = status.checkpoint;
107
+ if (checkpoint) {
108
+ let after = -1;
109
+ while (entries.length < checkpoint.fileIndex) {
110
+ const page = await deliveryJson("/admin/api/deliveries", {
111
+ action: "manifestDraftPage", id: deliveryId, packageId, after,
112
+ }, control);
113
+ if (!page.entries.length)
114
+ throw new Error("Il checkpoint non contiene tutti i file precedenti.");
115
+ for (const row of page.entries) {
116
+ if (row.index >= checkpoint.fileIndex)
117
+ break;
118
+ if (row.index !== entries.length || row.entry.name !== safePath(deliveryRelativeName(files[row.index])) || row.entry.original.length !== files[row.index].size) {
119
+ throw new Error("La selezione non corrisponde al checkpoint.");
120
+ }
121
+ // Le varianti verranno riprese nella loro fase; i batch originali restano immutabili.
122
+ entries.push({ name: row.entry.name, type: row.entry.type, original: row.entry.original });
123
+ after = row.index;
124
+ }
125
+ }
126
+ }
127
+ }
128
+ let processed = checkpoint?.fileIndex || 0;
129
+ const entryByName = new Map();
130
+ let metadataBatch = [];
131
+ let metadataSize = 0;
132
+ let phase = "originals";
133
+ let sequence = checkpoint?.sequence || 0;
134
+ for (let i = 0; i < entries.length; i++)
135
+ entryByName.set(entries[i].name, i);
136
+ async function flushMetadata() {
137
+ if (!metadataBatch.length || options.manifestVersion !== 2)
138
+ return;
139
+ await deliveryJson("/admin/api/deliveries", { action: "manifestBatch", id: deliveryId, packageId,
140
+ phase, batchId: `${phase}-${sequence++}`, entries: metadataBatch }, control);
141
+ metadataBatch = [];
142
+ metadataSize = 0;
143
+ }
144
+ async function saveMetadata(index, entry) {
145
+ if (options.manifestVersion !== 2)
146
+ return;
147
+ const row = { index, entry };
148
+ const size = enc.encode(JSON.stringify(row)).length + 1;
149
+ if (metadataBatch.length >= 200 || metadataSize + size > 250 * 1024)
150
+ await flushMetadata();
151
+ metadataBatch.push(row);
152
+ metadataSize += size;
153
+ }
26
154
  let currentOriginal = "";
27
- const originalsSink = new B2Sink(deliveryId, packageId, "originals", reportOriginals, control);
28
- const originals = new ZipStream(originalsSink);
155
+ const originalsSink = new B2Sink(deliveryId, packageId, "originals", reportOriginals, control, options.manifestVersion === 2);
156
+ sinks.push(originalsSink);
157
+ const originals = new ZipStream(originalsSink, options.manifestVersion === 2);
158
+ if (checkpoint) {
159
+ if (checkpoint.complete) {
160
+ originalsSink.totalBytes = checkpoint.offset;
161
+ originalsSink.uploadedBytes = checkpoint.offset;
162
+ }
163
+ else {
164
+ await originalsSink.restore(checkpoint.offset, checkpoint.partsHash);
165
+ originals.restore(entries, files, checkpoint.offset);
166
+ }
167
+ }
168
+ let lastCheckpoint = checkpoint?.offset || 0;
169
+ async function saveCheckpoint(current, offset) {
170
+ if (options.manifestVersion !== 2 || offset - lastCheckpoint < PART * 4)
171
+ return;
172
+ await originalsSink.checkpointBoundary();
173
+ await flushMetadata();
174
+ await deliveryJson("/admin/api/deliveries", { action: "manifestCheckpoint", id: deliveryId, packageId,
175
+ checkpoint: { version: 1, fileIndex: processed, offset, sequence, current } }, control);
176
+ lastCheckpoint = offset;
177
+ }
29
178
  function reportOriginals() {
30
179
  onProgress({
31
180
  phase: "originals", file: currentOriginal, doneFiles: processed, totalFiles: files.length,
@@ -34,17 +183,27 @@ export async function uploadDeliveryPackage(deliveryId, packageId, selected, opt
34
183
  transferStartedAt: originalsSink.transferStartedAt || undefined,
35
184
  });
36
185
  }
37
- for (const file of files) {
186
+ for (const file of files.slice(processed)) {
38
187
  if (control)
39
188
  await control.gate();
40
189
  const name = safePath(deliveryRelativeName(file));
41
190
  currentOriginal = name;
42
- const member = await originals.add(name, file, reportOriginals, new Date(file.lastModified));
191
+ const member = await originals.add(name, file, reportOriginals, new Date(file.lastModified), options.manifestVersion === 2 ? saveCheckpoint : undefined, checkpoint && processed === checkpoint.fileIndex ? checkpoint.current : undefined);
192
+ entryByName.set(name, entries.length);
43
193
  entries.push({ name, type: file.type || mimeFromName(name), original: member });
194
+ await saveMetadata(entries.length - 1, entries[entries.length - 1]);
44
195
  processed++;
45
196
  reportOriginals();
46
197
  }
47
- await originals.close();
198
+ if (!checkpoint?.complete)
199
+ await originals.close();
200
+ await flushMetadata();
201
+ if (options.manifestVersion === 2 && !checkpoint?.complete) {
202
+ await deliveryJson("/admin/api/deliveries", { action: "manifestCheckpoint", id: deliveryId, packageId,
203
+ checkpoint: { version: 1, fileIndex: files.length, offset: originalsSink.totalBytes, sequence, complete: true } }, control);
204
+ }
205
+ phase = "variants";
206
+ sequence = 0;
48
207
  const wantWebArchive = options.webEnabled || options.galleryEnabled;
49
208
  let webSink = null;
50
209
  let thumbsSink = null;
@@ -52,12 +211,14 @@ export async function uploadDeliveryPackage(deliveryId, packageId, selected, opt
52
211
  let thumbsZip = null;
53
212
  const skipped = [];
54
213
  if (wantWebArchive) {
55
- webSink = new B2Sink(deliveryId, packageId, "web", undefined, control);
56
- webZip = new ZipStream(webSink);
214
+ webSink = new B2Sink(deliveryId, packageId, "web", undefined, control, options.manifestVersion === 2);
215
+ sinks.push(webSink);
216
+ webZip = new ZipStream(webSink, options.manifestVersion === 2);
57
217
  }
58
218
  if (options.galleryEnabled) {
59
- thumbsSink = new B2Sink(deliveryId, packageId, "thumbs", undefined, control);
60
- thumbsZip = new ZipStream(thumbsSink);
219
+ thumbsSink = new B2Sink(deliveryId, packageId, "thumbs", undefined, control, options.manifestVersion === 2);
220
+ sinks.push(thumbsSink);
221
+ thumbsZip = new ZipStream(thumbsSink, options.manifestVersion === 2);
61
222
  }
62
223
  if (webZip) {
63
224
  let imageDone = 0;
@@ -66,24 +227,36 @@ export async function uploadDeliveryPackage(deliveryId, packageId, selected, opt
66
227
  if (control)
67
228
  await control.gate();
68
229
  const sourceName = safePath(deliveryRelativeName(file));
230
+ let variants;
69
231
  try {
70
- const variants = await makeVariants(file, !!thumbsZip);
71
- const outputName = sourceName.replace(/\.[^.\/]+$/, "") + ".webp";
72
- const modified = new Date(file.lastModified);
73
- const web = await webZip.add(outputName, variants.display, undefined, modified);
74
- const entry = entries.find((it) => it.name === sourceName);
75
- if (entry) {
76
- entry.web = web;
77
- entry.width = variants.width;
78
- entry.height = variants.height;
79
- if (thumbsZip && variants.thumb) {
80
- entry.thumb = await thumbsZip.add(outputName, variants.thumb, undefined, modified);
81
- }
82
- }
232
+ variants = await makeVariants(file, !!thumbsZip, options.webEnabled ? options.webFormat : "webp");
83
233
  }
84
234
  catch (e) {
85
- skipped.push({ name: sourceName, reason: e instanceof Error ? e.message : String(e) });
235
+ checkCancelled(control);
236
+ if (e instanceof UploadCancelled)
237
+ throw e;
238
+ skipped.push({ name: sourceName, reason: (e instanceof Error ? e.message : String(e)).slice(0, DELIVERY_SKIP_REASON_CHARS) });
239
+ const index = entryByName.get(sourceName);
240
+ await saveMetadata(index, { ...entries[index], skipped: skipped[skipped.length - 1].reason });
241
+ imageDone++;
242
+ continue;
243
+ }
244
+ await control?.gate();
245
+ const outputBase = options.manifestVersion === 2 ? sourceName : sourceName.replace(/\.[^.\/]+$/, "");
246
+ const outputName = outputBase + (options.webEnabled && options.webFormat === "jpg" ? ".jpg" : ".webp");
247
+ const modified = new Date(file.lastModified);
248
+ const web = await webZip.add(outputName, variants.display, undefined, modified);
249
+ const index = entryByName.get(sourceName);
250
+ const entry = entries[index];
251
+ if (entry) {
252
+ entry.web = web;
253
+ entry.width = variants.width;
254
+ entry.height = variants.height;
255
+ if (thumbsZip && variants.thumb) {
256
+ entry.thumb = await thumbsZip.add(outputBase + ".webp", variants.thumb, undefined, modified);
257
+ }
86
258
  }
259
+ await saveMetadata(index, entry);
87
260
  imageDone++;
88
261
  onProgress({
89
262
  phase: "derivatives", file: sourceName, doneFiles: imageDone,
@@ -101,6 +274,7 @@ export async function uploadDeliveryPackage(deliveryId, packageId, selected, opt
101
274
  if (thumbsZip)
102
275
  await thumbsZip.close();
103
276
  }
277
+ await flushMetadata();
104
278
  onProgress({
105
279
  phase: "finalizing", file: "", doneFiles: files.length, totalFiles: files.length,
106
280
  sourceBytes, uploadedBytes: originalsSink.uploadedBytes + (webSink?.uploadedBytes || 0) +
@@ -111,13 +285,14 @@ export async function uploadDeliveryPackage(deliveryId, packageId, selected, opt
111
285
  transferStartedAt: firstStartedAt(originalsSink, webSink, thumbsSink),
112
286
  });
113
287
  return {
114
- manifest: { version: 1, createdAt: new Date().toISOString(), entries, skipped },
288
+ manifest: { version: options.manifestVersion || 1, createdAt: new Date().toISOString(), entries: options.manifestVersion === 2 ? [] : entries, skipped },
115
289
  fileCount: files.length,
116
290
  sourceBytes,
117
291
  storedBytes: originalsSink.totalBytes + (webSink?.totalBytes || 0) + (thumbsSink?.totalBytes || 0),
118
292
  };
119
293
  }
120
294
  class B2Sink {
295
+ forceMultipart;
121
296
  deliveryId;
122
297
  packageId;
123
298
  variant;
@@ -134,7 +309,8 @@ class B2Sink {
134
309
  failure = null;
135
310
  onTransfer;
136
311
  control;
137
- constructor(deliveryId, packageId, variant, onTransfer, control) {
312
+ constructor(deliveryId, packageId, variant, onTransfer, control, forceMultipart = false) {
313
+ this.forceMultipart = forceMultipart;
138
314
  this.deliveryId = deliveryId;
139
315
  this.packageId = packageId;
140
316
  this.variant = variant;
@@ -144,6 +320,9 @@ class B2Sink {
144
320
  async write(input) {
145
321
  if (!input.byteLength)
146
322
  return;
323
+ if (this.totalBytes + input.byteLength > DELIVERY_MAX_ARCHIVE_BYTES) {
324
+ throw new Error("L’archivio supera il limite tecnico: dividi i file in più invii.");
325
+ }
147
326
  this.chunks.push(input);
148
327
  this.buffered += input.byteLength;
149
328
  this.totalBytes += input.byteLength;
@@ -151,47 +330,53 @@ class B2Sink {
151
330
  await this.queuePart(this.take(PART));
152
331
  }
153
332
  async close() {
154
- if (this.session?.state === "completed") {
155
- const rest = this.take(this.buffered);
156
- this.markUploaded(rest.byteLength, false);
157
- await this.drain();
158
- return;
159
- }
333
+ await this.control?.gate();
334
+ if (this.forceMultipart && !this.session)
335
+ await this.ensureSession();
160
336
  if (!this.session) {
161
337
  const body = this.take(this.buffered);
162
- const signed = await postJson({
163
- action: "putPresign", id: this.deliveryId, packageId: this.packageId, variant: this.variant,
164
- });
165
338
  this.startTransfer();
166
- const res = await fetch(signed.url, {
167
- method: "PUT", body: body.buffer, headers: { "Content-Type": "application/zip" },
168
- });
169
- if (!res.ok)
170
- throw new Error(`Caricamento non riuscito (${res.status}). Riprova fra poco.`);
339
+ const etag = await deliveryPut(async () => {
340
+ const signed = await postJson({
341
+ action: "putPresign", id: this.deliveryId, packageId: this.packageId, variant: this.variant,
342
+ }, this.control);
343
+ return signed.url;
344
+ }, body.buffer, "application/zip", this.control);
345
+ await this.control?.gate();
171
346
  await postJson({
172
347
  action: "putComplete", id: this.deliveryId, packageId: this.packageId,
173
- variant: this.variant, bytes: body.byteLength,
174
- });
348
+ variant: this.variant, bytes: body.byteLength, etag,
349
+ }, this.control);
175
350
  this.markUploaded(body.byteLength, true);
176
351
  return;
177
352
  }
178
353
  if (this.buffered)
179
354
  await this.queuePart(this.take(this.buffered));
180
355
  await this.drain();
181
- await postJson({ action: "complete", uploadId: this.session.id });
356
+ await this.control?.gate();
357
+ if (this.session.state === "completed") {
358
+ if (this.session.bytes !== this.totalBytes || this.priorParts.size !== this.partNumber) {
359
+ throw new Error("I file non corrispondono all’archivio già caricato. Crea un nuovo caricamento.");
360
+ }
361
+ return;
362
+ }
363
+ await postJson({ action: "complete", uploadId: this.session.id,
364
+ bytes: this.totalBytes, partCount: this.partNumber }, this.control);
182
365
  }
183
366
  async ensureSession() {
184
367
  if (this.session)
185
368
  return this.session;
186
369
  const out = await postJson({
187
370
  action: "create", id: this.deliveryId, packageId: this.packageId, variant: this.variant,
188
- });
371
+ }, this.control);
189
372
  this.session = out.upload;
190
- if (out.upload.state === "completed")
191
- return out.upload;
192
373
  const reconciled = await postJson({
193
374
  action: "reconcile", uploadId: out.upload.id,
194
- });
375
+ }, this.control);
376
+ if (reconciled.completed) {
377
+ this.session.state = "completed";
378
+ this.session.bytes = reconciled.bytes;
379
+ }
195
380
  this.priorParts = new Map(reconciled.parts.map((part) => [part.partNumber, part]));
196
381
  return out.upload;
197
382
  }
@@ -217,69 +402,59 @@ class B2Sink {
217
402
  }
218
403
  }
219
404
  async uploadPart(body, partNumber, session) {
220
- if (session.state === "completed") {
221
- this.markUploaded(body.byteLength, false);
222
- return;
223
- }
224
405
  const digest = await digestHex(body);
225
406
  const prior = this.priorParts.get(partNumber);
226
407
  if (prior?.size === body.byteLength && prior.digest === digest) {
227
408
  this.markUploaded(body.byteLength, false);
228
409
  return;
229
410
  }
230
- const signed = await postJson({
231
- action: "presign", uploadId: session.id, partNumber,
232
- });
233
- let res = null;
234
- let failure = "errore di rete o CORS";
235
- for (let attempt = 0; attempt < 3; attempt++) {
236
- this.startTransfer();
237
- res = await fetch(signed.url, {
238
- method: "PUT", body: body.buffer, headers: { "Content-Type": "application/octet-stream" },
239
- }).catch(() => null);
240
- if (res?.ok)
241
- break;
242
- if (res) {
243
- const detail = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 180);
244
- failure = `HTTP ${res.status}${detail ? ` · ${detail}` : ""}`;
245
- }
246
- await delay(500 * 2 ** attempt);
247
- }
248
- if (!res?.ok) {
249
- throw new Error(`Un pezzo del caricamento non è passato (${failure}). Riprova.`);
250
- }
251
- const etag = res.headers.get("etag");
252
- if (!etag) {
253
- throw new Error("L’archivio non espone l’header ETag: manca la configurazione CORS del bucket (vedi DELIVERY.md)");
254
- }
255
- let recorded = false;
256
- for (let attempt = 0; attempt < 3 && !recorded; attempt++) {
257
- try {
258
- await postJson({
259
- action: "record", uploadId: session.id, partNumber, etag,
260
- digest, size: body.byteLength,
261
- });
262
- recorded = true;
263
- }
264
- catch {
265
- if (attempt === 2)
266
- throw new Error("Un pezzo del caricamento non è stato registrato. Riprova.");
267
- await delay(400 * 2 ** attempt);
268
- }
411
+ if (session.state === "completed") {
412
+ throw new Error("Non posso verificare gli stessi file nell’archivio completato. Crea un nuovo caricamento.");
269
413
  }
414
+ this.startTransfer();
415
+ const etag = await deliveryPut(async () => {
416
+ const signed = await postJson({
417
+ action: "presign", uploadId: session.id, partNumber,
418
+ }, this.control);
419
+ return signed.url;
420
+ }, body.buffer, "application/octet-stream", this.control);
421
+ if (!etag)
422
+ throw new Error("L’archivio non espone ETag: verifica la configurazione CORS del bucket.");
423
+ await postJson({ action: "record", uploadId: session.id, partNumber, etag,
424
+ digest, size: body.byteLength }, this.control);
270
425
  this.markUploaded(body.byteLength, true);
271
426
  }
427
+ async restore(offset, partsHash) {
428
+ await this.ensureSession();
429
+ const count = offset / PART;
430
+ const parts = [...this.priorParts.values()].filter(p => p.partNumber <= count).sort((a, b) => a.partNumber - b.partNumber);
431
+ const hash = await digestHex(enc.encode(JSON.stringify(parts.map(p => [p.partNumber, p.size, p.digest]))));
432
+ if (parts.length !== count || hash !== partsHash)
433
+ throw new Error("Le parti remote non corrispondono al checkpoint. La ripresa è stata fermata per proteggere l’integrità.");
434
+ this.partNumber = count;
435
+ this.totalBytes = offset;
436
+ this.uploadedBytes = offset;
437
+ }
438
+ async gate() { await this.control?.gate(); }
439
+ async checkpointBoundary() {
440
+ await this.drain();
441
+ if (this.buffered)
442
+ throw new Error("Checkpoint fuori dal confine di una parte.");
443
+ }
444
+ async settle() { await Promise.allSettled([...this.inFlight]); }
272
445
  async drain() {
273
- await Promise.all([...this.inFlight]);
446
+ while (this.inFlight.size) {
447
+ await Promise.race(this.inFlight);
448
+ await this.failIfNeeded();
449
+ }
274
450
  await this.failIfNeeded();
275
451
  }
276
452
  async failIfNeeded() {
277
453
  if (this.failure == null)
278
454
  return;
279
- await Promise.all([...this.inFlight]);
280
- const error = this.failure;
281
- this.failure = null;
282
- throw error;
455
+ // Il finally del motore abortisce e attende le altre richieste. Attenderle qui
456
+ // bloccherebbe un errore permanente dietro ai retry di un'altra parte.
457
+ throw this.failure;
283
458
  }
284
459
  startTransfer() {
285
460
  if (!this.transferStartedAt)
@@ -315,14 +490,28 @@ function firstStartedAt(...sinks) {
315
490
  return values.length ? Math.min(...values) : undefined;
316
491
  }
317
492
  export class ZipStream {
493
+ utc;
318
494
  sink;
319
495
  offset = 0;
320
496
  entries = [];
321
- constructor(sink) { this.sink = sink; }
322
- async add(nameValue, blob, progress, modified = new Date()) {
497
+ constructor(sink, utc = false) {
498
+ this.utc = utc;
499
+ this.sink = sink;
500
+ }
501
+ restore(entries, files, offset) {
502
+ this.offset = offset;
503
+ this.entries = entries.map((entry, i) => {
504
+ const name = enc.encode(entry.name);
505
+ const size = entry.original.length;
506
+ const [time, date] = dosDate(new Date(files[i].lastModified), this.utc);
507
+ return { name, size, crc: entry.original.crc32, time, date,
508
+ localOffset: entry.original.offset - 30 - name.length - (size >= 0xffffffff ? 20 : 0) };
509
+ });
510
+ }
511
+ async add(nameValue, blob, progress, modified = new Date(), checkpoint, resume) {
323
512
  const name = enc.encode(safePath(nameValue));
324
513
  const size = blob.size;
325
- const localOffset = this.offset;
514
+ const localOffset = resume?.localOffset ?? this.offset;
326
515
  const zip64 = size >= 0xffffffff;
327
516
  const extra = zip64 ? zip64Extra([size, size]) : new Uint8Array();
328
517
  const local = bytes(30 + name.length + extra.length);
@@ -331,7 +520,7 @@ export class ZipStream {
331
520
  v.setUint16(4, zip64 ? 45 : 20, true);
332
521
  v.setUint16(6, 0x0808, true); // descriptor + nomi UTF-8
333
522
  v.setUint16(8, 0, true); // STORE
334
- const [time, date] = dosDate(modified);
523
+ const [time, date] = dosDate(modified, this.utc);
335
524
  v.setUint16(10, time, true);
336
525
  v.setUint16(12, date, true);
337
526
  v.setUint32(18, zip64 ? 0xffffffff : 0, true);
@@ -340,15 +529,42 @@ export class ZipStream {
340
529
  v.setUint16(28, extra.length, true);
341
530
  local.set(name, 30);
342
531
  local.set(extra, 30 + name.length);
343
- await this.push(local);
344
- const dataOffset = this.offset;
532
+ if (!resume)
533
+ await this.push(local);
534
+ const dataOffset = resume?.dataOffset ?? this.offset;
345
535
  let crc = 0xffffffff;
346
536
  let read = 0;
537
+ let chain = "";
538
+ if (resume && (resume.name !== nameValue || resume.size !== size || resume.time !== time || resume.date !== date ||
539
+ resume.read > size || resume.read < 0 || dataOffset + resume.read !== this.offset))
540
+ throw new Error("File diverso da quello del checkpoint.");
541
+ // Verifica il prefisso del solo file interrotto; gli originali già conclusi sono
542
+ // la copia remota autorevole. Non mescolare un nuovo suffisso a un vecchio prefisso.
543
+ while (resume && read < resume.read) {
544
+ const length = Math.min(READ, PART - ((dataOffset + read) % PART), resume.read - read);
545
+ const chunk = new Uint8Array(await blob.slice(read, read + length).arrayBuffer());
546
+ if (chunk.length !== length)
547
+ throw new Error("Lettura sorgente incompleta durante la verifica.");
548
+ crc = crc32Update(crc, chunk);
549
+ chain = await digestHex(enc.encode(chain + ":" + await digestHex(chunk)));
550
+ read += length;
551
+ // La verifica può essere lunga: il gate deve continuare a rispondere ad annullamento.
552
+ await this.sinkGate();
553
+ }
554
+ if (resume && (chain !== resume.chain || crc !== resume.crc))
555
+ throw new Error("Il file interrotto è cambiato: impossibile riprenderlo senza alterare i dati.");
347
556
  while (read < size) {
348
- const chunk = new Uint8Array(await blob.slice(read, Math.min(size, read + READ)).arrayBuffer());
557
+ const length = Math.min(READ, size - read, checkpoint ? PART - (this.offset % PART) : READ);
558
+ const chunk = new Uint8Array(await blob.slice(read, read + length).arrayBuffer());
559
+ if (chunk.length !== length)
560
+ throw new Error("Lettura sorgente incompleta.");
349
561
  crc = crc32Update(crc, chunk);
562
+ if (checkpoint)
563
+ chain = await digestHex(enc.encode(chain + ":" + await digestHex(chunk)));
350
564
  read += chunk.byteLength;
351
565
  await this.push(chunk);
566
+ if (checkpoint && this.offset % PART === 0)
567
+ await checkpoint({ name: nameValue, size, read, crc, chain, localOffset, dataOffset, time, date }, this.offset);
352
568
  progress?.(read);
353
569
  }
354
570
  crc = (crc ^ 0xffffffff) >>> 0;
@@ -424,20 +640,21 @@ export class ZipStream {
424
640
  await this.push(end);
425
641
  await this.sink.close();
426
642
  }
643
+ async sinkGate() { await this.sink.gate?.(); }
427
644
  async push(data) {
428
645
  await this.sink.write(data);
429
646
  this.offset += data.byteLength;
430
647
  }
431
648
  }
432
- async function makeVariants(file, wantThumb) {
649
+ async function makeVariants(file, wantThumb, format = "webp") {
433
650
  const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
434
651
  try {
435
652
  const displaySize = fit(bitmap.width, bitmap.height, 2048);
436
- const display = await drawWebp(bitmap, displaySize.width, displaySize.height, .8);
653
+ const display = await drawImageBlob(bitmap, displaySize.width, displaySize.height, format === "jpg" ? .82 : .8, format === "jpg" ? "image/jpeg" : "image/webp");
437
654
  let thumb;
438
655
  if (wantThumb) {
439
656
  const thumbSize = fit(bitmap.width, bitmap.height, 640);
440
- thumb = await drawWebp(bitmap, thumbSize.width, thumbSize.height, .72);
657
+ thumb = await drawImageBlob(bitmap, thumbSize.width, thumbSize.height, .72);
441
658
  }
442
659
  return { display, thumb, width: displaySize.width, height: displaySize.height };
443
660
  }
@@ -445,14 +662,21 @@ async function makeVariants(file, wantThumb) {
445
662
  bitmap.close();
446
663
  }
447
664
  }
448
- async function drawWebp(bitmap, width, height, quality) {
665
+ async function drawImageBlob(bitmap, width, height, quality, type = "image/webp") {
449
666
  if (typeof OffscreenCanvas !== "undefined") {
450
667
  const canvas = new OffscreenCanvas(width, height);
451
668
  const ctx = canvas.getContext("2d", { alpha: false });
452
669
  if (!ctx)
453
670
  throw new Error("Canvas 2D non disponibile");
671
+ if (type === "image/jpeg") {
672
+ ctx.fillStyle = "#ffffff";
673
+ ctx.fillRect(0, 0, width, height);
674
+ }
454
675
  ctx.drawImage(bitmap, 0, 0, width, height);
455
- return canvas.convertToBlob({ type: "image/webp", quality });
676
+ const blob = await canvas.convertToBlob({ type, quality });
677
+ if (blob.type !== type)
678
+ throw new Error("Il browser non supporta il formato richiesto");
679
+ return blob;
456
680
  }
457
681
  const canvas = document.createElement("canvas");
458
682
  canvas.width = width;
@@ -460,21 +684,19 @@ async function drawWebp(bitmap, width, height, quality) {
460
684
  const ctx = canvas.getContext("2d", { alpha: false });
461
685
  if (!ctx)
462
686
  throw new Error("Canvas 2D non disponibile");
687
+ if (type === "image/jpeg") {
688
+ ctx.fillStyle = "#ffffff";
689
+ ctx.fillRect(0, 0, width, height);
690
+ }
463
691
  ctx.drawImage(bitmap, 0, 0, width, height);
464
- return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("Conversione WebP non riuscita")), "image/webp", quality));
692
+ return new Promise((resolve, reject) => canvas.toBlob((blob) => blob?.type === type ? resolve(blob) : reject(new Error("Conversione nel formato richiesto non riuscita")), type, quality));
465
693
  }
466
694
  function fit(width, height, edge) {
467
695
  const scale = Math.min(1, edge / Math.max(width, height));
468
696
  return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
469
697
  }
470
- async function postJson(body) {
471
- const res = await fetch("/admin/api/delivery/multipart", {
472
- method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
473
- });
474
- const answer = await res.json().catch(() => ({}));
475
- if (!res.ok || answer.ok === false)
476
- throw new Error(answer.error || `HTTP ${res.status}`);
477
- return answer;
698
+ function postJson(body, control) {
699
+ return deliveryJson("/admin/api/delivery/multipart", body, control);
478
700
  }
479
701
  export function setDeliveryRelativePath(file, path) {
480
702
  droppedPaths.set(file, safePath(path));
@@ -482,6 +704,60 @@ export function setDeliveryRelativePath(file, path) {
482
704
  export function deliveryRelativeName(file) {
483
705
  return droppedPaths.get(file) || file.webkitRelativePath || file.name;
484
706
  }
707
+ /** Solo metadati: nessuna lettura dei file o richiesta di rete prima del rifiuto. */
708
+ export function preflightDeliverySelection(files, options = { galleryEnabled: true, webEnabled: true }) {
709
+ if (!files.length)
710
+ throw new Error("Nessun file selezionato");
711
+ if (options.manifestVersion === 2 && files.length > 100_000)
712
+ throw new Error("Troppi file: il limite è 100.000 per invio.");
713
+ let sourceBytes = 0;
714
+ // Stima conservativa ZIP64: extra locali/centrali e descriptor per ogni membro.
715
+ let archiveBytes = 98;
716
+ let manifestBytes = 128; // envelope, data ISO, delimitatori
717
+ const names = new Set();
718
+ const variantNames = new Set();
719
+ const member = { offset: DELIVERY_MAX_ARCHIVE_BYTES, length: DELIVERY_MAX_ARCHIVE_BYTES, crc32: 0xffffffff };
720
+ for (const file of files) {
721
+ validateDeliverySourceBytes(file.size);
722
+ sourceBytes = validateDeliverySourceBytes(sourceBytes + file.size);
723
+ const name = safePath(deliveryRelativeName(file));
724
+ if (names.has(name))
725
+ throw new Error("Due file hanno lo stesso percorso nell’archivio. Seleziona cartelle con percorsi distinti.");
726
+ names.add(name);
727
+ const nameBytes = enc.encode(name).length;
728
+ if (options.manifestVersion === 2 && (nameBytes > 8192 || /[\x00-\x1f\x7f]/.test(name)))
729
+ throw new Error("Un percorso supera i limiti del nuovo formato (8 KiB, senza caratteri di controllo).");
730
+ if (enc.encode(file.type || "").length > 256)
731
+ throw new Error("Il tipo di un file è troppo lungo.");
732
+ if ((options.galleryEnabled || options.webEnabled) && IMAGE.test(file.name)) {
733
+ const variantName = (options.manifestVersion === 2 ? name : name.replace(/\.[^.\/]+$/, "")) + (options.webEnabled && options.webFormat === "jpg" ? ".jpg" : ".webp");
734
+ if (variantNames.has(variantName))
735
+ throw new Error("Due immagini produrrebbero lo stesso nome di anteprima. Seleziona cartelle con percorsi distinti.");
736
+ variantNames.add(variantName);
737
+ }
738
+ const entry = { name, type: file.type || mimeFromName(name), original: member };
739
+ if ((options.galleryEnabled || options.webEnabled) && IMAGE.test(file.name)) {
740
+ entry.web = member;
741
+ if (options.galleryEnabled)
742
+ entry.thumb = member;
743
+ entry.width = Number.MAX_SAFE_INTEGER;
744
+ entry.height = Number.MAX_SAFE_INTEGER;
745
+ // Anche l'errore di conversione ha un budget; conteggiare entrambi è prudente.
746
+ manifestBytes += enc.encode(JSON.stringify({ name, reason: "\u0000".repeat(DELIVERY_SKIP_REASON_CHARS) })).length + 1;
747
+ }
748
+ manifestBytes += enc.encode(JSON.stringify(entry)).length + 1;
749
+ if (manifestBytes > (options.manifestVersion === 2 ? 64 * 1024 * 1024 : DELIVERY_V1_MANIFEST_BYTES)) {
750
+ throw new Error("Troppi metadati per questo formato di invio: dividi la selezione in più invii prima di caricare i file.");
751
+ }
752
+ if (nameBytes > 65_535)
753
+ throw new Error("Un percorso è troppo lungo per l’archivio ZIP: accorcialo prima di inviare.");
754
+ archiveBytes += file.size + 148 + 2 * nameBytes;
755
+ if (archiveBytes > DELIVERY_MAX_ARCHIVE_BYTES) {
756
+ throw new Error("I file e i loro percorsi superano il limite dell’archivio: dividi l’invio.");
757
+ }
758
+ }
759
+ return { sourceBytes, archiveBytes, manifestBytes };
760
+ }
485
761
  export function deliverySelectionName(files) {
486
762
  const first = files[0];
487
763
  if (!first)
@@ -512,7 +788,7 @@ async function makeHeroVariant(file) {
512
788
  try {
513
789
  const size = fit(bitmap.width, bitmap.height, 2560);
514
790
  return {
515
- blob: await drawWebp(bitmap, size.width, size.height, .82),
791
+ blob: await drawImageBlob(bitmap, size.width, size.height, .82),
516
792
  width: size.width, height: size.height,
517
793
  };
518
794
  }
@@ -520,9 +796,9 @@ async function makeHeroVariant(file) {
520
796
  bitmap.close();
521
797
  }
522
798
  }
523
- export async function deliverySourceFingerprint(files) {
799
+ export async function deliverySourceFingerprint(files, version = 1) {
524
800
  const lines = [...files]
525
- .sort((a, b) => deliveryRelativeName(a).localeCompare(deliveryRelativeName(b), undefined, {
801
+ .sort((a, b) => version === 2 ? (deliveryRelativeName(a) < deliveryRelativeName(b) ? -1 : deliveryRelativeName(a) > deliveryRelativeName(b) ? 1 : 0) : deliveryRelativeName(a).localeCompare(deliveryRelativeName(b), undefined, {
526
802
  numeric: true, sensitivity: "base",
527
803
  }))
528
804
  .map((file) => `${safePath(deliveryRelativeName(file))}\0${file.size}\0${file.lastModified}`)
@@ -554,12 +830,14 @@ function zip64Extra(values) {
554
830
  values.forEach((value, i) => setU64(v, 4 + i * 8, value));
555
831
  return out;
556
832
  }
557
- function dosDate(date) {
558
- const year = Math.max(1980, date.getFullYear());
559
- return [
560
- (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
561
- ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
562
- ];
833
+ function dosDate(date, utc = false) {
834
+ const year = Math.min(2107, Math.max(1980, utc ? date.getUTCFullYear() : date.getFullYear()));
835
+ const hour = utc ? date.getUTCHours() : date.getHours();
836
+ const minute = utc ? date.getUTCMinutes() : date.getMinutes();
837
+ const second = utc ? date.getUTCSeconds() : date.getSeconds();
838
+ const month = utc ? date.getUTCMonth() : date.getMonth();
839
+ const day = utc ? date.getUTCDate() : date.getDate();
840
+ return [(hour << 11) | (minute << 5) | Math.floor(second / 2), ((year - 1980) << 9) | ((month + 1) << 5) | day];
563
841
  }
564
842
  const CRC_TABLE = (() => {
565
843
  const table = new Uint32Array(256);
@@ -577,7 +855,6 @@ function crc32Update(crc, data) {
577
855
  c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
578
856
  return c >>> 0;
579
857
  }
580
- function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
581
858
  async function digestHex(data) {
582
859
  const digest = await crypto.subtle.digest("SHA-256", data.buffer);
583
860
  return [...new Uint8Array(digest)]