@cardinali-studio/adminkit 1.0.0
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/api.d.ts +195 -0
- package/dist/api.js +118 -0
- package/dist/app/App.d.ts +3 -0
- package/dist/app/App.js +320 -0
- package/dist/app/app.css +2068 -0
- package/dist/app/index.d.ts +18 -0
- package/dist/app/index.js +33 -0
- package/dist/app/lib/bookings.d.ts +132 -0
- package/dist/app/lib/bookings.js +79 -0
- package/dist/app/lib/deliveries.d.ts +95 -0
- package/dist/app/lib/deliveries.js +104 -0
- package/dist/app/lib/delivery-drop.d.ts +1 -0
- package/dist/app/lib/delivery-drop.js +72 -0
- package/dist/app/lib/delivery-upload.d.ts +78 -0
- package/dist/app/lib/delivery-upload.js +585 -0
- package/dist/app/lib/groups.d.ts +9 -0
- package/dist/app/lib/groups.js +32 -0
- package/dist/app/lib/home.d.ts +11 -0
- package/dist/app/lib/home.js +67 -0
- package/dist/app/lib/overview.d.ts +82 -0
- package/dist/app/lib/overview.js +68 -0
- package/dist/app/lib/pages.d.ts +24 -0
- package/dist/app/lib/pages.js +156 -0
- package/dist/app/lib/publish.d.ts +18 -0
- package/dist/app/lib/publish.js +63 -0
- package/dist/app/lib/reorder.d.ts +3 -0
- package/dist/app/lib/reorder.js +31 -0
- package/dist/app/lib/requests.d.ts +10 -0
- package/dist/app/lib/requests.js +67 -0
- package/dist/app/lib/router.d.ts +1 -0
- package/dist/app/lib/router.js +14 -0
- package/dist/app/lib/settings.d.ts +29 -0
- package/dist/app/lib/settings.js +82 -0
- package/dist/app/lib/slug.d.ts +3 -0
- package/dist/app/lib/slug.js +23 -0
- package/dist/app/lib/text.d.ts +1 -0
- package/dist/app/lib/text.js +13 -0
- package/dist/app/lib/theme.d.ts +7 -0
- package/dist/app/lib/theme.js +43 -0
- package/dist/app/lib/upload-queue.d.ts +35 -0
- package/dist/app/lib/upload-queue.js +167 -0
- package/dist/app/routes/AboutEditor.d.ts +15 -0
- package/dist/app/routes/AboutEditor.js +122 -0
- package/dist/app/routes/Bookings.d.ts +21 -0
- package/dist/app/routes/Bookings.js +134 -0
- package/dist/app/routes/Deliveries.d.ts +37 -0
- package/dist/app/routes/Deliveries.js +563 -0
- package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
- package/dist/app/routes/GallerySectionsEditor.js +107 -0
- package/dist/app/routes/HomeEditor.d.ts +10 -0
- package/dist/app/routes/HomeEditor.js +188 -0
- package/dist/app/routes/Overview.d.ts +17 -0
- package/dist/app/routes/Overview.js +318 -0
- package/dist/app/routes/PageActions.d.ts +12 -0
- package/dist/app/routes/PageActions.js +93 -0
- package/dist/app/routes/PageEditor.d.ts +23 -0
- package/dist/app/routes/PageEditor.js +233 -0
- package/dist/app/routes/PageSeoEditor.d.ts +13 -0
- package/dist/app/routes/PageSeoEditor.js +102 -0
- package/dist/app/routes/PagesList.d.ts +10 -0
- package/dist/app/routes/PagesList.js +76 -0
- package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
- package/dist/app/routes/PortfolioWorksEditor.js +139 -0
- package/dist/app/routes/Requests.d.ts +13 -0
- package/dist/app/routes/Requests.js +30 -0
- package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
- package/dist/app/routes/SiteSettingsEditor.js +66 -0
- package/dist/app/routes/UploadDock.d.ts +1 -0
- package/dist/app/routes/UploadDock.js +66 -0
- package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
- package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
- package/dist/app/site.d.ts +37 -0
- package/dist/app/site.js +18 -0
- package/dist/app/ui/MediaSlot.d.ts +10 -0
- package/dist/app/ui/MediaSlot.js +20 -0
- package/dist/app/ui/OpenCard.d.ts +18 -0
- package/dist/app/ui/OpenCard.js +20 -0
- package/dist/app/ui/ParagraphEditor.d.ts +8 -0
- package/dist/app/ui/ParagraphEditor.js +37 -0
- package/dist/app/ui/dnd.d.ts +1 -0
- package/dist/app/ui/dnd.js +11 -0
- package/dist/app/ui/icons.d.ts +26 -0
- package/dist/app/ui/icons.js +45 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +26 -0
- package/dist/form/MediaBrowser.d.ts +13 -0
- package/dist/form/MediaBrowser.js +24 -0
- package/dist/form/MediaPicker.d.ts +10 -0
- package/dist/form/MediaPicker.js +20 -0
- package/dist/form/SchemaForm.d.ts +21 -0
- package/dist/form/SchemaForm.js +67 -0
- package/dist/form/Trash.d.ts +1 -0
- package/dist/form/Trash.js +42 -0
- package/dist/form/fields/index.d.ts +1 -0
- package/dist/form/fields/index.js +110 -0
- package/dist/form/fields/objectList.d.ts +1 -0
- package/dist/form/fields/objectList.js +99 -0
- package/dist/form/index.d.ts +8 -0
- package/dist/form/index.js +8 -0
- package/dist/form/media/Lightbox.d.ts +4 -0
- package/dist/form/media/Lightbox.js +70 -0
- package/dist/form/media/MediaFolders.d.ts +20 -0
- package/dist/form/media/MediaFolders.js +116 -0
- package/dist/form/media/MediaGrid.d.ts +8 -0
- package/dist/form/media/MediaGrid.js +139 -0
- package/dist/form/media/UploadDialog.d.ts +4 -0
- package/dist/form/media/UploadDialog.js +37 -0
- package/dist/form/media/useMediaLibrary.d.ts +55 -0
- package/dist/form/media/useMediaLibrary.js +495 -0
- package/dist/form/prose/ProseBar.d.ts +21 -0
- package/dist/form/prose/ProseBar.js +111 -0
- package/dist/form/prose/ProseField.d.ts +11 -0
- package/dist/form/prose/ProseField.js +152 -0
- package/dist/form/prose/pages.d.ts +6 -0
- package/dist/form/prose/pages.js +13 -0
- package/dist/form/registry.d.ts +20 -0
- package/dist/form/registry.js +15 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +202 -0
- package/dist/functions/_middleware.js +101 -0
- package/dist/functions/_shared/analytics-ga4.js +248 -0
- package/dist/functions/_shared/analytics-umami.js +119 -0
- package/dist/functions/_shared/assets.js +90 -0
- package/dist/functions/_shared/b2.js +300 -0
- package/dist/functions/_shared/delivery-crypto.js +99 -0
- package/dist/functions/_shared/delivery-purge.js +222 -0
- package/dist/functions/_shared/edge-cache.js +37 -0
- package/dist/functions/_shared/exif.js +53 -0
- package/dist/functions/_shared/github.js +138 -0
- package/dist/functions/_shared/imagesize.js +101 -0
- package/dist/functions/_shared/media-content.js +200 -0
- package/dist/functions/_shared/pending-media.js +98 -0
- package/dist/functions/_shared/refs.js +155 -0
- package/dist/functions/api/analytics.js +106 -0
- package/dist/functions/api/assets/delete.js +241 -0
- package/dist/functions/api/assets/folder.js +43 -0
- package/dist/functions/api/assets/folders.js +32 -0
- package/dist/functions/api/assets/move.js +253 -0
- package/dist/functions/api/assets/preview.js +17 -0
- package/dist/functions/api/assets/trash/delete.js +13 -0
- package/dist/functions/api/assets/trash/empty.js +16 -0
- package/dist/functions/api/assets/trash/restore.js +43 -0
- package/dist/functions/api/assets/trash.js +28 -0
- package/dist/functions/api/assets/upload.js +54 -0
- package/dist/functions/api/assets/usage.js +41 -0
- package/dist/functions/api/assets.js +94 -0
- package/dist/functions/api/deliveries.js +629 -0
- package/dist/functions/api/delivery/hero.js +21 -0
- package/dist/functions/api/delivery/image.js +35 -0
- package/dist/functions/api/delivery/multipart.js +261 -0
- package/dist/functions/api/pages/get.js +26 -0
- package/dist/functions/api/pages.js +354 -0
- package/dist/functions/api/publish.js +134 -0
- package/dist/functions/api/requests.js +57 -0
- package/dist/functions/api/settings.js +236 -0
- package/dist/functions/api/singleton.js +105 -0
- package/dist/functions/api/storage.js +125 -0
- package/dist/functions/index.js +124 -0
- package/dist/functions/site.js +38 -0
- package/dist/gallery.d.ts +28 -0
- package/dist/gallery.js +18 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +47 -0
- package/dist/normalize.d.ts +50 -0
- package/dist/normalize.js +120 -0
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +82 -0
- package/dist/prose.d.ts +17 -0
- package/dist/prose.js +205 -0
- package/dist/registries.d.ts +43 -0
- package/dist/registries.js +29 -0
- package/dist/serialize.d.ts +26 -0
- package/dist/serialize.js +136 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +66 -0
- package/dist/theme.css +636 -0
- package/dist/yaml.d.ts +8 -0
- package/dist/yaml.js +90 -0
- package/package.json +97 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/* Client Backblaze B2 attraverso l'API S3-compatible.
|
|
2
|
+
*
|
|
3
|
+
* Le chiamate di controllo (create/complete/list/GET) sono firmate qui con SigV4.
|
|
4
|
+
* I byte grandi NON attraversano la Function: `b2PresignUploadPart` produce URL
|
|
5
|
+
* PUT a breve durata che il browser usa direttamente verso il bucket privato.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const enc = new TextEncoder();
|
|
9
|
+
|
|
10
|
+
export function b2Configured(env) {
|
|
11
|
+
return !!(env.B2_ENDPOINT && env.B2_REGION && env.B2_BUCKET &&
|
|
12
|
+
env.B2_APPLICATION_KEY_ID && env.B2_APPLICATION_KEY);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function b2ObjectKey(prefix, filename) {
|
|
16
|
+
return `${String(prefix).replace(/^\/+|\/+$/g, "")}/${String(filename).replace(/^\/+/g, "")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function b2Request(env, method, key, options = {}) {
|
|
20
|
+
assertConfig(env);
|
|
21
|
+
const url = objectUrl(env, key);
|
|
22
|
+
addQuery(url, options.query);
|
|
23
|
+
const body = options.body ?? null;
|
|
24
|
+
const payloadHash = await sha256Bytes(body == null ? new Uint8Array() : toBytes(body));
|
|
25
|
+
const now = new Date();
|
|
26
|
+
const amzDate = stamp(now);
|
|
27
|
+
const date = amzDate.slice(0, 8);
|
|
28
|
+
const headers = new Headers(options.headers || {});
|
|
29
|
+
headers.set("x-amz-date", amzDate);
|
|
30
|
+
headers.set("x-amz-content-sha256", payloadHash);
|
|
31
|
+
|
|
32
|
+
const signedNames = ["host", ...[...headers.keys()].map((h) => h.toLowerCase())]
|
|
33
|
+
.filter((v, i, a) => a.indexOf(v) === i).sort();
|
|
34
|
+
const canonicalHeaders = signedNames.map((name) =>
|
|
35
|
+
`${name}:${name === "host" ? url.host : cleanHeader(headers.get(name) || "")}\n`).join("");
|
|
36
|
+
const canonical = [
|
|
37
|
+
method.toUpperCase(),
|
|
38
|
+
canonicalPath(url.pathname),
|
|
39
|
+
canonicalQuery(url),
|
|
40
|
+
canonicalHeaders,
|
|
41
|
+
signedNames.join(";"),
|
|
42
|
+
payloadHash,
|
|
43
|
+
].join("\n");
|
|
44
|
+
const scope = `${date}/${env.B2_REGION}/s3/aws4_request`;
|
|
45
|
+
const stringToSign = [
|
|
46
|
+
"AWS4-HMAC-SHA256", amzDate, scope, await sha256Bytes(enc.encode(canonical)),
|
|
47
|
+
].join("\n");
|
|
48
|
+
const signature = hex(await hmac(await signingKey(env.B2_APPLICATION_KEY, date, env.B2_REGION), stringToSign));
|
|
49
|
+
headers.set("Authorization",
|
|
50
|
+
`AWS4-HMAC-SHA256 Credential=${env.B2_APPLICATION_KEY_ID}/${scope}, ` +
|
|
51
|
+
`SignedHeaders=${signedNames.join(";")}, Signature=${signature}`);
|
|
52
|
+
|
|
53
|
+
return fetch(url, { method, headers, body });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function b2PresignUploadPart(env, key, uploadId, partNumber, expires = 900) {
|
|
57
|
+
return presignObject(env, "PUT", key, { partNumber: String(partNumber), uploadId }, expires, 3600);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function b2PresignPutObject(env, key, expires = 900) {
|
|
61
|
+
return presignObject(env, "PUT", key, {}, expires, 3600);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function b2PresignGetObject(env, key, options = {}) {
|
|
65
|
+
const query = {};
|
|
66
|
+
if (options.contentDisposition) {
|
|
67
|
+
query["response-content-disposition"] = String(options.contentDisposition);
|
|
68
|
+
}
|
|
69
|
+
if (options.contentType) query["response-content-type"] = String(options.contentType);
|
|
70
|
+
return presignObject(env, "GET", key, query, options.expires ?? 86_400, 604_800);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function presignObject(env, method, key, query, expires, maxExpires) {
|
|
74
|
+
assertConfig(env);
|
|
75
|
+
const url = objectUrl(env, key);
|
|
76
|
+
addQuery(url, query);
|
|
77
|
+
const now = new Date();
|
|
78
|
+
const amzDate = stamp(now);
|
|
79
|
+
const date = amzDate.slice(0, 8);
|
|
80
|
+
const scope = `${date}/${env.B2_REGION}/s3/aws4_request`;
|
|
81
|
+
url.searchParams.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
|
|
82
|
+
url.searchParams.set("X-Amz-Credential", `${env.B2_APPLICATION_KEY_ID}/${scope}`);
|
|
83
|
+
url.searchParams.set("X-Amz-Date", amzDate);
|
|
84
|
+
url.searchParams.set("X-Amz-Expires", String(Math.min(maxExpires, Math.max(60, expires))));
|
|
85
|
+
url.searchParams.set("X-Amz-SignedHeaders", "host");
|
|
86
|
+
const canonical = [
|
|
87
|
+
method,
|
|
88
|
+
canonicalPath(url.pathname),
|
|
89
|
+
canonicalQuery(url),
|
|
90
|
+
`host:${url.host}\n`,
|
|
91
|
+
"host",
|
|
92
|
+
"UNSIGNED-PAYLOAD",
|
|
93
|
+
].join("\n");
|
|
94
|
+
const stringToSign = [
|
|
95
|
+
"AWS4-HMAC-SHA256", amzDate, scope, await sha256Bytes(enc.encode(canonical)),
|
|
96
|
+
].join("\n");
|
|
97
|
+
const signature = hex(await hmac(await signingKey(env.B2_APPLICATION_KEY, date, env.B2_REGION), stringToSign));
|
|
98
|
+
url.searchParams.set("X-Amz-Signature", signature);
|
|
99
|
+
return url.toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function b2CreateMultipart(env, key, contentType = "application/zip") {
|
|
103
|
+
const res = await b2Request(env, "POST", key, {
|
|
104
|
+
query: { uploads: "" },
|
|
105
|
+
headers: { "Content-Type": contentType },
|
|
106
|
+
});
|
|
107
|
+
const text = await res.text();
|
|
108
|
+
if (!res.ok) throw new Error(`B2 create multipart ${res.status}: ${text.slice(0, 300)}`);
|
|
109
|
+
const uploadId = xml(text, "UploadId");
|
|
110
|
+
if (!uploadId) throw new Error("B2 non ha restituito uploadId");
|
|
111
|
+
return uploadId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function b2CompleteMultipart(env, key, uploadId, parts) {
|
|
115
|
+
const body = `<CompleteMultipartUpload>${parts.map((p) =>
|
|
116
|
+
`<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`
|
|
117
|
+
).join("")}</CompleteMultipartUpload>`;
|
|
118
|
+
const res = await b2Request(env, "POST", key, {
|
|
119
|
+
query: { uploadId },
|
|
120
|
+
headers: { "Content-Type": "application/xml" },
|
|
121
|
+
body,
|
|
122
|
+
});
|
|
123
|
+
const text = await res.text();
|
|
124
|
+
if (!res.ok) throw new Error(`B2 complete multipart ${res.status}: ${text.slice(0, 300)}`);
|
|
125
|
+
return { etag: xml(text, "ETag"), versionId: xml(text, "VersionId") };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function b2AbortMultipart(env, key, uploadId) {
|
|
129
|
+
const res = await b2Request(env, "DELETE", key, { query: { uploadId } });
|
|
130
|
+
if (!res.ok && res.status !== 404) {
|
|
131
|
+
throw new Error(`B2 abort multipart ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function b2ListParts(env, key, uploadId) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
let marker = "";
|
|
138
|
+
for (let page = 0; page < 10; page++) {
|
|
139
|
+
const query = { uploadId, "max-parts": "1000" };
|
|
140
|
+
if (marker) query["part-number-marker"] = marker;
|
|
141
|
+
const res = await b2Request(env, "GET", key, { query });
|
|
142
|
+
const text = await res.text();
|
|
143
|
+
if (!res.ok) throw new Error(`B2 list parts ${res.status}: ${text.slice(0, 300)}`);
|
|
144
|
+
for (const block of text.matchAll(/<Part>([\s\S]*?)<\/Part>/g)) {
|
|
145
|
+
parts.push({
|
|
146
|
+
partNumber: Number(xml(block[1], "PartNumber")),
|
|
147
|
+
etag: xml(block[1], "ETag"),
|
|
148
|
+
size: Number(xml(block[1], "Size")),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (xml(text, "IsTruncated").toLowerCase() !== "true") break;
|
|
152
|
+
marker = xml(text, "NextPartNumberMarker");
|
|
153
|
+
if (!marker) throw new Error("B2 ListParts paginato senza marker");
|
|
154
|
+
}
|
|
155
|
+
return parts.filter((p) => Number.isInteger(p.partNumber) && p.partNumber > 0 && p.etag);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function b2PutSmall(env, key, body, contentType) {
|
|
159
|
+
let lastFailure = "";
|
|
160
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
161
|
+
let res;
|
|
162
|
+
try {
|
|
163
|
+
/* Ogni tentativo passa di nuovo da b2Request: data e firma SigV4 vengono
|
|
164
|
+
rigenerate. Il PUT è idempotente perché il manifest usa sempre la
|
|
165
|
+
stessa chiave immutabile del pacchetto. */
|
|
166
|
+
res = await b2Request(env, "PUT", key, {
|
|
167
|
+
headers: { "Content-Type": contentType },
|
|
168
|
+
body,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
lastFailure = error instanceof Error ? error.message : String(error);
|
|
172
|
+
if (attempt === 4) throw new Error(`B2 put: ${lastFailure}`);
|
|
173
|
+
await retryDelay(null, attempt);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (res.ok) return;
|
|
177
|
+
const detail = (await res.text()).replace(/\s+/g, " ").trim().slice(0, 300);
|
|
178
|
+
lastFailure = `${res.status}${detail ? `: ${detail}` : ""}`;
|
|
179
|
+
if (!transientStatus(res.status) || attempt === 4) {
|
|
180
|
+
throw new Error(`B2 put ${lastFailure}`);
|
|
181
|
+
}
|
|
182
|
+
await retryDelay(res, attempt);
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`B2 put ${lastFailure || "non riuscito"}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function b2Get(env, key, range) {
|
|
188
|
+
const headers = range ? { Range: range } : {};
|
|
189
|
+
return b2Request(env, "GET", key, { headers });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function b2Delete(env, key) {
|
|
193
|
+
const res = await b2Request(env, "DELETE", key);
|
|
194
|
+
if (!res.ok && res.status !== 404) {
|
|
195
|
+
throw new Error(`B2 delete ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assertConfig(env) {
|
|
200
|
+
if (!b2Configured(env)) {
|
|
201
|
+
throw new Error("Backblaze B2 non configurato: servono endpoint, region, bucket e application key");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function transientStatus(status) {
|
|
206
|
+
return status === 408 || status === 429 || status === 500 ||
|
|
207
|
+
status === 502 || status === 503 || status === 504;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function retryDelay(response, attempt) {
|
|
211
|
+
const retryAfter = Number(response?.headers.get("retry-after"));
|
|
212
|
+
const delay = Number.isFinite(retryAfter) && retryAfter > 0
|
|
213
|
+
? Math.min(10_000, retryAfter * 1000)
|
|
214
|
+
: Math.min(5_000, 400 * 2 ** attempt) + Math.floor(Math.random() * 150);
|
|
215
|
+
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function objectUrl(env, key) {
|
|
219
|
+
const endpoint = new URL(String(env.B2_ENDPOINT));
|
|
220
|
+
endpoint.hostname = `${env.B2_BUCKET}.${endpoint.hostname}`;
|
|
221
|
+
endpoint.pathname = "/" + String(key).split("/").map(rfc3986).join("/");
|
|
222
|
+
endpoint.search = "";
|
|
223
|
+
return endpoint;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function addQuery(url, query) {
|
|
227
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
228
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function canonicalPath(path) {
|
|
233
|
+
return path.split("/").map((part) => rfc3986(decodeURIComponent(part))).join("/") || "/";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function canonicalQuery(url) {
|
|
237
|
+
return [...url.searchParams.entries()]
|
|
238
|
+
.map(([k, v]) => [rfc3986(k), rfc3986(v)])
|
|
239
|
+
/* SigV4 richiede ordine lessicografico sui byte UTF-8 già percent-encoded.
|
|
240
|
+
localeCompare applica regole linguistiche (e può mettere `partNumber`
|
|
241
|
+
prima di `X-Amz-*`), producendo una firma diversa da quella verificata da B2. */
|
|
242
|
+
.sort(([ak, av], [bk, bv]) => ak === bk ? asciiCompare(av, bv) : asciiCompare(ak, bk))
|
|
243
|
+
.map(([k, v]) => `${k}=${v}`).join("&");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function asciiCompare(a, b) {
|
|
247
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function rfc3986(value) {
|
|
251
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (c) =>
|
|
252
|
+
`%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function cleanHeader(value) {
|
|
256
|
+
return value.trim().replace(/\s+/g, " ");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function stamp(date) {
|
|
260
|
+
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function signingKey(secret, date, region) {
|
|
264
|
+
const kDate = await hmac(enc.encode("AWS4" + secret), date);
|
|
265
|
+
const kRegion = await hmac(kDate, region);
|
|
266
|
+
const kService = await hmac(kRegion, "s3");
|
|
267
|
+
return hmac(kService, "aws4_request");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function hmac(rawKey, value) {
|
|
271
|
+
const key = await crypto.subtle.importKey(
|
|
272
|
+
"raw", rawKey, { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
|
273
|
+
);
|
|
274
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(value)));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function sha256Bytes(bytes) {
|
|
278
|
+
return hex(await crypto.subtle.digest("SHA-256", bytes));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function toBytes(value) {
|
|
282
|
+
if (typeof value === "string") return enc.encode(value);
|
|
283
|
+
if (value instanceof Uint8Array) return value;
|
|
284
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
285
|
+
throw new Error("body B2 non supportato");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function hex(buffer) {
|
|
289
|
+
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function xml(text, tag) {
|
|
293
|
+
const m = String(text).match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
|
|
294
|
+
return m ? m[1].replace(/"/g, '"').replace(/&/g, "&").trim() : "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function escapeXml(value) {
|
|
298
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<")
|
|
299
|
+
.replace(/>/g, ">").replace(/"/g, """);
|
|
300
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/* Segreti recuperabili e firme brevi per Delivery.
|
|
2
|
+
*
|
|
3
|
+
* Token e PIN devono poter essere ricopiati dall'admin, quindi un hash non basta:
|
|
4
|
+
* in D1 teniamo hash(token) per il lookup e AES-GCM(token/PIN) per la lettura
|
|
5
|
+
* dietro Access. DELIVERY_SECRET è l'unica chiave e non entra mai nel repo.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const enc = new TextEncoder();
|
|
9
|
+
const dec = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
export function b64url(bytes) {
|
|
12
|
+
let s = "";
|
|
13
|
+
const a = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
14
|
+
for (let i = 0; i < a.length; i += 0x8000) {
|
|
15
|
+
s += String.fromCharCode(...a.subarray(i, i + 0x8000));
|
|
16
|
+
}
|
|
17
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fromB64url(value) {
|
|
21
|
+
const s = String(value).replace(/-/g, "+").replace(/_/g, "/");
|
|
22
|
+
const raw = atob(s + "=".repeat((4 - s.length % 4) % 4));
|
|
23
|
+
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function randomToken(bytes = 24) {
|
|
27
|
+
return b64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function sha256(value) {
|
|
31
|
+
return hex(await crypto.subtle.digest("SHA-256", enc.encode(String(value))));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function aesKey(secret) {
|
|
35
|
+
if (!secret) throw new Error("DELIVERY_SECRET non configurato");
|
|
36
|
+
const raw = await crypto.subtle.digest("SHA-256", enc.encode(String(secret)));
|
|
37
|
+
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function seal(secret, value) {
|
|
41
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
42
|
+
const out = await crypto.subtle.encrypt(
|
|
43
|
+
{ name: "AES-GCM", iv },
|
|
44
|
+
await aesKey(secret),
|
|
45
|
+
enc.encode(String(value)),
|
|
46
|
+
);
|
|
47
|
+
return `v1.${b64url(iv)}.${b64url(out)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function unseal(secret, value) {
|
|
51
|
+
const [version, iv, body] = String(value || "").split(".");
|
|
52
|
+
if (version !== "v1" || !iv || !body) throw new Error("segreto Delivery illeggibile");
|
|
53
|
+
const out = await crypto.subtle.decrypt(
|
|
54
|
+
{ name: "AES-GCM", iv: fromB64url(iv) },
|
|
55
|
+
await aesKey(secret),
|
|
56
|
+
fromB64url(body),
|
|
57
|
+
);
|
|
58
|
+
return dec.decode(out);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function hmac(secret, value) {
|
|
62
|
+
const key = await crypto.subtle.importKey(
|
|
63
|
+
"raw", enc.encode(String(secret)), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
|
64
|
+
);
|
|
65
|
+
return b64url(await crypto.subtle.sign("HMAC", key, enc.encode(String(value))));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function signValue(secret, payload, ttlSeconds = 900) {
|
|
69
|
+
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
|
|
70
|
+
const body = b64url(enc.encode(JSON.stringify({ ...payload, exp })));
|
|
71
|
+
return `${body}.${await hmac(secret, body)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function verifyValue(secret, value) {
|
|
75
|
+
const dot = String(value || "").lastIndexOf(".");
|
|
76
|
+
if (dot < 1) return null;
|
|
77
|
+
const body = value.slice(0, dot);
|
|
78
|
+
const got = value.slice(dot + 1);
|
|
79
|
+
const want = await hmac(secret, body);
|
|
80
|
+
if (!safeEqual(got, want)) return null;
|
|
81
|
+
try {
|
|
82
|
+
const payload = JSON.parse(dec.decode(fromB64url(body)));
|
|
83
|
+
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) return null;
|
|
84
|
+
return payload;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function safeEqual(a, b) {
|
|
91
|
+
if (a.length !== b.length) return false;
|
|
92
|
+
let diff = 0;
|
|
93
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
94
|
+
return diff === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hex(buffer) {
|
|
98
|
+
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
99
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/* Cancellazione dei byte delle consegne: l'unico posto che tocca B2 per togliere.
|
|
2
|
+
*
|
|
3
|
+
* Sta in _shared perché ha due chiamanti con tempi diversi:
|
|
4
|
+
* - functions/admin/api/deliveries.js — la cancellazione chiesta a mano, che
|
|
5
|
+
* avviene nella richiesta stessa, e il recupero di ciò che si fosse fermato;
|
|
6
|
+
* - workers/delivery-housekeeping — il cron, che è l'unico a far scattare i
|
|
7
|
+
* lavori che nessuno innesca di persona (file scaduti, caricamenti
|
|
8
|
+
* abbandonati, originali archiviati). Pages non ha cron trigger: senza quel
|
|
9
|
+
* Worker quei tre restano fermi finché qualcuno non apre il pannello.
|
|
10
|
+
*
|
|
11
|
+
* NIENTE React e niente dipendenze dal contesto Pages: gira anche in un Worker
|
|
12
|
+
* nudo, con le sole `env.DB` e le variabili B2.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { b2AbortMultipart, b2Delete } from "./b2.js";
|
|
16
|
+
|
|
17
|
+
const DAY = 86_400_000;
|
|
18
|
+
|
|
19
|
+
/* Ogni oggetto tolto da B2 è una sottorichiesta, e il tetto è per invocazione:
|
|
20
|
+
* da qui il vecchio «due pacchetti per giro», che era un limite di piattaforma
|
|
21
|
+
* travestito da LIMIT. Il budget lo dice esplicitamente e lo rende regolabile —
|
|
22
|
+
* stretto quando c'è un utente che aspetta la risposta, largo nel cron.
|
|
23
|
+
* Un pacchetto già cominciato arriva in fondo anche se sfora (gli servono al
|
|
24
|
+
* più sette chiamate): le sue righe non spariscono finché i suoi byte no. */
|
|
25
|
+
export const B2_CALLS_PER_REQUEST = 24;
|
|
26
|
+
export const budget = (left = B2_CALLS_PER_REQUEST) => ({ left });
|
|
27
|
+
|
|
28
|
+
/* Toglie da B2 tutto ciò che appartiene ai pacchetti passati e poi le righe.
|
|
29
|
+
* I multipart ancora aperti vanno abortiti prima: le parti già caricate non
|
|
30
|
+
* appartengono a nessun oggetto e la lifecycle le raccoglierebbe soltanto dopo
|
|
31
|
+
* giorni. Una DELETE S3 su bucket versionato nasconde: i byte spariscono con
|
|
32
|
+
* `daysFromHidingToDeleting` (vedi DELIVERY.md). */
|
|
33
|
+
async function purgePackages(env, rows, calls) {
|
|
34
|
+
for (const pkg of rows) {
|
|
35
|
+
if (calls.left <= 0) return false;
|
|
36
|
+
const uploads = await env.DB.prepare(
|
|
37
|
+
"SELECT object_key,b2_upload_id,state FROM delivery_uploads WHERE package_id=?"
|
|
38
|
+
).bind(pkg.id).all();
|
|
39
|
+
for (const upload of (uploads.results || [])) {
|
|
40
|
+
if (upload.state === "uploading" && upload.b2_upload_id !== "single") {
|
|
41
|
+
calls.left -= 1;
|
|
42
|
+
await b2AbortMultipart(env, upload.object_key, upload.b2_upload_id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const keys = new Set([
|
|
46
|
+
pkg.originals_key, pkg.web_key, pkg.thumbs_key, pkg.manifest_key,
|
|
47
|
+
...(uploads.results || []).map((upload) => upload.object_key),
|
|
48
|
+
].filter(Boolean));
|
|
49
|
+
for (const key of keys) {
|
|
50
|
+
calls.left -= 1;
|
|
51
|
+
await b2Delete(env, key);
|
|
52
|
+
}
|
|
53
|
+
await env.DB.prepare("DELETE FROM delivery_packages WHERE id=? AND state='deleted'")
|
|
54
|
+
.bind(pkg.id).run();
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function purgeHeroes(env, rows, calls) {
|
|
60
|
+
for (const hero of rows) {
|
|
61
|
+
if (calls.left <= 0) return false;
|
|
62
|
+
calls.left -= 1;
|
|
63
|
+
await b2Delete(env, hero.object_key);
|
|
64
|
+
await env.DB.prepare("DELETE FROM delivery_hero_objects WHERE id=?").bind(hero.id).run();
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Cancellazione immediata di quanto è già marcato `deleted` per un transfer.
|
|
70
|
+
* Torna false se è rimasto indietro qualcosa — un errore su B2, o un transfer
|
|
71
|
+
* così grande da esaurire il budget: le tombstone hanno `purge_after` nel
|
|
72
|
+
* passato, quindi il giro successivo (cron, o la prossima schermata dell'admin)
|
|
73
|
+
* riprende da dov'era. */
|
|
74
|
+
export async function purgeDeliveryNow(env, id, calls = budget()) {
|
|
75
|
+
try {
|
|
76
|
+
const packages = await env.DB.prepare(
|
|
77
|
+
"SELECT * FROM delivery_packages WHERE delivery_id=? AND state='deleted'"
|
|
78
|
+
).bind(id).all();
|
|
79
|
+
if (!await purgePackages(env, packages.results || [], calls)) return false;
|
|
80
|
+
const heroes = await env.DB.prepare(
|
|
81
|
+
"SELECT id,object_key FROM delivery_hero_objects WHERE delivery_id=?"
|
|
82
|
+
).bind(id).all();
|
|
83
|
+
if (!await purgeHeroes(env, heroes.results || [], calls)) return false;
|
|
84
|
+
await env.DB.prepare(`
|
|
85
|
+
DELETE FROM deliveries
|
|
86
|
+
WHERE id=? AND status='deleted'
|
|
87
|
+
AND NOT EXISTS (SELECT 1 FROM delivery_packages p WHERE p.delivery_id=deliveries.id)
|
|
88
|
+
AND NOT EXISTS (SELECT 1 FROM delivery_hero_objects h WHERE h.delivery_id=deliveries.id)
|
|
89
|
+
`).bind(id).run();
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error("delivery purge now:", error);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Le hero dedicate vanno lette prima di cancellare il pacchetto: la loro
|
|
98
|
+
* `package_id` è ON DELETE SET NULL, e dopo non si saprebbe più a chi
|
|
99
|
+
* appartenevano. */
|
|
100
|
+
export async function purgePackageNow(env, packageId, calls = budget()) {
|
|
101
|
+
try {
|
|
102
|
+
const heroes = await env.DB.prepare(
|
|
103
|
+
"SELECT id,object_key FROM delivery_hero_objects WHERE package_id=?"
|
|
104
|
+
).bind(packageId).all();
|
|
105
|
+
if (!await purgeHeroes(env, heroes.results || [], calls)) return false;
|
|
106
|
+
const pkg = await env.DB.prepare(
|
|
107
|
+
"SELECT * FROM delivery_packages WHERE id=? AND state='deleted'"
|
|
108
|
+
).bind(packageId).first();
|
|
109
|
+
if (pkg) return purgePackages(env, [pkg], calls);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error("package purge now:", error);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Un giro di housekeeping. Le cancellazioni chieste a mano non passano di qui —
|
|
118
|
+
* avvengono nella loro richiesta — ma se una si era fermata a metà è questo giro
|
|
119
|
+
* a finirla, perché la sua `purge_after` è già scaduta. Un solo budget per tutte
|
|
120
|
+
* le fasi; le LIMIT tengono corta ogni singola query, il budget tiene corto il
|
|
121
|
+
* totale. Torna false se il budget è finito: c'è ancora lavoro da fare. */
|
|
122
|
+
export async function purgeDue(env, calls = budget()) {
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
const stale = new Date(Date.now() - 7 * DAY).toISOString();
|
|
125
|
+
const expiredFiles = await env.DB.prepare(`
|
|
126
|
+
SELECT id FROM deliveries
|
|
127
|
+
WHERE files_expires_at IS NOT NULL AND files_expires_at<=?
|
|
128
|
+
AND status IN ('uploading','active','archived')
|
|
129
|
+
AND EXISTS (
|
|
130
|
+
SELECT 1 FROM delivery_packages p
|
|
131
|
+
WHERE p.delivery_id=deliveries.id AND p.state!='deleted'
|
|
132
|
+
)
|
|
133
|
+
ORDER BY files_expires_at LIMIT 10
|
|
134
|
+
`).bind(now).all();
|
|
135
|
+
for (const delivery of (expiredFiles.results || [])) {
|
|
136
|
+
await env.DB.batch([
|
|
137
|
+
env.DB.prepare(`
|
|
138
|
+
UPDATE delivery_packages SET state='deleted',deleted_at=?,purge_after=?,updated_at=?
|
|
139
|
+
WHERE delivery_id=? AND state!='deleted'
|
|
140
|
+
`).bind(now, now, now, delivery.id),
|
|
141
|
+
env.DB.prepare(`
|
|
142
|
+
UPDATE delivery_hero_objects SET state='deleted',purge_after=?,updated_at=?
|
|
143
|
+
WHERE delivery_id=? AND state!='deleted'
|
|
144
|
+
`).bind(now, now, delivery.id),
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
await env.DB.prepare(`
|
|
148
|
+
UPDATE delivery_packages SET state='deleted',deleted_at=?,purge_after=?,updated_at=?
|
|
149
|
+
WHERE state='uploading' AND updated_at<=?
|
|
150
|
+
AND NOT EXISTS (
|
|
151
|
+
SELECT 1 FROM delivery_uploads u
|
|
152
|
+
WHERE u.package_id=delivery_packages.id AND u.updated_at>?
|
|
153
|
+
)
|
|
154
|
+
`).bind(now, now, now, stale, stale).run();
|
|
155
|
+
const abandoned = await env.DB.prepare(`
|
|
156
|
+
SELECT d.id FROM deliveries d
|
|
157
|
+
WHERE d.status='uploading' AND d.updated_at<=?
|
|
158
|
+
AND NOT EXISTS (
|
|
159
|
+
SELECT 1 FROM delivery_packages p
|
|
160
|
+
WHERE p.delivery_id=d.id AND p.state IN ('uploading','ready')
|
|
161
|
+
)
|
|
162
|
+
ORDER BY d.updated_at LIMIT 10
|
|
163
|
+
`).bind(stale).all();
|
|
164
|
+
/* Sono già fermi da sette giorni: non ne aggiungiamo altri sette. */
|
|
165
|
+
for (const delivery of (abandoned.results || [])) {
|
|
166
|
+
await env.DB.batch([
|
|
167
|
+
env.DB.prepare(`
|
|
168
|
+
UPDATE deliveries SET status='deleted',deleted_at=?,purge_after=?,updated_at=? WHERE id=?
|
|
169
|
+
`).bind(now, now, now, delivery.id),
|
|
170
|
+
env.DB.prepare(`
|
|
171
|
+
UPDATE delivery_links SET revoked_at=COALESCE(revoked_at,?) WHERE delivery_id=?
|
|
172
|
+
`).bind(now, delivery.id),
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
const staleHeroes = await env.DB.prepare(`
|
|
176
|
+
SELECT id,object_key FROM delivery_hero_objects
|
|
177
|
+
WHERE (state='pending' AND updated_at<=?)
|
|
178
|
+
OR (state='deleted' AND purge_after IS NOT NULL AND purge_after<=?)
|
|
179
|
+
ORDER BY updated_at LIMIT 10
|
|
180
|
+
`).bind(new Date(Date.now() - DAY).toISOString(), now).all();
|
|
181
|
+
if (!await purgeHeroes(env, staleHeroes.results || [], calls)) return false;
|
|
182
|
+
const archived = await env.DB.prepare(`
|
|
183
|
+
SELECT p.id,p.originals_key,u.bytes
|
|
184
|
+
FROM delivery_packages p
|
|
185
|
+
LEFT JOIN delivery_uploads u ON u.package_id=p.id AND u.variant='originals'
|
|
186
|
+
WHERE p.state='ready' AND p.originals_key IS NOT NULL
|
|
187
|
+
AND p.originals_purge_after IS NOT NULL AND p.originals_purge_after<=?
|
|
188
|
+
ORDER BY p.originals_purge_after LIMIT 4
|
|
189
|
+
`).bind(now).all();
|
|
190
|
+
for (const pkg of (archived.results || [])) {
|
|
191
|
+
if (calls.left <= 0) return false;
|
|
192
|
+
calls.left -= 1;
|
|
193
|
+
await b2Delete(env, pkg.originals_key);
|
|
194
|
+
await env.DB.batch([
|
|
195
|
+
env.DB.prepare(`
|
|
196
|
+
UPDATE delivery_packages SET originals_key=NULL,originals_purge_after=NULL,
|
|
197
|
+
stored_bytes=MAX(0,stored_bytes-?),updated_at=? WHERE id=?
|
|
198
|
+
`).bind(Number(pkg.bytes || 0), now, pkg.id),
|
|
199
|
+
env.DB.prepare("DELETE FROM delivery_uploads WHERE package_id=? AND variant='originals'")
|
|
200
|
+
.bind(pkg.id),
|
|
201
|
+
]);
|
|
202
|
+
}
|
|
203
|
+
const { results } = await env.DB.prepare(`
|
|
204
|
+
SELECT * FROM delivery_packages
|
|
205
|
+
WHERE state='deleted' AND purge_after IS NOT NULL AND purge_after<=?
|
|
206
|
+
ORDER BY purge_after LIMIT 8
|
|
207
|
+
`).bind(now).all();
|
|
208
|
+
if (!await purgePackages(env, results || [], calls)) return false;
|
|
209
|
+
await env.DB.prepare(`
|
|
210
|
+
DELETE FROM deliveries
|
|
211
|
+
WHERE status='deleted' AND purge_after IS NOT NULL AND purge_after<=?
|
|
212
|
+
AND NOT EXISTS (SELECT 1 FROM delivery_packages p WHERE p.delivery_id=deliveries.id)
|
|
213
|
+
AND NOT EXISTS (SELECT 1 FROM delivery_hero_objects h WHERE h.delivery_id=deliveries.id)
|
|
214
|
+
`).bind(now).run();
|
|
215
|
+
/* Una fase che ha riempito la sua LIMIT probabilmente ha ancora coda: chi
|
|
216
|
+
* chiama in loop (il cron) sa che deve rifare un giro. */
|
|
217
|
+
return (expiredFiles.results || []).length < 10 &&
|
|
218
|
+
(abandoned.results || []).length < 10 &&
|
|
219
|
+
(staleHeroes.results || []).length < 10 &&
|
|
220
|
+
(archived.results || []).length < 4 &&
|
|
221
|
+
(results || []).length < 8;
|
|
222
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* Un risultato calcolato una volta e riusato per qualche secondo, nella cache dell'edge.
|
|
2
|
+
*
|
|
3
|
+
* Lo usano i due numeri cari della Panoramica: lo spazio occupato su R2 va contato elencando
|
|
4
|
+
* il bucket INTERO (mille oggetti per chiamata), e le visite sono una richiesta a un servizio
|
|
5
|
+
* esterno con un tetto di chiamate. Nessuno dei due cambia da un minuto all'altro, e senza
|
|
6
|
+
* questo ogni ritorno sulla schermata iniziale — che è anche quella da cui si parte — ripaga
|
|
7
|
+
* il prezzo pieno.
|
|
8
|
+
*
|
|
9
|
+
* `caches.default` e NON una variabile di modulo: un isolate vive poco, non è condiviso e non
|
|
10
|
+
* si sa quale risponderà: una variabile darebbe un colpo di fortuna, non una cache.
|
|
11
|
+
*
|
|
12
|
+
* La chiave dev'essere un URL della ZONA della richiesta. Con un hostname inventato Cloudflare
|
|
13
|
+
* ignora la `put` senza dare errore, e la cache sembra funzionare mentre non fa niente: per
|
|
14
|
+
* questo la si costruisce da `request.url`. Il percorso non è una rotta — non esiste nessun
|
|
15
|
+
* file sotto quel nome — è solo un identificatore, ed è sotto /admin/ così anche a volerlo
|
|
16
|
+
* chiedere si finirebbe contro Access.
|
|
17
|
+
*/
|
|
18
|
+
export async function cachedJson(request, waitUntil, name, ttlSeconds, build) {
|
|
19
|
+
const key = new Request(new URL(`/admin/api/_cache/${name}`, request.url).toString());
|
|
20
|
+
const cache = caches.default;
|
|
21
|
+
|
|
22
|
+
// `fresh=1` salta la lettura ma NON la scrittura: chi preme «Aggiorna» rinfresca la cache
|
|
23
|
+
// per tutti, invece di prendersi il numero buono e lasciare agli altri quello vecchio.
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
if (url.searchParams.get("fresh") !== "1") {
|
|
26
|
+
const hit = await cache.match(key);
|
|
27
|
+
if (hit) return { data: await hit.json(), cached: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const data = await build();
|
|
31
|
+
const stored = new Response(JSON.stringify(data), {
|
|
32
|
+
headers: { "Content-Type": "application/json", "Cache-Control": `max-age=${ttlSeconds}` },
|
|
33
|
+
});
|
|
34
|
+
const put = cache.put(key, stored);
|
|
35
|
+
if (typeof waitUntil === "function") waitUntil(put); else await put;
|
|
36
|
+
return { data, cached: false };
|
|
37
|
+
}
|