@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
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* nudo, con le sole `env.DB` e le variabili B2.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
373
|
+
sourceBytes, Number(body.storedBytes || 0), now, packageId,
|
|
314
374
|
).run();
|
|
315
|
-
|
|
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=?,
|
|
324
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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) &&
|
|
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
|
|
15
|
+
SELECT * FROM delivery_packages
|
|
14
16
|
WHERE id=? AND state='ready'
|
|
15
17
|
`).bind(packageId).first();
|
|
16
|
-
const
|
|
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 (
|
|
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",
|