@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>/<
|
|
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
|
|
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>/<
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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,
|
|
36
|
-
const uid = uuidv4();
|
|
57
|
+
function buildObjectKey(pathPrefix, scope, filename, suffix) {
|
|
37
58
|
const safe = sanitiseFilename(filename);
|
|
38
|
-
const
|
|
39
|
-
const
|
|
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>/<
|
|
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.
|
|
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.
|
|
45
|
+
"@byline/core": "4.2.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@biomejs/biome": "2.5.2",
|