c3b-assets 0.2.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.
Files changed (3) hide show
  1. package/README.md +18 -6
  2. package/c3b-assets.js +60 -22
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -12,7 +12,8 @@ Run `c3b-assets u` in a project root whenever you add, change or remove an image
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 KB.
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. Supported: `avif`, `gif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.
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.
@@ -73,7 +75,7 @@ your shell profile or a password manager — never in the repo.
73
75
 
74
76
  ```
75
77
  c3b-assets # status: src/asset/ against the manifest — new, changed, missing, retired. Changes nothing, no key needed
76
- c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json, delete retired files
78
+ c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json (deletes need --allow-removals)
77
79
  c3b-assets s # sync: download the images the manifest lists that are missing locally, then upload as "u"
78
80
  c3b-assets --help # the commands and flags
79
81
  ```
@@ -89,7 +91,8 @@ upload or delete afterwards. So a fresh clone on a machine without the key still
89
91
  1. **Fresh clone?** Run `c3b-assets s` first — images aren't in git, so `src/asset/` starts empty.
90
92
  2. Replace, add or remove the file in `src/asset/` (kebab-case names). Keeping the same name for a replacement is
91
93
  fine: the fingerprint changes anyway. Check it locally by opening `src/`.
92
- 3. `c3b-assets` to see what will happen, `c3b-assets u --dry-run` if you want the exact Bunny paths, then `c3b-assets u`.
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`.
93
96
  4. Commit `d8e-asset-manifest.json` (plus any code change that references a new image) and deploy as usual.
94
97
 
95
98
  If two people sync on different branches, `d8e-asset-manifest.json` conflicts on merge: pull both sets of images
@@ -100,10 +103,19 @@ into `src/asset/` (`c3b-assets s` on each) and run `c3b-assets u` again — it r
100
103
  - **Nothing is deleted just because it's missing locally.** If images the manifest lists aren't in `src/asset/` (a
101
104
  fresh clone, a half-copied folder), `u` refuses and says to run `s`. If you removed them on purpose, add
102
105
  `--allow-removals`.
103
- - The download step of `s` never overwrites a local file that differs from Bunny — that's usually a change that hasn't been uploaded
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
104
108
  yet. It only fills in missing ones, and checks each download against its fingerprint.
105
109
  - Deletions only happen inside the zone's `colab-asset-manager/` folder, 7 days after a file stopped being
106
- referenced. A folder left empty by those deletions is deleted with them (Bunny keeps empty folders otherwise).
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.
107
119
 
108
120
  ## Limitations
109
121
 
package/c3b-assets.js CHANGED
@@ -37,17 +37,18 @@ const DAY_MS = 24 * 60 * 60 * 1000;
37
37
  // anything not listed here is rejected rather than silently ignored.
38
38
  const COMMANDS = {
39
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")',
40
+ 's': 'sync: download images the manifest lists that are missing locally, then upload (as "u")'
41
41
  };
42
42
  const FLAGS = {
43
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',
45
- '--help': 'print this help',
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'
46
47
  };
47
48
 
48
49
  function usage() {
49
50
  console.log('Usage: c3b-assets [command] [flags]\n\nWith no command, prints the status of src/asset/ against '
50
- + 'd8e-asset-manifest.json and changes nothing.\n\nCommands:');
51
+ + 'd8e-asset-manifest.json and changes nothing.\n\nCommands:');
51
52
  for (const [command, description] of Object.entries(COMMANDS)) {
52
53
  console.log(` ${ command.padEnd(18) }${ description }`);
53
54
  }
@@ -99,9 +100,16 @@ function readConfig() {
99
100
  fail('c3b-assets.json needs "storageZone" and "cdnUrl"');
100
101
  }
101
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
+
102
110
  return {
103
111
  storageZone: config.storageZone,
104
- storageHost: config.storageHost ?? 'storage.bunnycdn.com',
112
+ storageHost,
105
113
  cdnUrl: config.cdnUrl.replace(/\/+$/, '')
106
114
  };
107
115
  }
