@byline/storage-s3 4.0.0 → 4.2.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.
@@ -73,7 +73,7 @@ export interface S3StorageConfig {
73
73
  * uploads compute their key from the original's `storagePath`, which
74
74
  * already carries the prefix.
75
75
  *
76
- * @example `'byline'` → keys stored as `byline/<collection>/<uuid>-<filename>`
76
+ * @example `'byline'` → keys stored as `byline/<collection>/<filename>-<suffix>.<ext>`
77
77
  */
78
78
  pathPrefix?: string;
79
79
  /**
@@ -89,7 +89,7 @@ export interface S3StorageConfig {
89
89
  /**
90
90
  * Default `Cache-Control` header written to every uploaded object's
91
91
  * metadata, used by S3/CDNs when serving the file. Long-lived
92
- * immutable URLs are common for content with UUID-prefixed keys.
92
+ * immutable URLs are common for content with collision-proof keys.
93
93
  *
94
94
  * @example `'public, max-age=31536000, immutable'`
95
95
  */
@@ -122,7 +122,7 @@ export interface S3StorageConfig {
122
122
  * `forcePathStyle: true` for non-AWS providers.
123
123
  *
124
124
  * Uploaded files are stored at:
125
- * `[pathPrefix/]<collection>/<uuid>-<filename>`
125
+ * `[pathPrefix/]<location|collection>/<filename>-<suffix>.<ext>`
126
126
  *
127
127
  * Image variants (thumbnail / card / etc.) are written as siblings of the
128
128
  * original under the same prefix, e.g.:
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { randomBytes } from 'node:crypto';
8
9
  import path from 'node:path';
9
10
  import { Readable } from 'node:stream';
10
11
  import { CopyObjectCommand, DeleteObjectCommand, HeadObjectCommand, S3Client, } from '@aws-sdk/client-s3';
@@ -23,20 +24,41 @@ function sanitiseFilename(filename) {
23
24
  .replace(/^-|-$/g, '');
24
25
  return `${safe || 'file'}${ext.toLowerCase()}`;
25
26
  }
27
+ const SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
26
28
  /**
27
- * Build a namespaced object key, e.g.:
28
- * `[pathPrefix/]media/a1b2c3d4-e5f6-...-photo.jpg`
29
+ * Short random base36 suffix appended to stored basenames. Six characters
30
+ * (36^6 ≈ 2.2 billion) keep filenames human-friendly while making
31
+ * collisions among same-named files in one scope vanishingly rare — and
32
+ * the upload path verifies availability with `exists()` (one HeadObject)
33
+ * and retries, so the residual risk is handled rather than merely
34
+ * improbable.
35
+ */
36
+ function randomSuffix(length = 6) {
37
+ let out = '';
38
+ for (const byte of randomBytes(length)) {
39
+ out += SUFFIX_ALPHABET.charAt(byte % SUFFIX_ALPHABET.length);
40
+ }
41
+ return out;
42
+ }
43
+ /**
44
+ * Compose a candidate object key, e.g.:
45
+ * `[pathPrefix/]events/meeting-agenda-4fa35g.pdf`
29
46
  *
30
- * The UUIDv4 prefix is sufficient to prevent filename collisions without
31
- * year/month directory nesting, and it simplifies variant path derivation
32
- * and cleanup on deletion. The high-entropy UUID prefix also gives S3
33
- * enough scatter to auto-scale per prefix without a hot-partition concern.
47
+ * `scope` is the field's `upload.location` when declared (may carry nested
48
+ * segments, e.g. `news/attachments`), else the collection path. The
49
+ * filename leads (arriving pre-slugified from `uploadField`'s configurable
50
+ * filename slugifier; `sanitiseFilename` re-applies the default rules as a
51
+ * safety net for direct callers) and the entropy rides as a short suffix
52
+ * before the extension — friendly to download as, browse in the console,
53
+ * and log. At CMS scales the reduced key-prefix scatter is irrelevant to
54
+ * S3's per-prefix request limits (thousands of requests/second — orders of
55
+ * magnitude above editorial traffic).
34
56
  */
35
- function buildObjectKey(pathPrefix, collection, filename) {
36
- const uid = uuidv4();
57
+ function buildObjectKey(pathPrefix, scope, filename, suffix) {
37
58
  const safe = sanitiseFilename(filename);
38
- const scope = collection ?? 'uploads';
39
- const key = `${scope}/${uid}-${safe}`;
59
+ const ext = path.extname(safe);
60
+ const base = path.basename(safe, ext);
61
+ const key = `${scope ?? 'uploads'}/${base}-${suffix}${ext}`;
40
62
  return pathPrefix ? `${pathPrefix}/${key}` : key;
41
63
  }
42
64
  function toReadable(stream) {
@@ -97,8 +119,7 @@ class S3StorageProvider {
97
119
  `https://${config.bucket}.s3.${config.region}.amazonaws.com`;
98
120
  }
99
121
  async upload(stream, options) {
100
- const objectKey = options.targetStoragePath ??
101
- buildObjectKey(this.pathPrefix, options.collection, options.filename);
122
+ const objectKey = options.targetStoragePath ?? (await this.availableObjectKey(options));
102
123
  const userMetadata = resolveMetadata(this.metadata, options);
103
124
  const upload = new Upload({
104
125
  client: this.client,
@@ -154,6 +175,22 @@ class S3StorageProvider {
154
175
  storageUrl: this.getUrl(toPath),
155
176
  };
156
177
  }
178
+ /**
179
+ * Pick an unclaimed object key: compose the friendly key, verify it is
180
+ * free (one HeadObject), and retry with a fresh suffix on collision.
181
+ * After three straight collisions (pathological — a same-named file
182
+ * avalanche) fall back to a full-entropy UUID suffix, which cannot
183
+ * realistically collide.
184
+ */
185
+ async availableObjectKey(options) {
186
+ const scope = options.location ?? options.collection;
187
+ for (let attempt = 0; attempt < 3; attempt++) {
188
+ const candidate = buildObjectKey(this.pathPrefix, scope, options.filename, randomSuffix());
189
+ if (!(await this.exists(candidate)))
190
+ return candidate;
191
+ }
192
+ return buildObjectKey(this.pathPrefix, scope, options.filename, uuidv4());
193
+ }
157
194
  async exists(storagePath) {
158
195
  try {
159
196
  await this.client.send(new HeadObjectCommand({
@@ -186,7 +223,7 @@ class S3StorageProvider {
186
223
  * `forcePathStyle: true` for non-AWS providers.
187
224
  *
188
225
  * Uploaded files are stored at:
189
- * `[pathPrefix/]<collection>/<uuid>-<filename>`
226
+ * `[pathPrefix/]<location|collection>/<filename>-<suffix>.<ext>`
190
227
  *
191
228
  * Image variants (thumbnail / card / etc.) are written as siblings of the
192
229
  * original under the same prefix, e.g.:
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/storage-s3",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.0.0",
5
+ "version": "4.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -42,7 +42,7 @@
42
42
  "@aws-sdk/client-s3": "^3.1081.0",
43
43
  "@aws-sdk/lib-storage": "^3.1081.0",
44
44
  "uuid": "^14.0.1",
45
- "@byline/core": "4.0.0"
45
+ "@byline/core": "4.2.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@biomejs/biome": "2.5.2",