c3b-assets 0.1.0 → 0.3.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/README.md +31 -14
- package/c3b-assets.js +188 -22
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -6,13 +6,14 @@ git and never need uploading by hand. Writes `d8e-asset-manifest.json`, which [d
|
|
|
6
6
|
|
|
7
7
|
## How it works
|
|
8
8
|
|
|
9
|
-
Run `c3b-assets` in a project root whenever you add, change or remove an image under `src/asset/`. It:
|
|
9
|
+
Run `c3b-assets u` in a project root whenever you add, change or remove an image under `src/asset/`. It:
|
|
10
10
|
|
|
11
11
|
1. Fingerprints each image from its content — `asset/hero-banner.webp` is stored as
|
|
12
12
|
`colab-asset-manager/hero-banner.3f2a9c1e.webp`. Change the image and it gets a new name automatically, so it can be
|
|
13
13
|
cached forever and a stale copy can never be served.
|
|
14
14
|
2. Uploads the ones the storage zone doesn't have yet (checksum-verified).
|
|
15
|
-
3. Writes `d8e-asset-manifest.json`: each image's path under `src/` and its CDN URL. **Commit this file** — it's a few
|
|
15
|
+
3. Writes `d8e-asset-manifest.json`: each image's path under `src/` and its CDN URL. **Commit this file** — it's a few
|
|
16
|
+
KB.
|
|
16
17
|
4. Deletes files nobody references any more, **7 days** after they stopped being referenced, so pages in browser
|
|
17
18
|
caches, sent emails and older staging builds keep working in the meantime.
|
|
18
19
|
|
|
@@ -21,7 +22,8 @@ local dev still works by opening `src/`. At deploy, d8e rewrites those reference
|
|
|
21
22
|
images out of `dist/`.
|
|
22
23
|
|
|
23
24
|
It only ever lists, uploads to and deletes inside the zone's `colab-asset-manager/` folder (the tool's old name,
|
|
24
|
-
kept so existing zones need no re-upload) — files uploaded by hand anywhere else in the zone are never touched.
|
|
25
|
+
kept so existing zones need no re-upload) — files uploaded by hand anywhere else in the zone are never touched.
|
|
26
|
+
Supported: `avif`, `gif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.
|
|
25
27
|
|
|
26
28
|
It runs on your machine only, never at deploy: the storage key stays off Heroku and Vercel, and staging can't delete
|
|
27
29
|
production's images.
|
|
@@ -72,33 +74,48 @@ your shell profile or a password manager — never in the repo.
|
|
|
72
74
|
## Usage
|
|
73
75
|
|
|
74
76
|
```
|
|
75
|
-
c3b-assets
|
|
76
|
-
c3b-assets
|
|
77
|
-
c3b-assets
|
|
78
|
-
c3b-assets
|
|
77
|
+
c3b-assets # status: src/asset/ against the manifest — new, changed, missing, retired. Changes nothing, no key needed
|
|
78
|
+
c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json (deletes need --allow-removals)
|
|
79
|
+
c3b-assets s # sync: download the images the manifest lists that are missing locally, then upload as "u"
|
|
80
|
+
c3b-assets --help # the commands and flags
|
|
79
81
|
```
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
Flags: `--dry-run` (report, change nothing), `--allow-removals` (see Safety checks). The version is printed first, so
|
|
84
|
+
you can tell which install is running.
|
|
85
|
+
|
|
86
|
+
`s` needs no key to download — the CDN is public — and only asks for `BUNNY_STORAGE_KEY` if there is something to
|
|
87
|
+
upload or delete afterwards. So a fresh clone on a machine without the key still gets its images.
|
|
82
88
|
|
|
83
89
|
### Changing an image
|
|
84
90
|
|
|
85
|
-
1. **Fresh clone?** Run `c3b-assets
|
|
91
|
+
1. **Fresh clone?** Run `c3b-assets s` first — images aren't in git, so `src/asset/` starts empty.
|
|
86
92
|
2. Replace, add or remove the file in `src/asset/` (kebab-case names). Keeping the same name for a replacement is
|
|
87
93
|
fine: the fingerprint changes anyway. Check it locally by opening `src/`.
|
|
88
|
-
3. `c3b-assets --dry-run
|
|
94
|
+
3. `c3b-assets` to see what will happen, `c3b-assets u --dry-run` if you want the exact Bunny paths, then
|
|
95
|
+
`c3b-assets u`.
|
|
89
96
|
4. Commit `d8e-asset-manifest.json` (plus any code change that references a new image) and deploy as usual.
|
|
90
97
|
|
|
91
98
|
If two people sync on different branches, `d8e-asset-manifest.json` conflicts on merge: pull both sets of images
|
|
92
|
-
into `src/asset/` (
|
|
99
|
+
into `src/asset/` (`c3b-assets s` on each) and run `c3b-assets u` again — it rebuilds the manifest from what's there.
|
|
93
100
|
|
|
94
101
|
### Safety checks
|
|
95
102
|
|
|
96
103
|
- **Nothing is deleted just because it's missing locally.** If images the manifest lists aren't in `src/asset/` (a
|
|
97
|
-
fresh clone, a half-copied folder),
|
|
104
|
+
fresh clone, a half-copied folder), `u` refuses and says to run `s`. If you removed them on purpose, add
|
|
98
105
|
`--allow-removals`.
|
|
99
|
-
-
|
|
106
|
+
- The download step of `s` never overwrites a local file that differs from Bunny — that's usually a change that hasn't
|
|
107
|
+
been uploaded
|
|
100
108
|
yet. It only fills in missing ones, and checks each download against its fingerprint.
|
|
101
|
-
- Deletions only happen inside the zone's `colab-asset-manager/` folder, 7 days after a file stopped being
|
|
109
|
+
- Deletions only happen inside the zone's `colab-asset-manager/` folder, 7 days after a file stopped being
|
|
110
|
+
referenced, and only with `--allow-removals`: "unreferenced" is judged from the checkout you run in, which may be
|
|
111
|
+
a branch that dropped images `main` still serves. A plain `u` reports what is due and stops. A folder left empty by
|
|
112
|
+
those deletions is deleted with them (Bunny keeps empty folders otherwise).
|
|
113
|
+
- Only files this project's manifest has listed are ever retired or deleted. If two projects share a storage zone,
|
|
114
|
+
neither touches the other's images.
|
|
115
|
+
- The download step of `s` only fetches image files, and only from this project's `cdnUrl` folder — the manifest is
|
|
116
|
+
a committed file, so a tampered entry can't plant something else in `src/asset/`.
|
|
117
|
+
- `storageHost` must be a Bunny storage endpoint (`storage.bunnycdn.com`, optionally region-prefixed): the storage
|
|
118
|
+
key is sent to it, and the config is committed.
|
|
102
119
|
|
|
103
120
|
## Limitations
|
|
104
121
|
|
package/c3b-assets.js
CHANGED
|
@@ -33,17 +33,26 @@ const FOLDER = 'colab-asset-manager';
|
|
|
33
33
|
const GRACE_DAYS = 7;
|
|
34
34
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
// listed here is rejected rather than silently ignored.
|
|
36
|
+
// The commands and flags the tool accepts, each with its one-line description. `--help` prints them;
|
|
37
|
+
// anything not listed here is rejected rather than silently ignored.
|
|
38
|
+
const COMMANDS = {
|
|
39
|
+
'u': 'upload: fingerprint src/asset/, upload new images, write d8e-asset-manifest.json, delete retired files',
|
|
40
|
+
's': 'sync: download images the manifest lists that are missing locally, then upload (as "u")'
|
|
41
|
+
};
|
|
38
42
|
const FLAGS = {
|
|
39
|
-
'--dry-run': 'report what would be uploaded, deleted and written, change nothing',
|
|
40
|
-
'--
|
|
41
|
-
|
|
42
|
-
'--help': 'print this help'
|
|
43
|
+
'--dry-run': 'report what would be uploaded, downloaded, deleted and written, change nothing',
|
|
44
|
+
'--allow-removals': 'allow manifest entries with no local file to be dropped, and retired files past the grace '
|
|
45
|
+
+ 'period to be deleted from Bunny',
|
|
46
|
+
'--help': 'print this help'
|
|
43
47
|
};
|
|
44
48
|
|
|
45
49
|
function usage() {
|
|
46
|
-
console.log(
|
|
50
|
+
console.log('Usage: c3b-assets [command] [flags]\n\nWith no command, prints the status of src/asset/ against '
|
|
51
|
+
+ 'd8e-asset-manifest.json and changes nothing.\n\nCommands:');
|
|
52
|
+
for (const [command, description] of Object.entries(COMMANDS)) {
|
|
53
|
+
console.log(` ${ command.padEnd(18) }${ description }`);
|
|
54
|
+
}
|
|
55
|
+
console.log('\nFlags:');
|
|
47
56
|
for (const [flag, description] of Object.entries(FLAGS)) {
|
|
48
57
|
console.log(` ${ flag.padEnd(18) }${ description }`);
|
|
49
58
|
}
|
|
@@ -54,16 +63,16 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
54
63
|
usage();
|
|
55
64
|
process.exit(0);
|
|
56
65
|
}
|
|
57
|
-
|
|
66
|
+
const command = args[0] in COMMANDS ? args[0] : null;
|
|
67
|
+
for (const arg of args.slice(command ? 1 : 0)) {
|
|
58
68
|
if (!(arg in FLAGS)) {
|
|
59
|
-
console.error(`c3b-assets: unknown option ${ arg }\n`);
|
|
69
|
+
console.error(`c3b-assets: unknown ${ arg.startsWith('-') ? 'option' : 'command' } ${ arg }\n`);
|
|
60
70
|
usage();
|
|
61
71
|
process.exit(1);
|
|
62
72
|
}
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
const dryRun = args.includes('--dry-run');
|
|
66
|
-
const pull = args.includes('--pull');
|
|
67
76
|
const allowRemovals = args.includes('--allow-removals');
|
|
68
77
|
|
|
69
78
|
function fail(message) {
|
|
@@ -91,9 +100,16 @@ function readConfig() {
|
|
|
91
100
|
fail('c3b-assets.json needs "storageZone" and "cdnUrl"');
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
// The storage key goes to this host in every request, and the config is a committed file: only
|
|
104
|
+
// Bunny's own storage endpoints (optionally region-prefixed) may receive it.
|
|
105
|
+
const storageHost = config.storageHost ?? 'storage.bunnycdn.com';
|
|
106
|
+
if (!/^([a-z]{2,3}\.)?storage\.bunnycdn\.com$/.test(storageHost)) {
|
|
107
|
+
fail(`c3b-assets.json "storageHost" must be a Bunny storage endpoint, not ${ storageHost }`);
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
return {
|
|
95
111
|
storageZone: config.storageZone,
|
|
96
|
-
storageHost
|
|
112
|
+
storageHost,
|
|
97
113
|
cdnUrl: config.cdnUrl.replace(/\/+$/, '')
|
|
98
114
|
};
|
|
99
115
|
}
|
|
@@ -122,13 +138,15 @@ function scanImages() {
|
|
|
122
138
|
|
|
123
139
|
// Which unreferenced files to delete now, and the updated record of when each one stopped being
|
|
124
140
|
// referenced. That record is kept in the manifest, so the grace period survives between runs and
|
|
125
|
-
// machines.
|
|
126
|
-
|
|
141
|
+
// machines. Only files this project's manifest has known about (`known`) are candidates: a zone
|
|
142
|
+
// shared with another project holds files this checkout never referenced, and they are not ours
|
|
143
|
+
// to delete.
|
|
144
|
+
function planDeletions(remoteFiles, referenced, known, previouslyRetired, now) {
|
|
127
145
|
const retired = {};
|
|
128
146
|
const toDelete = [];
|
|
129
147
|
|
|
130
148
|
for (const path of remoteFiles) {
|
|
131
|
-
if (referenced.has(path)) {
|
|
149
|
+
if (referenced.has(path) || !known.has(path)) {
|
|
132
150
|
continue;
|
|
133
151
|
}
|
|
134
152
|
|
|
@@ -154,13 +172,84 @@ function sortedObject(entries) {
|
|
|
154
172
|
|
|
155
173
|
const config = readConfig();
|
|
156
174
|
|
|
157
|
-
|
|
175
|
+
// Every manifest URL is `${ cdnUrl }/${ encodeURI(remotePath) }`; anything else is not ours.
|
|
176
|
+
const CDN_PREFIX = `${ config.cdnUrl }/${ FOLDER }/`;
|
|
177
|
+
const remotePathOf = (url) => (url.startsWith(CDN_PREFIX) ? decodeURI(url.slice(config.cdnUrl.length + 1)) : null);
|
|
178
|
+
|
|
179
|
+
// Where src/asset/ stands against the manifest, without touching Bunny: images not in the manifest
|
|
180
|
+
// yet, images whose content no longer matches the URL the manifest holds, and manifest entries with
|
|
181
|
+
// no local file. The manifest is only written after every upload succeeded, so "in the manifest"
|
|
182
|
+
// means "on Bunny" — this is what a bare run reports and what a keyless sync decides on.
|
|
183
|
+
function localChanges(images, previous) {
|
|
184
|
+
const assets = previous.assets ?? {};
|
|
185
|
+
const localKeys = new Set(images.map((image) => image.key));
|
|
186
|
+
const added = [];
|
|
187
|
+
const changed = [];
|
|
188
|
+
for (const image of images) {
|
|
189
|
+
if (!(image.key in assets)) {
|
|
190
|
+
added.push(image.key);
|
|
191
|
+
} else if (!assets[image.key].endsWith('/' + encodeURI(image.remotePath))) {
|
|
192
|
+
changed.push(image.key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const missing = Object.keys(assets).filter((key) => !localKeys.has(key));
|
|
196
|
+
|
|
197
|
+
return { added, changed, missing };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function requireStorageKey() {
|
|
158
201
|
const accessKey = process.env.BUNNY_STORAGE_KEY;
|
|
159
202
|
if (!accessKey) {
|
|
160
203
|
fail('set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
|
|
161
204
|
}
|
|
205
|
+
|
|
206
|
+
return accessKey;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The bare run: a read-only report of src/asset/ against the manifest, so it needs no key.
|
|
210
|
+
function status() {
|
|
211
|
+
console.log(`zone ${ config.storageZone } -> ${ config.cdnUrl }`);
|
|
162
212
|
if (!existsSync(ASSET_DIR)) {
|
|
163
|
-
|
|
213
|
+
console.log(`${ ASSET_DIR } not found — run "c3b-assets s" to download the manifest's images`);
|
|
214
|
+
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const images = scanImages();
|
|
219
|
+
const previous = existsSync(MANIFEST) ? readJson(MANIFEST) : {};
|
|
220
|
+
const { added, changed, missing } = localChanges(images, previous);
|
|
221
|
+
const retired = Object.entries(previous.retired ?? {});
|
|
222
|
+
const due = retired.filter(([, since]) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
|
|
223
|
+
|
|
224
|
+
console.log(`${ images.length } images in src/asset/, ${ Object.keys(previous.assets ?? {}).length } in the manifest`);
|
|
225
|
+
for (const key of added) {
|
|
226
|
+
console.log(` new ${ key }`);
|
|
227
|
+
}
|
|
228
|
+
for (const key of changed) {
|
|
229
|
+
console.log(` changed ${ key }`);
|
|
230
|
+
}
|
|
231
|
+
for (const key of missing) {
|
|
232
|
+
console.log(` missing ${ key } (in the manifest, not in src/asset/)`);
|
|
233
|
+
}
|
|
234
|
+
if (retired.length > 0) {
|
|
235
|
+
console.log(` ${ retired.length } retired on Bunny, ${ due } past the ${ GRACE_DAYS }-day grace period`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const toUpload = added.length + changed.length;
|
|
239
|
+
if (toUpload === 0 && missing.length === 0 && due === 0) {
|
|
240
|
+
console.log('Everything in src/asset/ is uploaded and in the manifest.');
|
|
241
|
+
} else if (missing.length > 0) {
|
|
242
|
+
console.log(`Run "c3b-assets s" to download ${ missing.length } missing image(s)`
|
|
243
|
+
+ (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
|
|
244
|
+
} else {
|
|
245
|
+
console.log(`Run "c3b-assets u" to upload ${ toUpload } image(s)`
|
|
246
|
+
+ (due > 0 ? `, with --allow-removals to delete ${ due } retired file(s).` : '.'));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function upload(accessKey) {
|
|
251
|
+
if (!existsSync(ASSET_DIR)) {
|
|
252
|
+
fail(`${ ASSET_DIR } not found — on a fresh clone, run "c3b-assets s" first`);
|
|
164
253
|
}
|
|
165
254
|
|
|
166
255
|
const storage = createStorage({ host: config.storageHost, zone: config.storageZone, accessKey });
|
|
@@ -173,17 +262,28 @@ async function sync() {
|
|
|
173
262
|
const missing = Object.keys(previous.assets ?? {}).filter((key) => !localKeys.has(key));
|
|
174
263
|
if (missing.length > 0 && !allowRemovals) {
|
|
175
264
|
fail(`${ missing.length } image(s) in the manifest aren't in src/asset/ (e.g. ${ missing[0] }). `
|
|
176
|
-
|
|
265
|
+
+ 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
|
|
177
266
|
}
|
|
178
267
|
|
|
179
268
|
const remoteFiles = new Set(await storage.listFiles(FOLDER));
|
|
180
269
|
|
|
181
270
|
const toUpload = images.filter((image) => !remoteFiles.has(image.remotePath));
|
|
182
271
|
const referenced = new Set(images.map((image) => image.remotePath));
|
|
183
|
-
const
|
|
272
|
+
const known = new Set([
|
|
273
|
+
...Object.values(previous.assets ?? {}).map(remotePathOf),
|
|
274
|
+
...Object.keys(previous.retired ?? {})
|
|
275
|
+
]);
|
|
276
|
+
const { retired, toDelete } = planDeletions(remoteFiles, referenced, known, previous.retired ?? {}, new Date());
|
|
277
|
+
|
|
278
|
+
// Deleting is driven by this checkout, which may be a branch that dropped images main still
|
|
279
|
+
// serves — so it is never a side effect of a plain upload.
|
|
280
|
+
if (toDelete.length > 0 && !allowRemovals) {
|
|
281
|
+
fail(`${ toDelete.length } retired file(s) are past the ${ GRACE_DAYS }-day grace period. `
|
|
282
|
+
+ 'Add --allow-removals to delete them from Bunny, once this branch is what production serves.');
|
|
283
|
+
}
|
|
184
284
|
|
|
185
285
|
console.log(`${ images.length } images, ${ toUpload.length } to upload, ${ toDelete.length } to delete, `
|
|
186
|
-
|
|
286
|
+
+ `${ Object.keys(retired).length } waiting out the ${ GRACE_DAYS }-day grace period`);
|
|
187
287
|
|
|
188
288
|
for (const image of toUpload) {
|
|
189
289
|
console.log(` upload ${ image.remotePath }`);
|
|
@@ -210,15 +310,70 @@ async function sync() {
|
|
|
210
310
|
}
|
|
211
311
|
}
|
|
212
312
|
|
|
313
|
+
// Bunny leaves a folder behind when its last file goes, so the zone slowly fills with empty
|
|
314
|
+
// ones. Deepest first, so a parent is empty by the time it is checked. The listing predates
|
|
315
|
+
// this run's uploads, so they are added back in — a folder that only just received a new
|
|
316
|
+
// image is not empty.
|
|
317
|
+
const deleted = new Set(toDelete);
|
|
318
|
+
const remaining = [...remoteFiles, ...referenced].filter((path) => !deleted.has(path));
|
|
319
|
+
for (const folder of emptiedFolders(toDelete, remaining)) {
|
|
320
|
+
console.log(` delete ${ folder }/`);
|
|
321
|
+
if (!dryRun) {
|
|
322
|
+
await storage.remove(folder + '/');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
213
326
|
console.log(dryRun ? 'Dry run — nothing uploaded, deleted or written.' : `Wrote ${ MANIFEST }`);
|
|
214
327
|
}
|
|
215
328
|
|
|
329
|
+
// Every folder under FOLDER that the deletions left with nothing in it, deepest first. FOLDER
|
|
330
|
+
// itself is never returned — an empty zone folder is harmless and the next upload needs it.
|
|
331
|
+
function emptiedFolders(deleted, remaining) {
|
|
332
|
+
const candidates = new Set();
|
|
333
|
+
for (const path of deleted) {
|
|
334
|
+
let folder = dirname(path);
|
|
335
|
+
while (folder !== FOLDER && folder !== '.') {
|
|
336
|
+
candidates.add(folder);
|
|
337
|
+
folder = dirname(folder);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return [...candidates]
|
|
342
|
+
.sort((a, b) => b.length - a.length)
|
|
343
|
+
.filter((folder) => !remaining.some((path) => path.startsWith(folder + '/')));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Pull, then upload. The pull needs no storage key (the CDN is public), so a fresh clone without the
|
|
347
|
+
// key still gets its images: the key is only demanded when the upload step actually has work to do.
|
|
348
|
+
async function sync() {
|
|
349
|
+
await pullImages();
|
|
350
|
+
|
|
351
|
+
const accessKey = process.env.BUNNY_STORAGE_KEY;
|
|
352
|
+
if (accessKey) {
|
|
353
|
+
await upload(accessKey);
|
|
354
|
+
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const previous = existsSync(MANIFEST) ? readJson(MANIFEST) : {};
|
|
359
|
+
const { added, changed } = localChanges(existsSync(ASSET_DIR) ? scanImages() : [], previous);
|
|
360
|
+
const due = Object.values(previous.retired ?? {})
|
|
361
|
+
.filter((since) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
|
|
362
|
+
if (added.length + changed.length + due > 0) {
|
|
363
|
+
fail(`${ added.length + changed.length } image(s) to upload and ${ due } retired file(s) to delete — `
|
|
364
|
+
+ 'set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
|
|
365
|
+
}
|
|
366
|
+
console.log('Nothing to upload.');
|
|
367
|
+
}
|
|
368
|
+
|
|
216
369
|
// Downloads every image in the manifest that src/asset/ is missing, straight from the CDN — so it
|
|
217
370
|
// needs no storage key, just the committed manifest. A local file that differs from the manifest is
|
|
218
371
|
// kept, not overwritten: it's most likely a change that hasn't been uploaded yet.
|
|
219
372
|
async function pullImages() {
|
|
220
373
|
if (!existsSync(MANIFEST)) {
|
|
221
|
-
|
|
374
|
+
console.log('No manifest yet — nothing to download.');
|
|
375
|
+
|
|
376
|
+
return;
|
|
222
377
|
}
|
|
223
378
|
|
|
224
379
|
const assets = readJson(MANIFEST).assets ?? {};
|
|
@@ -231,6 +386,14 @@ async function pullImages() {
|
|
|
231
386
|
if (!target.startsWith(ASSET_DIR + sep)) {
|
|
232
387
|
fail(`refusing to write outside src/asset/: ${ key }`);
|
|
233
388
|
}
|
|
389
|
+
// The manifest is a committed file, so what it points at is only trusted as far as this
|
|
390
|
+
// project's own CDN folder, and only ever written as an image.
|
|
391
|
+
if (!IMAGE_EXTENSIONS.includes(extname(key).slice(1).toLowerCase())) {
|
|
392
|
+
fail(`refusing to download ${ key }: not an image`);
|
|
393
|
+
}
|
|
394
|
+
if (remotePathOf(url) === null) {
|
|
395
|
+
fail(`refusing to download ${ key } from outside ${ CDN_PREFIX }: ${ url }`);
|
|
396
|
+
}
|
|
234
397
|
|
|
235
398
|
// the fingerprint in the stored name (hero.0100d254.webp) is the content hash we expect
|
|
236
399
|
const expected = decodeURI(url).match(/\.([0-9a-f]{8})\.[^./]+$/)?.[1];
|
|
@@ -266,8 +429,11 @@ async function pullImages() {
|
|
|
266
429
|
}
|
|
267
430
|
|
|
268
431
|
console.log(`${ downloaded } downloaded, ${ upToDate } already up to date, ${ kept } kept (local changes)`
|
|
269
|
-
|
|
432
|
+
+ (dryRun ? ' — dry run, nothing written' : ''));
|
|
270
433
|
}
|
|
271
434
|
|
|
272
435
|
// Bunny API errors (wrong key, wrong zone or region) print as one line rather than a stack trace
|
|
273
|
-
|
|
436
|
+
const run = command === 'u' ? () => upload(requireStorageKey())
|
|
437
|
+
: command === 's' ? sync
|
|
438
|
+
: async () => status();
|
|
439
|
+
run().catch((error) => fail(error.message));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c3b-assets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Fingerprints a project's images and syncs them to a Bunny storage zone, writing the d8e-asset-manifest.json d8e reads.",
|
|
5
5
|
"license": "GPL-3.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"registry": "https://registry.npmjs.org"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "node --test test/*.test.js",
|
|
25
|
+
"test": "c3b-lint && node --test test/*.test.js",
|
|
26
26
|
"deploy": "npm run test && npm publish"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"c3b-lint": "0.0.8"
|
|
27
30
|
}
|
|
28
31
|
}
|