@@ -130,13 +138,15 @@ function scanImages() {
130
138
 
131
139
  // Which unreferenced files to delete now, and the updated record of when each one stopped being
132
140
  // referenced. That record is kept in the manifest, so the grace period survives between runs and
133
- // machines.
134
- function planDeletions(remoteFiles, referenced, previouslyRetired, now) {
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) {
135
145
  const retired = {};
136
146
  const toDelete = [];
137
147
 
138
148
  for (const path of remoteFiles) {
139
- if (referenced.has(path)) {
149
+ if (referenced.has(path) || !known.has(path)) {
140
150
  continue;
141
151
  }
142
152
 
@@ -162,6 +172,10 @@ function sortedObject(entries) {
162
172
 
163
173
  const config = readConfig();
164
174
 
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
+
165
179
  // Where src/asset/ stands against the manifest, without touching Bunny: images not in the manifest
166
180
  // yet, images whose content no longer matches the URL the manifest holds, and manifest entries with
167
181
  // no local file. The manifest is only written after every upload succeeded, so "in the manifest"
@@ -197,6 +211,7 @@ function status() {
197
211
  console.log(`zone ${ config.storageZone } -> ${ config.cdnUrl }`);
198
212
  if (!existsSync(ASSET_DIR)) {
199
213
  console.log(`${ ASSET_DIR } not found — run "c3b-assets s" to download the manifest's images`);
214
+
200
215
  return;
201
216
  }
202
217
 
@@ -225,10 +240,10 @@ function status() {
225
240
  console.log('Everything in src/asset/ is uploaded and in the manifest.');
226
241
  } else if (missing.length > 0) {
227
242
  console.log(`Run "c3b-assets s" to download ${ missing.length } missing image(s)`
228
- + (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
243
+ + (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
229
244
  } else {
230
245
  console.log(`Run "c3b-assets u" to upload ${ toUpload } image(s)`
231
- + (due > 0 ? ` and delete ${ due } retired file(s).` : '.'));
246
+ + (due > 0 ? `, with --allow-removals to delete ${ due } retired file(s).` : '.'));
232
247
  }
233
248
  }
234
249
 
@@ -247,17 +262,28 @@ async function upload(accessKey) {
247
262
  const missing = Object.keys(previous.assets ?? {}).filter((key) => !localKeys.has(key));
248
263
  if (missing.length > 0 && !allowRemovals) {
249
264
  fail(`${ missing.length } image(s) in the manifest aren't in src/asset/ (e.g. ${ missing[0] }). `
250
- + 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
265
+ + 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
251
266
  }
252
267
 
253
268
  const remoteFiles = new Set(await storage.listFiles(FOLDER));
254
269
 
255
270
  const toUpload = images.filter((image) => !remoteFiles.has(image.remotePath));
256
271
  const referenced = new Set(images.map((image) => image.remotePath));
257
- const { retired, toDelete } = planDeletions(remoteFiles, referenced, previous.retired ?? {}, new Date());
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
+ }
258
284
 
259
285
  console.log(`${ images.length } images, ${ toUpload.length } to upload, ${ toDelete.length } to delete, `
260
- + `${ Object.keys(retired).length } waiting out the ${ GRACE_DAYS }-day grace period`);
286
+ + `${ Object.keys(retired).length } waiting out the ${ GRACE_DAYS }-day grace period`);
261
287
 
262
288
  for (const image of toUpload) {
263
289
  console.log(` upload ${ image.remotePath }`);
@@ -285,9 +311,11 @@ async function upload(accessKey) {
285
311
  }
286
312
 
287
313
  // Bunny leaves a folder behind when its last file goes, so the zone slowly fills with empty
288
- // ones. Deepest first, so a parent is empty by the time it is checked.
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.
289
317
  const deleted = new Set(toDelete);
290
- const remaining = [...remoteFiles].filter((path) => !deleted.has(path));
318
+ const remaining = [...remoteFiles, ...referenced].filter((path) => !deleted.has(path));
291
319
  for (const folder of emptiedFolders(toDelete, remaining)) {
292
320
  console.log(` delete ${ folder }/`);
293
321
  if (!dryRun) {
@@ -311,8 +339,8 @@ function emptiedFolders(deleted, remaining) {
311
339
  }
312
340
 
313
341
  return [...candidates]
314
- .sort((a, b) => b.length - a.length)
315
- .filter((folder) => !remaining.some((path) => path.startsWith(folder + '/')));
342
+ .sort((a, b) => b.length - a.length)
343
+ .filter((folder) => !remaining.some((path) => path.startsWith(folder + '/')));
316
344
  }
317
345
 
318
346
  // Pull, then upload. The pull needs no storage key (the CDN is public), so a fresh clone without the
@@ -323,16 +351,17 @@ async function sync() {
323
351
  const accessKey = process.env.BUNNY_STORAGE_KEY;
324
352
  if (accessKey) {
325
353
  await upload(accessKey);
354
+
326
355
  return;
327
356
  }
328
357
 
329
358
  const previous = existsSync(MANIFEST) ? readJson(MANIFEST) : {};
330
359
  const { added, changed } = localChanges(existsSync(ASSET_DIR) ? scanImages() : [], previous);
331
360
  const due = Object.values(previous.retired ?? {})
332
- .filter((since) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
361
+ .filter((since) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
333
362
  if (added.length + changed.length + due > 0) {
334
363
  fail(`${ added.length + changed.length } image(s) to upload and ${ due } retired file(s) to delete — `
335
- + 'set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
364
+ + 'set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
336
365
  }
337
366
  console.log('Nothing to upload.');
338
367
  }
@@ -343,6 +372,7 @@ async function sync() {
343
372
  async function pullImages() {
344
373
  if (!existsSync(MANIFEST)) {
345
374
  console.log('No manifest yet — nothing to download.');
375
+
346
376
  return;
347
377
  }
348
378
 
@@ -356,6 +386,14 @@ async function pullImages() {
356
386
  if (!target.startsWith(ASSET_DIR + sep)) {
357
387
  fail(`refusing to write outside src/asset/: ${ key }`);
358
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
+ }
359
397
 
360
398
  // the fingerprint in the stored name (hero.0100d254.webp) is the content hash we expect
361
399
  const expected = decodeURI(url).match(/\.([0-9a-f]{8})\.[^./]+$/)?.[1];
@@ -391,11 +429,11 @@ async function pullImages() {
391
429
  }
392
430
 
393
431
  console.log(`${ downloaded } downloaded, ${ upToDate } already up to date, ${ kept } kept (local changes)`
394
- + (dryRun ? ' — dry run, nothing written' : ''));
432
+ + (dryRun ? ' — dry run, nothing written' : ''));
395
433
  }
396
434
 
397
435
  // Bunny API errors (wrong key, wrong zone or region) print as one line rather than a stack trace
398
436
  const run = command === 'u' ? () => upload(requireStorageKey())
399
- : command === 's' ? sync
400
- : async () => status();
437
+ : command === 's' ? sync
438
+ : async () => status();
401
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.2.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
  }