@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.
- package/dist/app/app.css +3 -3
- package/dist/app/delivery.d.ts +11 -0
- package/dist/app/delivery.js +46 -0
- package/dist/app/index.d.ts +4 -1
- package/dist/app/index.js +3 -2
- package/dist/app/lib/deliveries.d.ts +8 -0
- package/dist/app/lib/delivery-network.d.ts +25 -0
- package/dist/app/lib/delivery-network.js +159 -0
- package/dist/app/lib/delivery-upload.d.ts +29 -11
- package/dist/app/lib/delivery-upload.js +408 -131
- package/dist/app/lib/upload-queue.d.ts +21 -3
- package/dist/app/lib/upload-queue.js +76 -19
- package/dist/app/routes/Deliveries.js +108 -29
- package/dist/app/routes/UploadDock.js +6 -2
- package/dist/direct-publication.d.ts +20 -0
- package/dist/direct-publication.js +75 -0
- package/dist/functions/_shared/b2.js +22 -3
- package/dist/functions/_shared/delivery-crypto.js +5 -0
- package/dist/functions/_shared/delivery-limits.d.ts +9 -0
- package/dist/functions/_shared/delivery-limits.js +29 -0
- package/dist/functions/_shared/delivery-manifest-v2.sql +49 -0
- package/dist/functions/_shared/delivery-manifest.js +392 -0
- package/dist/functions/_shared/delivery-purge.js +17 -1
- package/dist/functions/api/deliveries.js +177 -49
- package/dist/functions/api/delivery/image.js +11 -6
- package/dist/functions/api/delivery/multipart.js +196 -64
- package/dist/functions/delivery-public/assets/client.js +246 -0
- package/dist/functions/delivery-public/assets/delivery.css +297 -0
- package/dist/functions/delivery-public/assets/gallery.js +243 -0
- package/dist/functions/delivery-public/assets/stage.js +107 -0
- package/dist/functions/delivery-public/index.js +637 -0
- package/dist/functions/delivery-public/migrations/0002_deliveries.sql +128 -0
- package/dist/functions/delivery-public/migrations/0003_delivery_quick_hero.sql +34 -0
- package/dist/functions/delivery-public/migrations/0004_delivery_file_expiry.sql +9 -0
- package/dist/functions/delivery-public/migrations/0006_delivery_manifest_v2.sql +49 -0
- package/dist/functions/delivery-public/migrations/0007_delivery_link_aliases.sql +8 -0
- package/dist/functions/delivery-public/migrations/0008_delivery_web_format.sql +2 -0
- package/dist/functions/delivery-public/migrations/0009_delivery_download_labels.sql +2 -0
- package/dist/functions/index.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +10 -3
|
@@ -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,
|
|
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
|
|
75
|
+
if (!FILENAMES[variant]) throw invalid("variante upload non valida");
|
|
69
76
|
if (variant === "web" && !pkg.web_enabled && !pkg.gallery_enabled) {
|
|
70
|
-
throw
|
|
77
|
+
throw invalid("versione web non richiesta");
|
|
71
78
|
}
|
|
72
|
-
if (variant === "thumbs" && !pkg.gallery_enabled) throw
|
|
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
|
|
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
|
|
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
|
|
134
|
+
if (!FILENAMES[variant]) throw invalid("variante multipart non valida");
|
|
109
135
|
if (variant === "web" && !pkg.web_enabled && !pkg.gallery_enabled) {
|
|
110
|
-
throw
|
|
136
|
+
throw invalid("versione web non richiesta");
|
|
111
137
|
}
|
|
112
|
-
if (variant === "thumbs" && !pkg.gallery_enabled) throw
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
env.DB.
|
|
130
|
-
|
|
131
|
-
UPDATE delivery_uploads SET object_key=?,b2_upload_id=?,state='uploading',
|
|
132
|
-
created_at=?,updated_at=?,completed_at=NULL WHERE id=?
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
165
|
-
throw
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
})();
|