@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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { canonicalChecksum, } from "./persistence.js";
|
|
2
|
+
import { createReleaseExport } from "./release.js";
|
|
3
|
+
/** A SELECT guard belongs inside the batch: a failed conditional must abort
|
|
4
|
+
* every mutation, including when an UPDATE would silently affect zero rows. */
|
|
5
|
+
export function transactionAssertion(db, condition, values = []) {
|
|
6
|
+
return db
|
|
7
|
+
.prepare(`SELECT CASE WHEN (${condition}) THEN 1 ELSE json('adminkit assertion failed') END AS passed`)
|
|
8
|
+
.bind(...values);
|
|
9
|
+
}
|
|
10
|
+
export async function previewCandidateChecksum(baseReleaseId, entries) {
|
|
11
|
+
return canonicalChecksum({
|
|
12
|
+
baseReleaseId,
|
|
13
|
+
entries: entries
|
|
14
|
+
.map(({ documentId, revisionId, payloadChecksum }) => ({
|
|
15
|
+
documentId,
|
|
16
|
+
revisionId,
|
|
17
|
+
payloadChecksum,
|
|
18
|
+
}))
|
|
19
|
+
.sort((a, b) => a.documentId.localeCompare(b.documentId)),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export async function commitDirectRelease(db, input) {
|
|
23
|
+
const { release, selectedDrafts, expectedBaseReleaseId, authorSubject, now } = input;
|
|
24
|
+
const checked = await createReleaseExport(release);
|
|
25
|
+
if (checked.manifestChecksum !== release.manifestChecksum ||
|
|
26
|
+
!selectedDrafts.length ||
|
|
27
|
+
release.siteAccess)
|
|
28
|
+
throw new Error("invalid_release_export");
|
|
29
|
+
for (const selected of selectedDrafts)
|
|
30
|
+
if (!release.entries.some((e) => e.documentId === selected.documentId &&
|
|
31
|
+
e.revisionId === selected.revisionId))
|
|
32
|
+
throw new Error("invalid_release_export");
|
|
33
|
+
const statements = [
|
|
34
|
+
transactionAssertion(db, `EXISTS(SELECT 1 FROM content_site_state WHERE singleton=1 AND current_release_id=?) AND NOT EXISTS(SELECT 1 FROM content_release_site_access WHERE release_id=?) AND NOT EXISTS(SELECT 1 FROM production_deployment_attempts WHERE phase NOT IN ('succeeded','failed','superseded'))`, [expectedBaseReleaseId, expectedBaseReleaseId]),
|
|
35
|
+
];
|
|
36
|
+
for (const d of selectedDrafts)
|
|
37
|
+
statements.push(transactionAssertion(db, "EXISTS(SELECT 1 FROM content_documents WHERE document_id=? AND draft_revision_id=?)", [d.documentId, d.revisionId]));
|
|
38
|
+
for (const e of release.entries)
|
|
39
|
+
statements.push(transactionAssertion(db, `EXISTS(SELECT 1 FROM content_revisions r JOIN content_documents d ON d.document_id=r.document_id WHERE r.document_id=? AND r.revision_id=? AND r.payload_checksum=? AND r.schema_version=? AND r.is_tombstone=? AND d.kind=? AND d.locale=?)`, [
|
|
40
|
+
e.documentId,
|
|
41
|
+
e.revisionId,
|
|
42
|
+
e.payloadChecksum,
|
|
43
|
+
e.schemaVersion,
|
|
44
|
+
e.tombstone ? 1 : 0,
|
|
45
|
+
e.kind,
|
|
46
|
+
e.locale,
|
|
47
|
+
]));
|
|
48
|
+
// Every unselected document must retain the exact public revision. New
|
|
49
|
+
// documents and removals must be explicitly selected, never implicit edits.
|
|
50
|
+
const selectedIds = new Set(selectedDrafts.map((d) => d.documentId));
|
|
51
|
+
for (const e of release.entries)
|
|
52
|
+
if (!selectedIds.has(e.documentId))
|
|
53
|
+
statements.push(transactionAssertion(db, "EXISTS(SELECT 1 FROM content_release_entries WHERE release_id=? AND document_id=? AND revision_id=?)", [expectedBaseReleaseId, e.documentId, e.revisionId]));
|
|
54
|
+
const placeholders = release.entries.map(() => "?").join(",");
|
|
55
|
+
statements.push(transactionAssertion(db, `NOT EXISTS(SELECT 1 FROM content_release_entries WHERE release_id=? AND document_id NOT IN (${placeholders}))`, [expectedBaseReleaseId, ...release.entries.map((e) => e.documentId)]));
|
|
56
|
+
statements.push(db
|
|
57
|
+
.prepare(`INSERT INTO content_releases(release_id,base_release_id,request_checksum,status,active_slot,manifest_checksum,schema_package_version,materializer_version,created_by_subject,created_at,finished_at) VALUES(?,?,?,'published',NULL,?,?,?,?,?,?)`)
|
|
58
|
+
.bind(release.releaseId, expectedBaseReleaseId, await canonicalChecksum({
|
|
59
|
+
releaseId: release.releaseId,
|
|
60
|
+
manifestChecksum: release.manifestChecksum,
|
|
61
|
+
}), release.manifestChecksum, release.schemaPackageVersion, release.materializerVersion, authorSubject, now, now));
|
|
62
|
+
for (const e of release.entries)
|
|
63
|
+
statements.push(db
|
|
64
|
+
.prepare("INSERT INTO content_release_entries(release_id,document_id,revision_id) VALUES(?,?,?)")
|
|
65
|
+
.bind(release.releaseId, e.documentId, e.revisionId));
|
|
66
|
+
for (const d of selectedDrafts)
|
|
67
|
+
statements.push(db
|
|
68
|
+
.prepare("UPDATE content_documents SET draft_revision_id=NULL,updated_at=? WHERE document_id=? AND draft_revision_id=?")
|
|
69
|
+
.bind(now, d.documentId, d.revisionId));
|
|
70
|
+
statements.push(db
|
|
71
|
+
.prepare("UPDATE content_site_state SET current_release_id=?,updated_at=? WHERE singleton=1 AND current_release_id=?")
|
|
72
|
+
.bind(release.releaseId, now, expectedBaseReleaseId));
|
|
73
|
+
statements.push(transactionAssertion(db, "EXISTS(SELECT 1 FROM content_site_state WHERE singleton=1 AND current_release_id=?)", [release.releaseId]));
|
|
74
|
+
await db.batch(statements);
|
|
75
|
+
}
|
|
@@ -50,7 +50,7 @@ export async function b2Request(env, method, key, options = {}) {
|
|
|
50
50
|
`AWS4-HMAC-SHA256 Credential=${env.B2_APPLICATION_KEY_ID}/${scope}, ` +
|
|
51
51
|
`SignedHeaders=${signedNames.join(";")}, Signature=${signature}`);
|
|
52
52
|
|
|
53
|
-
return fetch(url, { method, headers, body });
|
|
53
|
+
return fetch(url, { method, headers, body, signal: method === "GET" && !options.query?.uploadId ? undefined : AbortSignal.timeout(60_000) });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export async function b2PresignUploadPart(env, key, uploadId, partNumber, expires = 900) {
|
|
@@ -121,7 +121,7 @@ export async function b2CompleteMultipart(env, key, uploadId, parts) {
|
|
|
121
121
|
body,
|
|
122
122
|
});
|
|
123
123
|
const text = await res.text();
|
|
124
|
-
if (!res.ok) throw new Error(`B2 complete multipart ${res.status}: ${text.slice(0, 300)}`);
|
|
124
|
+
if (!res.ok || /<Error[>\s]/.test(text)) throw new Error(`B2 complete multipart ${res.status}: ${text.slice(0, 300)}`);
|
|
125
125
|
return { etag: xml(text, "ETag"), versionId: xml(text, "VersionId") };
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -140,7 +140,7 @@ export async function b2ListParts(env, key, uploadId) {
|
|
|
140
140
|
if (marker) query["part-number-marker"] = marker;
|
|
141
141
|
const res = await b2Request(env, "GET", key, { query });
|
|
142
142
|
const text = await res.text();
|
|
143
|
-
if (!res.ok) throw new Error(`B2 list parts ${res.status}: ${text.slice(0, 300)}`);
|
|
143
|
+
if (!res.ok) throw Object.assign(new Error(`B2 list parts ${res.status}: ${text.slice(0, 300)}`), { status: res.status });
|
|
144
144
|
for (const block of text.matchAll(/<Part>([\s\S]*?)<\/Part>/g)) {
|
|
145
145
|
parts.push({
|
|
146
146
|
partNumber: Number(xml(block[1], "PartNumber")),
|
|
@@ -189,6 +189,25 @@ export function b2Get(env, key, range) {
|
|
|
189
189
|
return b2Request(env, "GET", key, { headers });
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
export async function b2Head(env, key) {
|
|
193
|
+
const res = await b2Request(env, "HEAD", key);
|
|
194
|
+
if (res.status === 404) return null;
|
|
195
|
+
if (!res.ok) throw new Error(`B2 head ${res.status}`);
|
|
196
|
+
return { bytes: Number(res.headers.get("content-length")), etag: (res.headers.get("etag") || "").replaceAll('"', "") };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** ETag multipart S3: MD5 dei digest binari delle parti. MD5 è supportato da Workers
|
|
200
|
+
* per interoperabilità; non viene usato per autenticazione o hashing di segreti. */
|
|
201
|
+
export async function multipartEtag(parts) {
|
|
202
|
+
const tags = parts.map((p) => String(p.etag).replaceAll('"', ""));
|
|
203
|
+
if (tags.some((tag) => !/^[a-f0-9]{32}$/i.test(tag))) return null;
|
|
204
|
+
const binary = new Uint8Array(tags.length * 16);
|
|
205
|
+
tags.forEach((tag, n) => {
|
|
206
|
+
for (let i = 0; i < 16; i++) binary[n * 16 + i] = parseInt(tag.slice(i * 2, i * 2 + 2), 16);
|
|
207
|
+
});
|
|
208
|
+
return `${hex(await crypto.subtle.digest("MD5", binary))}-${parts.length}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
192
211
|
export async function b2Delete(env, key) {
|
|
193
212
|
const res = await b2Request(env, "DELETE", key);
|
|
194
213
|
if (!res.ok && res.status !== 404) {
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
const enc = new TextEncoder();
|
|
9
9
|
const dec = new TextDecoder();
|
|
10
10
|
|
|
11
|
+
// Public bearer links: 12 cryptographically random bytes = 16 base64url chars
|
|
12
|
+
// (96 bits). Keep other tokens at their existing lengths; never truncate old links.
|
|
13
|
+
export const DELIVERY_LINK_TOKEN_BYTES = 12;
|
|
14
|
+
export const DELIVERY_LINK_MIN_LENGTH = 16;
|
|
15
|
+
|
|
11
16
|
export function b64url(bytes) {
|
|
12
17
|
let s = "";
|
|
13
18
|
const a = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const DELIVERY_MAX_SOURCE_BYTES: number;
|
|
2
|
+
export const DELIVERY_PART_BYTES: number;
|
|
3
|
+
export const DELIVERY_MAX_PARTS: number;
|
|
4
|
+
export const DELIVERY_MAX_ARCHIVE_BYTES: number;
|
|
5
|
+
export function validateDeliverySourceBytes(value: unknown): number;
|
|
6
|
+
|
|
7
|
+
export const DELIVERY_V1_MANIFEST_BYTES: number;
|
|
8
|
+
export const DELIVERY_SKIP_REASON_CHARS: number;
|
|
9
|
+
export function validateDeliveryManifestV1(json: string): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Contratto condiviso tra browser e API. GB decimali, per pacchetto di originali.
|
|
2
|
+
export const DELIVERY_MAX_SOURCE_BYTES = 150_000_000_000;
|
|
3
|
+
export const DELIVERY_PART_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
export const DELIVERY_MAX_PARTS = 10_000;
|
|
5
|
+
export const DELIVERY_MAX_ARCHIVE_BYTES = DELIVERY_PART_BYTES * DELIVERY_MAX_PARTS;
|
|
6
|
+
|
|
7
|
+
export function validateDeliverySourceBytes(value) {
|
|
8
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
9
|
+
throw limitError("La dimensione dei file non è valida", 400);
|
|
10
|
+
}
|
|
11
|
+
if (value > DELIVERY_MAX_SOURCE_BYTES) {
|
|
12
|
+
throw limitError("Il limite è 150 GB per invio di file. Dividi i file in più invii; questo caricamento non può essere avviato o ripreso.", 413);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function limitError(message, status) {
|
|
18
|
+
return Object.assign(new Error(message), { status, code: "delivery_size_limit", retryable: false });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Il margine lascia spazio agli altri campi della riga D1 (limite 2 MB).
|
|
22
|
+
export const DELIVERY_V1_MANIFEST_BYTES = 1_800_000;
|
|
23
|
+
export const DELIVERY_SKIP_REASON_CHARS = 256;
|
|
24
|
+
export function validateDeliveryManifestV1(json) {
|
|
25
|
+
if (new TextEncoder().encode(json).byteLength > DELIVERY_V1_MANIFEST_BYTES) {
|
|
26
|
+
throw Object.assign(new Error("L’elenco dei file supera la capacità di questo formato di invio. Dividi la selezione in più invii."),
|
|
27
|
+
{ status: 413, code: "delivery_manifest_limit", retryable: false });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- Apply to the consumer's delivery DB AFTER its delivery_packages migration.
|
|
2
|
+
-- Separate from AdminKit's editorial CONTENT_DB migrations. Idempotent.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS delivery_manifest_state (
|
|
4
|
+
package_id TEXT PRIMARY KEY REFERENCES delivery_packages(id) ON DELETE CASCADE,
|
|
5
|
+
state TEXT NOT NULL DEFAULT 'uploading' CHECK(state IN ('uploading','sealed','ready')),
|
|
6
|
+
created_at TEXT NOT NULL,
|
|
7
|
+
writer_token TEXT,
|
|
8
|
+
writer_until INTEGER NOT NULL DEFAULT 0,
|
|
9
|
+
metadata_bytes INTEGER NOT NULL DEFAULT 0 CHECK(metadata_bytes BETWEEN 0 AND 67108864),
|
|
10
|
+
cursor INTEGER NOT NULL DEFAULT 0,
|
|
11
|
+
page_count INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
root_json TEXT
|
|
13
|
+
);
|
|
14
|
+
CREATE TABLE IF NOT EXISTS delivery_manifest_entries (
|
|
15
|
+
package_id TEXT NOT NULL REFERENCES delivery_manifest_state(package_id) ON DELETE CASCADE,
|
|
16
|
+
entry_id INTEGER NOT NULL CHECK(entry_id>=0),
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
has_preview INTEGER NOT NULL DEFAULT 0,
|
|
19
|
+
requires_variant INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
variant_done INTEGER NOT NULL DEFAULT 0,
|
|
21
|
+
source_bytes INTEGER NOT NULL CHECK(source_bytes>=0),
|
|
22
|
+
entry_json TEXT NOT NULL CHECK(length(CAST(entry_json AS BLOB))<=131072),
|
|
23
|
+
PRIMARY KEY(package_id,entry_id), UNIQUE(package_id,name)
|
|
24
|
+
);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS delivery_manifest_gallery ON delivery_manifest_entries(package_id,has_preview,entry_id);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS delivery_manifest_receipts (
|
|
27
|
+
package_id TEXT NOT NULL REFERENCES delivery_manifest_state(package_id) ON DELETE CASCADE,
|
|
28
|
+
batch_id TEXT NOT NULL, digest TEXT NOT NULL,
|
|
29
|
+
PRIMARY KEY(package_id,batch_id)
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS delivery_manifest_objects (
|
|
32
|
+
package_id TEXT NOT NULL REFERENCES delivery_manifest_state(package_id) ON DELETE CASCADE,
|
|
33
|
+
page_number INTEGER NOT NULL,
|
|
34
|
+
object_key TEXT NOT NULL UNIQUE, digest TEXT NOT NULL,
|
|
35
|
+
end_cursor INTEGER NOT NULL, entry_count INTEGER NOT NULL,
|
|
36
|
+
verified INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
PRIMARY KEY(package_id,page_number)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS delivery_manifest_claims (
|
|
41
|
+
package_id TEXT NOT NULL REFERENCES delivery_manifest_state(package_id) ON DELETE CASCADE,
|
|
42
|
+
phase TEXT NOT NULL, entry_id INTEGER NOT NULL,
|
|
43
|
+
PRIMARY KEY(package_id,phase,entry_id)
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS delivery_checkpoints (
|
|
46
|
+
package_id TEXT PRIMARY KEY REFERENCES delivery_manifest_state(package_id) ON DELETE CASCADE,
|
|
47
|
+
offset INTEGER NOT NULL,
|
|
48
|
+
checkpoint_json TEXT NOT NULL CHECK(length(CAST(checkpoint_json AS BLOB))<=32768)
|
|
49
|
+
);
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/* V2: bounded metadata ingestion, immutable snapshots and recoverable publication.
|
|
2
|
+
* The consumer must install delivery-manifest-v2.sql and all public readers before
|
|
3
|
+
* explicitly enabling DELIVERY_MANIFEST_V2. Readers remain available when disabled.
|
|
4
|
+
*/
|
|
5
|
+
import { b2ObjectKey, b2PutSmall, b2Head } from './b2.js';
|
|
6
|
+
import { sha256 } from './delivery-crypto.js';
|
|
7
|
+
import { DELIVERY_MAX_ARCHIVE_BYTES, validateDeliverySourceBytes } from './delivery-limits.js';
|
|
8
|
+
export const MANIFEST_BATCH_ENTRIES = 200;
|
|
9
|
+
export const MANIFEST_BATCH_BYTES = 256 * 1024;
|
|
10
|
+
export const MANIFEST_MAX_ENTRIES = 100_000;
|
|
11
|
+
export const MANIFEST_MAX_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
const bytes = value => enc.encode(value).length;
|
|
14
|
+
const fail = (message, status = 400) => Object.assign(new Error(message), { status, code: 'delivery_manifest', retryable: false });
|
|
15
|
+
export function manifestVersion(pkg) { return parseManifest(pkg?.manifest_json).version === 2 ? 2 : 1; }
|
|
16
|
+
export function parseManifest(json) { try { return JSON.parse(json || '{}'); } catch { return {}; } }
|
|
17
|
+
export function v2Enabled(env) { return env.DELIVERY_MANIFEST_V2 === 'true'; }
|
|
18
|
+
|
|
19
|
+
export async function readManifestEntry(env, pkg, index) {
|
|
20
|
+
if (!Number.isSafeInteger(index) || index < 0) return null;
|
|
21
|
+
if (manifestVersion(pkg) === 1) return parseManifest(pkg.manifest_json).entries?.[index] || null;
|
|
22
|
+
const row = await env.DB.prepare('SELECT entry_json FROM delivery_manifest_entries WHERE package_id=? AND entry_id=?')
|
|
23
|
+
.bind(pkg.id, index).first();
|
|
24
|
+
return row ? JSON.parse(row.entry_json) : null;
|
|
25
|
+
}
|
|
26
|
+
export async function readManifestPage(env, pkg, after = -1, limit = 100, imagesOnly = true) {
|
|
27
|
+
if (!Number.isSafeInteger(after) || after < -1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw fail('Pagina non valida');
|
|
28
|
+
let rows;
|
|
29
|
+
if (manifestVersion(pkg) === 1) {
|
|
30
|
+
rows = (parseManifest(pkg.manifest_json).entries || []).map((entry, index) => ({ index, entry }))
|
|
31
|
+
.filter(row => row.index > after && (!imagesOnly || row.entry.web)).slice(0, limit + 1);
|
|
32
|
+
} else {
|
|
33
|
+
const out = await env.DB.prepare(`SELECT entry_id,entry_json FROM delivery_manifest_entries
|
|
34
|
+
WHERE package_id=? ${imagesOnly ? 'AND has_preview=1' : ''} AND entry_id>? ORDER BY entry_id LIMIT ?`)
|
|
35
|
+
.bind(pkg.id, after, limit + 1).all();
|
|
36
|
+
rows = (out.results || []).map(row => ({ index: row.entry_id, entry: JSON.parse(row.entry_json) }));
|
|
37
|
+
}
|
|
38
|
+
const more = rows.length > limit;
|
|
39
|
+
rows = rows.slice(0, limit);
|
|
40
|
+
return { entries: rows, nextCursor: more ? rows.at(-1).index : null };
|
|
41
|
+
}
|
|
42
|
+
async function activePackage(env, body, write = false) {
|
|
43
|
+
const pkg = await env.DB.prepare(`SELECT p.* FROM delivery_packages p JOIN deliveries d ON d.id=p.delivery_id
|
|
44
|
+
WHERE p.id=? AND p.delivery_id=? AND p.state IN ('uploading','ready') AND d.status!='deleted'`)
|
|
45
|
+
.bind(String(body.packageId || ''), String(body.id || '')).first();
|
|
46
|
+
if (!pkg || manifestVersion(pkg) !== 2) throw fail('Pacchetto v2 non disponibile', 409);
|
|
47
|
+
if (write) await assertManifestWriter(env, pkg.id, body.writerToken);
|
|
48
|
+
return pkg;
|
|
49
|
+
}
|
|
50
|
+
function member(value) {
|
|
51
|
+
if (!value || !['offset','length','crc32'].every(key => Number.isSafeInteger(value[key]) && value[key] >= 0) ||
|
|
52
|
+
value.crc32 > 0xffffffff || value.offset + value.length > DELIVERY_MAX_ARCHIVE_BYTES) throw fail('Intervallo archivio non valido');
|
|
53
|
+
return { offset: value.offset, length: value.length, crc32: value.crc32 };
|
|
54
|
+
}
|
|
55
|
+
function entry(value) {
|
|
56
|
+
if (!value || typeof value.name !== 'string' || !value.name || bytes(value.name) > 8192 ||
|
|
57
|
+
value.name.startsWith('/') || value.name.includes('\\') || value.name.split('/').some(x => !x || x === '.' || x === '..') || /[\x00-\x1f\x7f]/.test(value.name)) throw fail('Percorso non valido');
|
|
58
|
+
if (typeof value.type !== 'string' || bytes(value.type) > 256) throw fail('Tipo file non valido');
|
|
59
|
+
const out = { name: value.name, type: value.type, original: member(value.original) };
|
|
60
|
+
for (const kind of ['web','thumb']) if (value[kind]) out[kind] = member(value[kind]);
|
|
61
|
+
for (const dim of ['width','height']) if (value[dim] != null) {
|
|
62
|
+
if (!Number.isSafeInteger(value[dim]) || value[dim] < 1) throw fail('Dimensioni immagine non valide');
|
|
63
|
+
out[dim] = value[dim];
|
|
64
|
+
}
|
|
65
|
+
if (value.skipped != null) {
|
|
66
|
+
if (typeof value.skipped !== 'string' || value.skipped.length > 256) throw fail('Errore conversione troppo lungo');
|
|
67
|
+
out.skipped = value.skipped;
|
|
68
|
+
}
|
|
69
|
+
if (bytes(JSON.stringify(out)) > 131072) throw fail('Metadati del file troppo grandi', 413);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function ingestManifest(env, body) {
|
|
74
|
+
const pkg = await activePackage(env, body, true);
|
|
75
|
+
if (!['originals', 'variants'].includes(body.phase) || !Array.isArray(body.entries) || !body.entries.length ||
|
|
76
|
+
body.entries.length > MANIFEST_BATCH_ENTRIES || bytes(JSON.stringify(body.entries)) > MANIFEST_BATCH_BYTES) throw fail('Batch manifest non valido', 413);
|
|
77
|
+
if (typeof body.batchId !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(body.batchId)) throw fail('Identità batch non valida');
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const rows = body.entries.map(row => {
|
|
80
|
+
if (!Number.isSafeInteger(row.index) || row.index < 0 || row.index >= pkg.file_count || seen.has(row.index)) throw fail('Indice file non valido');
|
|
81
|
+
seen.add(row.index);
|
|
82
|
+
const clean = entry(row.entry);
|
|
83
|
+
if (body.phase === 'originals' && (clean.web || clean.thumb || clean.skipped != null)) throw fail('Originali e varianti richiedono fasi distinte');
|
|
84
|
+
if (body.phase === 'variants' && clean.skipped == null && (!clean.web || (pkg.gallery_enabled && !clean.thumb))) throw fail('Varianti incomplete');
|
|
85
|
+
return { index: row.index, entry: clean, requiresVariant: Number(!!(pkg.web_enabled || pkg.gallery_enabled) && /\.(jpe?g|png|webp)$/i.test(clean.name)) };
|
|
86
|
+
});
|
|
87
|
+
const payload = JSON.stringify(rows);
|
|
88
|
+
const digest = await sha256(body.phase + ':' + payload);
|
|
89
|
+
const receipt = await env.DB.prepare('SELECT digest FROM delivery_manifest_receipts WHERE package_id=? AND batch_id=?').bind(pkg.id, body.batchId).first();
|
|
90
|
+
if (receipt) {
|
|
91
|
+
if (receipt.digest !== digest) throw fail('Il batch esiste con contenuti diversi', 409);
|
|
92
|
+
return { accepted: true };
|
|
93
|
+
}
|
|
94
|
+
const state = await env.DB.prepare('SELECT * FROM delivery_manifest_state WHERE package_id=?').bind(pkg.id).first();
|
|
95
|
+
if (pkg.state !== 'uploading' || state?.state !== 'uploading') throw fail('Il manifest è già congelato', 409);
|
|
96
|
+
const prior = await env.DB.prepare(`SELECT entry_id,entry_json FROM delivery_manifest_entries WHERE package_id=?
|
|
97
|
+
AND entry_id IN (SELECT json_extract(value,'$.index') FROM json_each(?))`).bind(pkg.id, payload).all();
|
|
98
|
+
const previous = new Map((prior.results || []).map(row => [row.entry_id, JSON.parse(row.entry_json)]));
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
const old = previous.get(row.index);
|
|
101
|
+
if (body.phase === 'originals' && old) throw fail('Originale già registrato da un altro batch', 409);
|
|
102
|
+
if (body.phase === 'variants' && (!old || old.name !== row.entry.name || old.type !== row.entry.type ||
|
|
103
|
+
JSON.stringify(old.original) !== JSON.stringify(row.entry.original))) throw fail('La variante non corrisponde all’originale', 409);
|
|
104
|
+
}
|
|
105
|
+
const delta = rows.reduce((n, row) => n + bytes(JSON.stringify(row.entry)) -
|
|
106
|
+
(previous.has(row.index) ? bytes(JSON.stringify(previous.get(row.index))) : 0), 0);
|
|
107
|
+
if (state.metadata_bytes + delta > MANIFEST_MAX_BYTES) throw fail('Troppi metadati per questo invio', 413);
|
|
108
|
+
// Budget computable before mutation; the transaction also rechecks it to close races.
|
|
109
|
+
const guard = `EXISTS (SELECT 1 FROM delivery_manifest_state s JOIN delivery_packages p ON p.id=s.package_id
|
|
110
|
+
WHERE s.package_id=? AND s.state='uploading' AND p.state='uploading')
|
|
111
|
+
AND NOT EXISTS (SELECT 1 FROM delivery_manifest_receipts WHERE package_id=? AND batch_id=?)`;
|
|
112
|
+
// Unique receipt + atomic statements: conflicting concurrent originals fail via PK,
|
|
113
|
+
// variants are accepted only once per index (a complete replacement, not mutable patches).
|
|
114
|
+
const statement = body.phase === 'originals' ? `INSERT INTO delivery_manifest_entries
|
|
115
|
+
(package_id,entry_id,name,has_preview,requires_variant,source_bytes,entry_json)
|
|
116
|
+
SELECT ?,json_extract(value,'$.index'),json_extract(value,'$.entry.name'),0,json_extract(value,'$.requiresVariant'),
|
|
117
|
+
json_extract(value,'$.entry.original.length'),json_extract(value,'$.entry') FROM json_each(?) WHERE ${guard}` :
|
|
118
|
+
`UPDATE delivery_manifest_entries SET variant_done=1,entry_json=(SELECT json_extract(value,'$.entry') FROM json_each(?) WHERE json_extract(value,'$.index')=entry_id),
|
|
119
|
+
has_preview=(SELECT COALESCE(json_type(value,'$.entry.web')='object',0) FROM json_each(?) WHERE json_extract(value,'$.index')=entry_id)
|
|
120
|
+
WHERE package_id=? AND entry_id IN (SELECT json_extract(value,'$.index') FROM json_each(?)) AND ${guard}`;
|
|
121
|
+
const write = body.phase === 'originals' ? env.DB.prepare(statement).bind(pkg.id, payload, pkg.id, pkg.id, body.batchId) :
|
|
122
|
+
env.DB.prepare(statement).bind(payload, payload, pkg.id, payload, pkg.id, pkg.id, body.batchId);
|
|
123
|
+
await env.DB.batch([
|
|
124
|
+
env.DB.prepare(`INSERT INTO delivery_manifest_claims(package_id,phase,entry_id)
|
|
125
|
+
SELECT ?,?,json_extract(value,'$.index') FROM json_each(?) WHERE ${guard}`)
|
|
126
|
+
.bind(pkg.id, body.phase, payload, pkg.id, pkg.id, body.batchId),
|
|
127
|
+
write,
|
|
128
|
+
env.DB.prepare(`UPDATE delivery_manifest_state SET metadata_bytes=metadata_bytes+? WHERE package_id=? AND ${guard}`)
|
|
129
|
+
.bind(delta, pkg.id, pkg.id, pkg.id, body.batchId),
|
|
130
|
+
env.DB.prepare(`INSERT INTO delivery_manifest_receipts(package_id,batch_id,digest)
|
|
131
|
+
SELECT ?,?,? WHERE ${guard}`).bind(pkg.id, body.batchId, digest, pkg.id, pkg.id, body.batchId),
|
|
132
|
+
|
|
133
|
+
]);
|
|
134
|
+
const saved = await env.DB.prepare('SELECT digest FROM delivery_manifest_receipts WHERE package_id=? AND batch_id=?').bind(pkg.id, body.batchId).first();
|
|
135
|
+
if (saved?.digest !== digest) throw fail('Il manifest è cambiato durante il salvataggio', 409);
|
|
136
|
+
return { accepted: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function finalizeManifestStep(env, body) {
|
|
140
|
+
const pkg = await activePackage(env, body, true);
|
|
141
|
+
if (pkg.state === 'ready') return { done: true };
|
|
142
|
+
validateDeliverySourceBytes(pkg.source_bytes);
|
|
143
|
+
const uploads = (await env.DB.prepare('SELECT * FROM delivery_uploads WHERE package_id=?').bind(pkg.id).all()).results || [];
|
|
144
|
+
const byVariant = Object.fromEntries(uploads.map(u => [u.variant, u]));
|
|
145
|
+
for (const kind of ['originals', ...(pkg.web_enabled || pkg.gallery_enabled ? ['web'] : []), ...(pkg.gallery_enabled ? ['thumbs'] : [])]) {
|
|
146
|
+
if (byVariant[kind]?.state !== 'completed') throw fail('Gli archivi non sono ancora completi', 409);
|
|
147
|
+
}
|
|
148
|
+
let state = await env.DB.prepare('SELECT * FROM delivery_manifest_state WHERE package_id=?').bind(pkg.id).first();
|
|
149
|
+
if (state?.state === 'uploading') {
|
|
150
|
+
const totals = await env.DB.prepare(`SELECT COUNT(*) n,COALESCE(SUM(source_bytes),0) bytes,
|
|
151
|
+
MIN(entry_id) lo,MAX(entry_id) hi,SUM(CASE WHEN requires_variant=1 AND variant_done=0 THEN 1 ELSE 0 END) pending FROM delivery_manifest_entries WHERE package_id=?`).bind(pkg.id).first();
|
|
152
|
+
if (totals.pending || totals.n !== pkg.file_count || totals.bytes !== pkg.source_bytes || totals.lo !== 0 || totals.hi !== pkg.file_count - 1) throw fail('Elenco dei file incompleto', 409);
|
|
153
|
+
await env.DB.prepare(`UPDATE delivery_manifest_state SET state='sealed' WHERE package_id=? AND state='uploading'
|
|
154
|
+
AND EXISTS (SELECT 1 FROM delivery_packages WHERE id=? AND state='uploading')`).bind(pkg.id, pkg.id).run();
|
|
155
|
+
state = await env.DB.prepare('SELECT * FROM delivery_manifest_state WHERE package_id=?').bind(pkg.id).first();
|
|
156
|
+
}
|
|
157
|
+
if (!state || !['sealed','ready'].includes(state.state)) throw fail('Finalizzazione non disponibile', 409);
|
|
158
|
+
if (state.cursor < pkg.file_count) {
|
|
159
|
+
const result = await readManifestPage(env, pkg, state.cursor - 1, 200, false);
|
|
160
|
+
const rows = [];
|
|
161
|
+
let size = 128;
|
|
162
|
+
for (const row of result.entries) {
|
|
163
|
+
const n = bytes(JSON.stringify(row)) + 1;
|
|
164
|
+
if (rows.length && size + n > MANIFEST_BATCH_BYTES) break;
|
|
165
|
+
for (const [kind, field] of [['originals','original'],['web','web'],['thumbs','thumb']]) {
|
|
166
|
+
const m = row.entry[field];
|
|
167
|
+
if (m && (!byVariant[kind] || m.offset + m.length > byVariant[kind].bytes)) throw fail('Il manifest supera i byte dell’archivio');
|
|
168
|
+
}
|
|
169
|
+
rows.push(row); size += n;
|
|
170
|
+
}
|
|
171
|
+
const json = JSON.stringify({ version: 2, generation: pkg.id, entries: rows });
|
|
172
|
+
const digest = await sha256(json);
|
|
173
|
+
const key = b2ObjectKey(pkg.storage_prefix, `manifest-v2/${state.page_count}-${digest}.json`);
|
|
174
|
+
const end = rows.at(-1).index + 1;
|
|
175
|
+
await env.DB.prepare(`INSERT OR IGNORE INTO delivery_manifest_objects
|
|
176
|
+
(package_id,page_number,object_key,digest,end_cursor,entry_count)
|
|
177
|
+
SELECT ?,?,?,?,?,? WHERE EXISTS (SELECT 1 FROM delivery_packages WHERE id=? AND state='uploading')`)
|
|
178
|
+
.bind(pkg.id, state.page_count, key, digest, end, rows.length, pkg.id).run();
|
|
179
|
+
const reserved = await env.DB.prepare('SELECT * FROM delivery_manifest_objects WHERE package_id=? AND page_number=?').bind(pkg.id, state.page_count).first();
|
|
180
|
+
if (reserved?.object_key !== key) throw fail('Generazione modificata o annullata', 409);
|
|
181
|
+
await b2PutSmall(env, key, json, 'application/json');
|
|
182
|
+
await env.DB.batch([
|
|
183
|
+
env.DB.prepare('UPDATE delivery_manifest_objects SET verified=1 WHERE package_id=? AND page_number=? AND digest=?').bind(pkg.id, state.page_count, digest),
|
|
184
|
+
env.DB.prepare(`UPDATE delivery_manifest_state SET cursor=?,page_count=page_count+1 WHERE package_id=? AND cursor=? AND state='sealed'
|
|
185
|
+
AND EXISTS (SELECT 1 FROM delivery_packages WHERE id=? AND state='uploading')`).bind(end, pkg.id, state.cursor, pkg.id),
|
|
186
|
+
]);
|
|
187
|
+
return { done: false, cursor: end, total: pkg.file_count };
|
|
188
|
+
}
|
|
189
|
+
const pages = (await env.DB.prepare('SELECT page_number,object_key,digest,entry_count FROM delivery_manifest_objects WHERE package_id=? AND page_number>=0 AND verified=1 ORDER BY page_number').bind(pkg.id).all()).results || [];
|
|
190
|
+
if (pages.length !== state.page_count || pages.reduce((n,p) => n + p.entry_count, 0) !== pkg.file_count) throw fail('Pagine del manifest incomplete', 409);
|
|
191
|
+
const archives = {};
|
|
192
|
+
for (const upload of uploads) {
|
|
193
|
+
const head = await b2Head(env, upload.object_key);
|
|
194
|
+
if (!head || head.bytes !== upload.bytes) throw fail('Dimensione remota diversa dall’archivio completato', 409);
|
|
195
|
+
archives[upload.variant] = { key: upload.object_key, bytes: upload.bytes, etag: head.etag };
|
|
196
|
+
}
|
|
197
|
+
const json = JSON.stringify({ version: 2, generation: pkg.id, createdAt: state.created_at,
|
|
198
|
+
fileCount: pkg.file_count, sourceBytes: pkg.source_bytes, archives, pages });
|
|
199
|
+
if (bytes(json) > MANIFEST_BATCH_BYTES) throw fail('Descrittore del manifest troppo grande', 413);
|
|
200
|
+
const digest = await sha256(json);
|
|
201
|
+
const key = b2ObjectKey(pkg.storage_prefix, `manifest-v2/root-${digest}.json`);
|
|
202
|
+
await env.DB.prepare(`INSERT OR IGNORE INTO delivery_manifest_objects(package_id,page_number,object_key,digest,end_cursor,entry_count)
|
|
203
|
+
SELECT ?,-1,?,?,?,0 WHERE EXISTS(SELECT 1 FROM delivery_packages WHERE id=? AND state='uploading')`)
|
|
204
|
+
.bind(pkg.id, key, digest, pkg.file_count, pkg.id).run();
|
|
205
|
+
const reserved = await env.DB.prepare('SELECT object_key FROM delivery_manifest_objects WHERE package_id=? AND page_number=-1').bind(pkg.id).first();
|
|
206
|
+
if (reserved?.object_key !== key) throw fail('Descrittore finale in conflitto', 409);
|
|
207
|
+
await b2PutSmall(env, key, json, 'application/json');
|
|
208
|
+
const marker = JSON.stringify({ version: 2, generation: pkg.id, root: key, digest });
|
|
209
|
+
await env.DB.batch([
|
|
210
|
+
env.DB.prepare(`UPDATE delivery_packages SET state='ready',manifest_json=?,manifest_key=?,originals_key=?,web_key=?,thumbs_key=?,stored_bytes=?,updated_at=?
|
|
211
|
+
WHERE id=? AND state='uploading' AND EXISTS(SELECT 1 FROM delivery_manifest_state WHERE package_id=? AND state='sealed' AND cursor=?)`)
|
|
212
|
+
.bind(marker, key, byVariant.originals.object_key, byVariant.web?.object_key || null, byVariant.thumbs?.object_key || null,
|
|
213
|
+
uploads.reduce((n,u) => n + u.bytes,0), new Date().toISOString(), pkg.id, pkg.id, pkg.file_count),
|
|
214
|
+
env.DB.prepare(`UPDATE delivery_manifest_state SET state='ready',root_json=? WHERE package_id=?
|
|
215
|
+
AND EXISTS(SELECT 1 FROM delivery_packages WHERE id=? AND state='ready' AND manifest_json=?)`).bind(json, pkg.id, pkg.id, marker),
|
|
216
|
+
]);
|
|
217
|
+
const done = await env.DB.prepare("SELECT manifest_json FROM delivery_packages WHERE id=? AND state='ready'").bind(pkg.id).first();
|
|
218
|
+
if (done?.manifest_json !== marker) throw fail('Completamento annullato o in conflitto', 409);
|
|
219
|
+
return { done: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function manifestStatus(env, body) {
|
|
223
|
+
const pkg = await activePackage(env, body);
|
|
224
|
+
const state = await env.DB.prepare('SELECT state FROM delivery_manifest_state WHERE package_id=?').bind(pkg.id).first();
|
|
225
|
+
const row = await env.DB.prepare('SELECT checkpoint_json FROM delivery_checkpoints WHERE package_id=?').bind(pkg.id).first();
|
|
226
|
+
const cp = row ? JSON.parse(row.checkpoint_json) : null;
|
|
227
|
+
if (cp?.validation && !cp.complete) {
|
|
228
|
+
const upload = await env.DB.prepare("SELECT * FROM delivery_uploads WHERE package_id=? AND variant='originals'").bind(pkg.id).first();
|
|
229
|
+
if (!upload) throw fail('Archivio del checkpoint non disponibile', 409);
|
|
230
|
+
const parts = await checkpointParts(env, upload.id, 0, cp.validation.partCount);
|
|
231
|
+
await verifyCheckpointProof(cp, upload, parts);
|
|
232
|
+
// Keep the existing browser protocol. The full hash is needed only on resume,
|
|
233
|
+
// never on each checkpoint. Check the stored proof before deriving this hash.
|
|
234
|
+
cp.partsHash = await sha256(JSON.stringify(parts.map(partTuple)));
|
|
235
|
+
}
|
|
236
|
+
if (cp) delete cp.validation;
|
|
237
|
+
return { state: state.state, checkpoint: cp };
|
|
238
|
+
}
|
|
239
|
+
export async function manifestDraftPage(env, body) {
|
|
240
|
+
return readManifestPage(env, await activePackage(env, body), body.after ?? -1, 200, false);
|
|
241
|
+
}
|
|
242
|
+
const CHECKPOINT_PART_BYTES = 16 * 1024 * 1024;
|
|
243
|
+
// A bounded tail fits with the interrupted member in the existing 32 KiB row.
|
|
244
|
+
// Hash 128-part blocks: O(N) work, including a full verification on real resume.
|
|
245
|
+
const PROOF_BLOCK_PARTS = 128;
|
|
246
|
+
const partTuple = p => [p.part_number, p.size, p.digest];
|
|
247
|
+
function emptyProof(upload) {
|
|
248
|
+
return { version: 2, uploadId: upload.id, sessionId: upload.b2_upload_id ?? null, partCount: 0, chain: '', tail: [] };
|
|
249
|
+
}
|
|
250
|
+
async function extendProof(proof, parts) {
|
|
251
|
+
const next = { ...proof, tail: [...proof.tail] };
|
|
252
|
+
for (const part of parts) {
|
|
253
|
+
next.tail.push(partTuple(part));
|
|
254
|
+
next.partCount++;
|
|
255
|
+
if (next.tail.length === PROOF_BLOCK_PARTS) {
|
|
256
|
+
next.chain = await sha256(next.chain + ':' + JSON.stringify(next.tail));
|
|
257
|
+
next.tail = [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return next;
|
|
261
|
+
}
|
|
262
|
+
async function checkpointParts(env, uploadId, after, through) {
|
|
263
|
+
return (await env.DB.prepare(`SELECT part_number,size,digest FROM delivery_upload_parts
|
|
264
|
+
WHERE upload_id=? AND part_number>? AND part_number<=? ORDER BY part_number`)
|
|
265
|
+
.bind(uploadId, after, through).all()).results || [];
|
|
266
|
+
}
|
|
267
|
+
function checkParts(parts, after, through) {
|
|
268
|
+
if (parts.length !== through - after || parts.some((p, i) => p.part_number !== after + i + 1 ||
|
|
269
|
+
p.size !== CHECKPOINT_PART_BYTES || !/^[a-f0-9]{64}$/.test(p.digest || ''))) {
|
|
270
|
+
throw fail('Parti del checkpoint non confermate', 409);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function verifyCheckpointProof(cp, upload, parts) {
|
|
274
|
+
const count = cp.offset / CHECKPOINT_PART_BYTES;
|
|
275
|
+
checkParts(parts, 0, count);
|
|
276
|
+
if (cp.validation) {
|
|
277
|
+
const actual = await extendProof(emptyProof(upload), parts);
|
|
278
|
+
if (JSON.stringify(actual) !== JSON.stringify(cp.validation)) throw fail('Le parti del checkpoint sono cambiate', 409);
|
|
279
|
+
} else if (await sha256(JSON.stringify(parts.map(partTuple))) !== cp.partsHash) {
|
|
280
|
+
throw fail('Le parti del checkpoint sono cambiate', 409);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Called before accepting the remote completion, including lost-response recovery.
|
|
284
|
+
export async function assertCheckpointParts(env, upload, parts) {
|
|
285
|
+
if (upload.variant !== 'originals' || manifestVersion(upload) !== 2) return;
|
|
286
|
+
const row = await env.DB.prepare('SELECT checkpoint_json FROM delivery_checkpoints WHERE package_id=?').bind(upload.package_id).first();
|
|
287
|
+
const cp = row ? JSON.parse(row.checkpoint_json) : null;
|
|
288
|
+
if (!cp || cp.complete) return;
|
|
289
|
+
const prefix = parts.filter(p => p.partNumber <= cp.offset / CHECKPOINT_PART_BYTES)
|
|
290
|
+
.map(p => ({ part_number: p.partNumber, size: p.size, digest: p.digest }));
|
|
291
|
+
await verifyCheckpointProof(cp, upload, prefix);
|
|
292
|
+
}
|
|
293
|
+
function checkpointPayload(cp) {
|
|
294
|
+
return { version: 1, fileIndex: cp.fileIndex, offset: cp.offset, sequence: cp.sequence,
|
|
295
|
+
complete: cp.complete === true, current: cp.current ?? null };
|
|
296
|
+
}
|
|
297
|
+
export async function saveManifestCheckpoint(env, body) {
|
|
298
|
+
const pkg = await activePackage(env, body, true);
|
|
299
|
+
const cp = body.checkpoint;
|
|
300
|
+
const partBytes = CHECKPOINT_PART_BYTES;
|
|
301
|
+
if (!cp || cp.version !== 1 || (cp.complete !== undefined && typeof cp.complete !== 'boolean') || !Number.isSafeInteger(cp.fileIndex) || cp.fileIndex < 0 || cp.fileIndex > pkg.file_count ||
|
|
302
|
+
!Number.isSafeInteger(cp.offset) || cp.offset < 0 || cp.offset > DELIVERY_MAX_ARCHIVE_BYTES ||
|
|
303
|
+
!Number.isSafeInteger(cp.sequence) || cp.sequence < 0 || bytes(JSON.stringify(cp)) > 30000) throw fail('Checkpoint non valido');
|
|
304
|
+
const upload = await env.DB.prepare("SELECT * FROM delivery_uploads WHERE package_id=? AND variant='originals'").bind(pkg.id).first();
|
|
305
|
+
if (!upload) throw fail('Archivio del checkpoint non disponibile', 409);
|
|
306
|
+
const previousRow = await env.DB.prepare('SELECT checkpoint_json FROM delivery_checkpoints WHERE package_id=?').bind(pkg.id).first();
|
|
307
|
+
const previous = previousRow ? JSON.parse(previousRow.checkpoint_json) : null;
|
|
308
|
+
if (previous && (cp.offset < previous.offset || cp.fileIndex < previous.fileIndex || cp.sequence < previous.sequence)) {
|
|
309
|
+
throw fail('Checkpoint precedente a quello confermato', 409);
|
|
310
|
+
}
|
|
311
|
+
if (previous && cp.offset === previous.offset) {
|
|
312
|
+
if (JSON.stringify(checkpointPayload(cp)) !== JSON.stringify(checkpointPayload(previous))) throw fail('Checkpoint diverso allo stesso offset', 409);
|
|
313
|
+
return { accepted: true };
|
|
314
|
+
}
|
|
315
|
+
if (previous?.complete) throw fail('Checkpoint già completo', 409);
|
|
316
|
+
let proof = emptyProof(upload);
|
|
317
|
+
let firstFile = 0;
|
|
318
|
+
if (previous?.validation) {
|
|
319
|
+
if (previous.validation.uploadId !== upload.id || previous.validation.sessionId !== (upload.b2_upload_id ?? null)) {
|
|
320
|
+
throw fail('Sessione del checkpoint cambiata', 409);
|
|
321
|
+
}
|
|
322
|
+
proof = previous.validation;
|
|
323
|
+
firstFile = previous.fileIndex;
|
|
324
|
+
}
|
|
325
|
+
// Original entries are insert-only. Variant updates preserve their original
|
|
326
|
+
// identity; PK + the interval count prove continuity of just the new suffix.
|
|
327
|
+
const count = await env.DB.prepare(`SELECT COUNT(*) n FROM delivery_manifest_entries
|
|
328
|
+
WHERE package_id=? AND entry_id>=? AND entry_id<?`).bind(pkg.id, firstFile, cp.fileIndex).first();
|
|
329
|
+
if (count.n !== cp.fileIndex - firstFile) throw fail('Metadati precedenti al checkpoint incompleti', 409);
|
|
330
|
+
if (cp.complete) {
|
|
331
|
+
if (cp.fileIndex !== pkg.file_count || upload.state !== 'completed' || cp.offset !== upload.bytes) throw fail('Archivio del checkpoint incompleto', 409);
|
|
332
|
+
if (previous) await verifyCheckpointProof(previous, upload, await checkpointParts(env, upload.id, 0, previous.offset / partBytes));
|
|
333
|
+
} else {
|
|
334
|
+
if (!cp.offset || cp.offset % partBytes || cp.fileIndex >= pkg.file_count || !cp.current ||
|
|
335
|
+
!Number.isSafeInteger(cp.current.read) || cp.current.read < 1 || cp.current.dataOffset + cp.current.read !== cp.offset ||
|
|
336
|
+
!/^[a-f0-9]{64}$/.test(cp.current.chain || '')) throw fail('Checkpoint non allineato');
|
|
337
|
+
const n = cp.offset / partBytes;
|
|
338
|
+
const parts = await checkpointParts(env, upload.id, proof.partCount, n);
|
|
339
|
+
checkParts(parts, proof.partCount, n);
|
|
340
|
+
if (previous && !previous.validation) {
|
|
341
|
+
// One full check when upgrading a legacy checkpoint; subsequent saves use
|
|
342
|
+
// only the suffix. A stored legacy hash must never be silently replaced.
|
|
343
|
+
await verifyCheckpointProof(previous, upload, parts.filter(p => p.part_number <= previous.offset / partBytes));
|
|
344
|
+
}
|
|
345
|
+
proof = await extendProof(proof, parts);
|
|
346
|
+
}
|
|
347
|
+
const json = JSON.stringify({ ...checkpointPayload(cp), partsHash: null, ...(cp.complete ? {} : { validation: proof }) });
|
|
348
|
+
if (bytes(json) > 32768) throw fail('Checkpoint troppo grande', 413);
|
|
349
|
+
await env.DB.prepare(`INSERT INTO delivery_checkpoints(package_id,offset,checkpoint_json)
|
|
350
|
+
SELECT ?,?,? WHERE EXISTS(SELECT 1 FROM delivery_packages p JOIN delivery_manifest_state s ON s.package_id=p.id
|
|
351
|
+
WHERE p.id=? AND p.state='uploading' AND s.writer_token=?)
|
|
352
|
+
ON CONFLICT(package_id) DO UPDATE SET offset=excluded.offset,checkpoint_json=excluded.checkpoint_json
|
|
353
|
+
WHERE delivery_checkpoints.checkpoint_json=? AND excluded.offset>delivery_checkpoints.offset`)
|
|
354
|
+
.bind(pkg.id, cp.offset, json, pkg.id, body.writerToken, previousRow?.checkpoint_json ?? null).run();
|
|
355
|
+
const saved = await env.DB.prepare('SELECT checkpoint_json FROM delivery_checkpoints WHERE package_id=?').bind(pkg.id).first();
|
|
356
|
+
if (!saved || saved.checkpoint_json !== json) throw fail('Checkpoint cambiato o annullato', 409);
|
|
357
|
+
return { accepted: true };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// La lease supera la durata massima delle URL PUT (15 min) e dei PUT in volo
|
|
361
|
+
// (30 min). La stessa identità locale recupera subito; un altro dispositivo deve
|
|
362
|
+
// attendere un'ora senza attività, evitando un takeover con scritture ancora vive.
|
|
363
|
+
const WRITER_RENEW_MS = 5 * 60_000;
|
|
364
|
+
// With renewal every five minutes, retain at least one full hour of protection
|
|
365
|
+
// after the last authorized operation (takeover may wait up to 65 minutes).
|
|
366
|
+
const WRITER_LEASE_MS = 65 * 60_000;
|
|
367
|
+
export async function claimManifestWriter(env, body) {
|
|
368
|
+
const pkg = await activePackage(env, body);
|
|
369
|
+
const token = body.writerToken;
|
|
370
|
+
if (typeof token !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(token)) throw fail('Identità del caricamento non valida');
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
await env.DB.prepare(`UPDATE delivery_manifest_state SET writer_token=?,writer_until=? WHERE package_id=?
|
|
373
|
+
AND (writer_token IS NULL OR writer_token=? OR writer_until<?)`)
|
|
374
|
+
.bind(token, now + WRITER_LEASE_MS, pkg.id, token, now).run();
|
|
375
|
+
await assertManifestWriter(env, pkg.id, token);
|
|
376
|
+
return { claimed: true };
|
|
377
|
+
}
|
|
378
|
+
export async function assertManifestWriter(env, packageId, token) {
|
|
379
|
+
if (!token) throw fail('Identità del caricamento mancante', 409);
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
const current = await env.DB.prepare('SELECT writer_token,writer_until FROM delivery_manifest_state WHERE package_id=?').bind(packageId).first();
|
|
382
|
+
const fenced = () => fail('Il caricamento è aperto in un altro browser. Riprendilo lì oppure attendi circa un’ora senza attività prima di cambiare dispositivo.', 409);
|
|
383
|
+
if (!current || current.writer_token !== token) throw fenced();
|
|
384
|
+
if (current.writer_until > now + WRITER_LEASE_MS - WRITER_RENEW_MS) return;
|
|
385
|
+
const result = await env.DB.prepare(`UPDATE delivery_manifest_state SET writer_until=?
|
|
386
|
+
WHERE package_id=? AND writer_token=? AND writer_until=?`)
|
|
387
|
+
.bind(now + WRITER_LEASE_MS, packageId, token, current.writer_until).run();
|
|
388
|
+
if (result.meta.changes !== 1) {
|
|
389
|
+
const renewed = await env.DB.prepare('SELECT writer_token,writer_until FROM delivery_manifest_state WHERE package_id=?').bind(packageId).first();
|
|
390
|
+
if (!renewed || renewed.writer_token !== token || renewed.writer_until <= now) throw fenced();
|
|
391
|
+
}
|
|
392
|
+
}
|