@orthacms/media-provider-s3 0.3.0 → 0.4.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/lib/fake-s3-client.d.ts +50 -0
- package/dist/lib/fake-s3-client.d.ts.map +1 -0
- package/dist/lib/fake-s3-client.js +113 -0
- package/dist/lib/s3-storage-provider.d.ts +63 -7
- package/dist/lib/s3-storage-provider.d.ts.map +1 -1
- package/dist/lib/s3-storage-provider.js +161 -24
- package/package.json +5 -2
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
/** One object, as the fake holds it. */
|
|
3
|
+
interface FakeObject {
|
|
4
|
+
body: Buffer;
|
|
5
|
+
contentType?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A stand-in `S3Client` that answers the four commands this adapter sends.
|
|
9
|
+
*
|
|
10
|
+
* **What it is for and what it is not.** It exercises *our* code — key shapes,
|
|
11
|
+
* the metering that produces size and checksum, the error mapping, the abort
|
|
12
|
+
* path — offline and in milliseconds. It is not a claim that the adapter works
|
|
13
|
+
* against S3: a fake agrees with whatever the code does, which is exactly the
|
|
14
|
+
* failure mode MinIO and a real bucket exist to catch. Both are named in
|
|
15
|
+
* `AGENTS.md` as required before this provider is trusted with a deployment.
|
|
16
|
+
*
|
|
17
|
+
* Exported from `src/` rather than hidden in a spec so the eventual MinIO suite
|
|
18
|
+
* can be written as the same cases against a different client.
|
|
19
|
+
*/
|
|
20
|
+
export declare class FakeS3Client {
|
|
21
|
+
private readonly buckets;
|
|
22
|
+
readonly objects: Map<string, FakeObject>;
|
|
23
|
+
/** Every command class name this client was asked to send, in order. */
|
|
24
|
+
readonly sent: string[];
|
|
25
|
+
/** Buckets that exist. `HeadBucket` fails for anything else. */
|
|
26
|
+
constructor(buckets?: Set<string>);
|
|
27
|
+
/** The provider only ever calls `send`, so that is all this implements. */
|
|
28
|
+
send(command: {
|
|
29
|
+
constructor: {
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
input: Record<string, unknown>;
|
|
33
|
+
}): Promise<unknown>;
|
|
34
|
+
/** Keys currently held, sorted — the harness for the contract suite. */
|
|
35
|
+
keys(): string[];
|
|
36
|
+
/**
|
|
37
|
+
* A **real** `S3Client` with only its `send` replaced.
|
|
38
|
+
*
|
|
39
|
+
* Hand-rolling the whole client does not work and should not be attempted:
|
|
40
|
+
* `lib-storage` reads the client's own `config` — `requestHandler` for
|
|
41
|
+
* progress events, `endpointProvider` to build the uploaded object's
|
|
42
|
+
* `Location` — so a plain object fails inside the SDK rather than in our
|
|
43
|
+
* code. Keeping the real client means the command construction, the
|
|
44
|
+
* endpoint resolution and the middleware stack are all genuine, and only
|
|
45
|
+
* the network hop is stubbed.
|
|
46
|
+
*/
|
|
47
|
+
asClient(): S3Client;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=fake-s3-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-s3-client.d.ts","sourceRoot":"","sources":["../../src/lib/fake-s3-client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAE9C,wCAAwC;AACxC,UAAU,UAAU;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAqBD;;;;;;;;;;;;GAYG;AACH,qBAAa,YAAY;IAKT,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,QAAQ,CAAC,OAAO,0BAAiC;IACjD,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAM;IAC7B,gEAAgE;gBACnC,OAAO,GAAE,GAAG,CAAC,MAAM,CAAuB;IAEvE,2EAA2E;IACrE,IAAI,CAAC,OAAO,EAAE;QAChB,WAAW,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;QAC9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,GAAG,OAAO,CAAC,OAAO,CAAC;IAuCpB,wEAAwE;IACxE,IAAI,IAAI,MAAM,EAAE;IAIhB;;;;;;;;;;OAUG;IACH,QAAQ,IAAI,QAAQ;CAcvB"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FakeS3Client = void 0;
|
|
4
|
+
const node_stream_1 = require("node:stream");
|
|
5
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
6
|
+
/** The error shape this family of services uses for a missing key. */
|
|
7
|
+
function notFound(name) {
|
|
8
|
+
return Object.assign(new Error(name), {
|
|
9
|
+
name,
|
|
10
|
+
$metadata: { httpStatusCode: 404 }
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Drains whatever `Body` the SDK was handed into one buffer. */
|
|
14
|
+
async function collect(body) {
|
|
15
|
+
if (Buffer.isBuffer(body))
|
|
16
|
+
return body;
|
|
17
|
+
if (typeof body === 'string')
|
|
18
|
+
return Buffer.from(body);
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of body) {
|
|
21
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A stand-in `S3Client` that answers the four commands this adapter sends.
|
|
27
|
+
*
|
|
28
|
+
* **What it is for and what it is not.** It exercises *our* code — key shapes,
|
|
29
|
+
* the metering that produces size and checksum, the error mapping, the abort
|
|
30
|
+
* path — offline and in milliseconds. It is not a claim that the adapter works
|
|
31
|
+
* against S3: a fake agrees with whatever the code does, which is exactly the
|
|
32
|
+
* failure mode MinIO and a real bucket exist to catch. Both are named in
|
|
33
|
+
* `AGENTS.md` as required before this provider is trusted with a deployment.
|
|
34
|
+
*
|
|
35
|
+
* Exported from `src/` rather than hidden in a spec so the eventual MinIO suite
|
|
36
|
+
* can be written as the same cases against a different client.
|
|
37
|
+
*/
|
|
38
|
+
class FakeS3Client {
|
|
39
|
+
buckets;
|
|
40
|
+
objects = new Map();
|
|
41
|
+
/** Every command class name this client was asked to send, in order. */
|
|
42
|
+
sent = [];
|
|
43
|
+
/** Buckets that exist. `HeadBucket` fails for anything else. */
|
|
44
|
+
constructor(buckets = new Set(['bucket'])) {
|
|
45
|
+
this.buckets = buckets;
|
|
46
|
+
}
|
|
47
|
+
/** The provider only ever calls `send`, so that is all this implements. */
|
|
48
|
+
async send(command) {
|
|
49
|
+
const kind = command.constructor.name;
|
|
50
|
+
this.sent.push(kind);
|
|
51
|
+
const key = command.input['Key'];
|
|
52
|
+
switch (kind) {
|
|
53
|
+
case 'PutObjectCommand': {
|
|
54
|
+
this.objects.set(key, {
|
|
55
|
+
body: await collect(command.input['Body']),
|
|
56
|
+
contentType: command.input['ContentType']
|
|
57
|
+
});
|
|
58
|
+
return { ETag: '"fake"' };
|
|
59
|
+
}
|
|
60
|
+
case 'GetObjectCommand': {
|
|
61
|
+
const object = this.objects.get(key);
|
|
62
|
+
if (!object)
|
|
63
|
+
throw notFound('NoSuchKey');
|
|
64
|
+
return {
|
|
65
|
+
Body: node_stream_1.Readable.from(object.body),
|
|
66
|
+
ContentType: object.contentType
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
case 'DeleteObjectCommand': {
|
|
70
|
+
// S3 answers 204 whether or not the key was there.
|
|
71
|
+
this.objects.delete(key);
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
case 'HeadBucketCommand': {
|
|
75
|
+
if (!this.buckets.has(command.input['Bucket'])) {
|
|
76
|
+
throw notFound('NotFound');
|
|
77
|
+
}
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`FakeS3Client cannot answer ${kind}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Keys currently held, sorted — the harness for the contract suite. */
|
|
85
|
+
keys() {
|
|
86
|
+
return [...this.objects.keys()].sort();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A **real** `S3Client` with only its `send` replaced.
|
|
90
|
+
*
|
|
91
|
+
* Hand-rolling the whole client does not work and should not be attempted:
|
|
92
|
+
* `lib-storage` reads the client's own `config` — `requestHandler` for
|
|
93
|
+
* progress events, `endpointProvider` to build the uploaded object's
|
|
94
|
+
* `Location` — so a plain object fails inside the SDK rather than in our
|
|
95
|
+
* code. Keeping the real client means the command construction, the
|
|
96
|
+
* endpoint resolution and the middleware stack are all genuine, and only
|
|
97
|
+
* the network hop is stubbed.
|
|
98
|
+
*/
|
|
99
|
+
asClient() {
|
|
100
|
+
const client = new client_s3_1.S3Client({
|
|
101
|
+
region: 'auto',
|
|
102
|
+
endpoint: 'https://fake.s3.test',
|
|
103
|
+
forcePathStyle: true,
|
|
104
|
+
credentials: {
|
|
105
|
+
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
106
|
+
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
client.send = ((command) => this.send(command));
|
|
110
|
+
return client;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
exports.FakeS3Client = FakeS3Client;
|
|
@@ -1,15 +1,71 @@
|
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
1
2
|
import type { StorageProvider } from '@orthacms/media-server';
|
|
2
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Settings for an **S3-compatible** object store.
|
|
5
|
+
*
|
|
6
|
+
* Written endpoint-first rather than AWS-first on purpose: `endpoint` plus
|
|
7
|
+
* `forcePathStyle` is the whole difference between AWS S3, Cloudflare R2,
|
|
8
|
+
* MinIO, DigitalOcean Spaces, Backblaze B2, Wasabi, Scaleway, Hetzner, Supabase
|
|
9
|
+
* Storage and Tigris. One adapter, ten services — which is why this is one
|
|
10
|
+
* package and not ten.
|
|
11
|
+
*/
|
|
3
12
|
export interface S3StorageConfig {
|
|
13
|
+
/** Bucket every object lands in. */
|
|
4
14
|
bucket: string;
|
|
5
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Region. Required by AWS; most S3-compatible services ignore it but the
|
|
17
|
+
* signer still needs a value, so it defaults to `auto` (what R2 expects).
|
|
18
|
+
*/
|
|
19
|
+
region?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Full endpoint URL for a non-AWS service — e.g.
|
|
22
|
+
* `https://<account>.r2.cloudflarestorage.com`, or `http://localhost:9000`
|
|
23
|
+
* for MinIO. Omit for AWS S3 itself.
|
|
24
|
+
*/
|
|
25
|
+
endpoint?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Address objects as `<endpoint>/<bucket>/<key>` rather than
|
|
28
|
+
* `<bucket>.<endpoint>/<key>`. Required by MinIO and most self-hosted
|
|
29
|
+
* gateways; harmless for R2.
|
|
30
|
+
*/
|
|
31
|
+
forcePathStyle?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Explicit credentials. **Omit them** on a deployment with an instance
|
|
34
|
+
* role, IRSA or any other ambient credential source — the SDK's own
|
|
35
|
+
* provider chain is what should resolve those, and passing blanks here
|
|
36
|
+
* would shadow it with credentials that cannot sign.
|
|
37
|
+
*/
|
|
38
|
+
credentials?: {
|
|
39
|
+
accessKeyId: string;
|
|
40
|
+
secretAccessKey: string;
|
|
41
|
+
sessionToken?: string;
|
|
42
|
+
};
|
|
43
|
+
/** Prefix every key with this, e.g. to share a bucket between environments. */
|
|
44
|
+
keyPrefix?: string;
|
|
45
|
+
/**
|
|
46
|
+
* An already-built client, for a deployment that needs custom middleware,
|
|
47
|
+
* a proxy agent or a retry strategy — and for tests, which drive this
|
|
48
|
+
* adapter against a stub rather than a network.
|
|
49
|
+
*
|
|
50
|
+
* When present, every other connection setting here is ignored: the client
|
|
51
|
+
* carries them.
|
|
52
|
+
*/
|
|
53
|
+
client?: S3Client;
|
|
6
54
|
}
|
|
7
55
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
56
|
+
* The S3-compatible {@link StorageProvider}.
|
|
57
|
+
*
|
|
58
|
+
* Three things here are load-bearing and easy to lose in a refactor:
|
|
59
|
+
*
|
|
60
|
+
* 1. **`put` is all-or-nothing.** A multipart upload that fails leaves parts
|
|
61
|
+
* billed and invisible, and the key never reached a caller — so nothing can
|
|
62
|
+
* reclaim them. The `catch` aborts the upload, which is what removes them.
|
|
63
|
+
* 2. **size and checksum are measured here, not taken from the response.** The
|
|
64
|
+
* core persists both and the reclaim path trusts them; `ETag` is not a
|
|
65
|
+
* sha256 and is not even an MD5 for a multipart object.
|
|
66
|
+
* 3. **`get` rejects with `ObjectNotFoundError`**, mapped from the several
|
|
67
|
+
* shapes this family of services uses. A raw `NoSuchKey` escaping makes a
|
|
68
|
+
* missing blob a 500 where the route means to answer 404.
|
|
13
69
|
*/
|
|
14
70
|
export declare function createS3StorageProvider(config: S3StorageConfig): StorageProvider;
|
|
15
71
|
//# sourceMappingURL=s3-storage-provider.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"s3-storage-provider.d.ts","sourceRoot":"","sources":["../../src/lib/s3-storage-provider.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"s3-storage-provider.d.ts","sourceRoot":"","sources":["../../src/lib/s3-storage-provider.ts"],"names":[],"mappings":"AAEA,OAAO,EAIH,QAAQ,EAEX,MAAM,oBAAoB,CAAC;AAI5B,OAAO,KAAK,EAGR,eAAe,EAElB,MAAM,wBAAwB,CAAC;AAEhC;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC5B,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,WAAW,CAAC,EAAE;QACV,WAAW,EAAE,MAAM,CAAC;QACpB,eAAe,EAAE,MAAM,CAAC;QACxB,YAAY,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;IACF,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,QAAQ,CAAC;CACrB;AAiCD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACnC,MAAM,EAAE,eAAe,GACxB,eAAe,CAsJjB"}
|
|
@@ -1,37 +1,174 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createS3StorageProvider = createS3StorageProvider;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const node_stream_1 = require("node:stream");
|
|
6
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
7
|
+
const lib_storage_1 = require("@aws-sdk/lib-storage");
|
|
8
|
+
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
|
|
9
|
+
const media_server_1 = require("@orthacms/media-server");
|
|
10
|
+
/** How many bytes one multipart part carries. The SDK's own minimum is 5 MB. */
|
|
11
|
+
const PART_SIZE = 5 * 1024 * 1024;
|
|
12
|
+
/** How many parts are in flight at once for one upload. */
|
|
13
|
+
const QUEUE_SIZE = 4;
|
|
14
|
+
/** Reduces a file name to one safe key segment. Mirrors the other providers. */
|
|
15
|
+
function sanitize(fileName) {
|
|
16
|
+
const cleaned = fileName.replace(/[^A-Za-z0-9_.-]+/g, '_');
|
|
17
|
+
// `.` and `..` survive the character class and are not names. S3 keys are
|
|
18
|
+
// opaque strings so neither would traverse anything — but a key that
|
|
19
|
+
// round-trips differently between two providers is a key the core cannot
|
|
20
|
+
// treat as opaque, and `..` in a key breaks most bucket browsers.
|
|
21
|
+
return cleaned === '.' || cleaned === '..' ? `_${cleaned}` : cleaned;
|
|
22
|
+
}
|
|
23
|
+
/** True for the several ways this family of services says "no such object". */
|
|
24
|
+
function isMissing(error) {
|
|
25
|
+
const candidate = error;
|
|
26
|
+
return (candidate?.name === 'NoSuchKey' ||
|
|
27
|
+
candidate?.name === 'NotFound' ||
|
|
28
|
+
candidate?.Code === 'NoSuchKey' ||
|
|
29
|
+
candidate?.$metadata?.httpStatusCode === 404);
|
|
10
30
|
}
|
|
11
31
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
32
|
+
* The S3-compatible {@link StorageProvider}.
|
|
33
|
+
*
|
|
34
|
+
* Three things here are load-bearing and easy to lose in a refactor:
|
|
35
|
+
*
|
|
36
|
+
* 1. **`put` is all-or-nothing.** A multipart upload that fails leaves parts
|
|
37
|
+
* billed and invisible, and the key never reached a caller — so nothing can
|
|
38
|
+
* reclaim them. The `catch` aborts the upload, which is what removes them.
|
|
39
|
+
* 2. **size and checksum are measured here, not taken from the response.** The
|
|
40
|
+
* core persists both and the reclaim path trusts them; `ETag` is not a
|
|
41
|
+
* sha256 and is not even an MD5 for a multipart object.
|
|
42
|
+
* 3. **`get` rejects with `ObjectNotFoundError`**, mapped from the several
|
|
43
|
+
* shapes this family of services uses. A raw `NoSuchKey` escaping makes a
|
|
44
|
+
* missing blob a 500 where the route means to answer 404.
|
|
17
45
|
*/
|
|
18
|
-
function createS3StorageProvider(
|
|
19
|
-
|
|
20
|
-
config
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
function createS3StorageProvider(config) {
|
|
47
|
+
if (!config.bucket?.trim()) {
|
|
48
|
+
throw new Error('createS3StorageProvider requires a bucket. Set it in the host config, ' +
|
|
49
|
+
'e.g. `storage: { bucket: process.env.MEDIA_S3_BUCKET }`.');
|
|
50
|
+
}
|
|
51
|
+
const clientConfig = {
|
|
52
|
+
region: config.region ?? 'auto',
|
|
53
|
+
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
54
|
+
...(config.forcePathStyle ? { forcePathStyle: true } : {}),
|
|
55
|
+
// Absent means "use the SDK's provider chain" — an instance role, IRSA,
|
|
56
|
+
// a shared config file. Passing an object with blank strings instead
|
|
57
|
+
// would shadow all of that with credentials that cannot sign.
|
|
58
|
+
...(config.credentials ? { credentials: config.credentials } : {})
|
|
59
|
+
};
|
|
60
|
+
const client = config.client ?? new client_s3_1.S3Client(clientConfig);
|
|
61
|
+
const bucket = config.bucket;
|
|
62
|
+
const prefix = config.keyPrefix?.replace(/^\/+|\/+$/g, '');
|
|
63
|
+
const keyFor = (object) => [
|
|
64
|
+
prefix,
|
|
65
|
+
object.workspaceId,
|
|
66
|
+
object.assetId,
|
|
67
|
+
object.isVariant ? 'variants' : undefined,
|
|
68
|
+
sanitize(object.fileName)
|
|
69
|
+
]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join('/');
|
|
23
72
|
return {
|
|
24
|
-
|
|
25
|
-
|
|
73
|
+
id: 's3',
|
|
74
|
+
capabilities: {
|
|
75
|
+
directUrl: true,
|
|
76
|
+
contentTypeMetadata: true,
|
|
77
|
+
streamingPut: true
|
|
78
|
+
},
|
|
79
|
+
async put(object) {
|
|
80
|
+
const storageKey = keyFor(object);
|
|
81
|
+
// Metered on the way through rather than buffered: an upload is
|
|
82
|
+
// bounded by `maxUploadBytes`, not by the heap, and `lib-storage`
|
|
83
|
+
// streams it in parts.
|
|
84
|
+
const hash = (0, node_crypto_1.createHash)('sha256');
|
|
85
|
+
let size = 0;
|
|
86
|
+
const meter = new node_stream_1.PassThrough();
|
|
87
|
+
meter.on('data', (chunk) => {
|
|
88
|
+
hash.update(chunk);
|
|
89
|
+
size += chunk.byteLength;
|
|
90
|
+
});
|
|
91
|
+
object.body.pipe(meter);
|
|
92
|
+
// A source that fails mid-stream must fail the upload, not stall
|
|
93
|
+
// it: `Upload` is waiting on a stream that will never end.
|
|
94
|
+
object.body.on('error', (error) => meter.destroy(error));
|
|
95
|
+
const upload = new lib_storage_1.Upload({
|
|
96
|
+
client,
|
|
97
|
+
partSize: PART_SIZE,
|
|
98
|
+
queueSize: QUEUE_SIZE,
|
|
99
|
+
params: {
|
|
100
|
+
Bucket: bucket,
|
|
101
|
+
Key: storageKey,
|
|
102
|
+
Body: meter,
|
|
103
|
+
// The object metadata is what makes `contentTypeMetadata`
|
|
104
|
+
// true here and false for a filesystem. It is the
|
|
105
|
+
// uploader's own claim, so the download route still
|
|
106
|
+
// hardens the response; this only keeps the object
|
|
107
|
+
// self-describing for anything reading the bucket directly.
|
|
108
|
+
ContentType: object.contentType
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
await upload.done();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Without this the failed multipart upload's parts sit in the
|
|
116
|
+
// bucket, billed, invisible to `ListObjects`, and unreachable:
|
|
117
|
+
// the key was never returned, so nothing can reclaim them.
|
|
118
|
+
await upload.abort().catch(() => undefined);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
return { storageKey, size, checksum: hash.digest('hex') };
|
|
122
|
+
},
|
|
123
|
+
async get(storageKey) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await client.send(new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: storageKey }));
|
|
126
|
+
if (!response.Body) {
|
|
127
|
+
throw new media_server_1.ObjectNotFoundError(storageKey);
|
|
128
|
+
}
|
|
129
|
+
return response.Body;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (isMissing(error)) {
|
|
133
|
+
throw new media_server_1.ObjectNotFoundError(storageKey, error);
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
26
137
|
},
|
|
27
|
-
|
|
28
|
-
|
|
138
|
+
async remove(storageKey) {
|
|
139
|
+
try {
|
|
140
|
+
await client.send(new client_s3_1.DeleteObjectCommand({ Bucket: bucket, Key: storageKey }));
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// S3 answers 204 for a key that was never there, so this is
|
|
144
|
+
// already idempotent — but a gateway that answers 404 instead
|
|
145
|
+
// must not turn a best-effort, post-commit reclaim into an
|
|
146
|
+
// error nobody can act on.
|
|
147
|
+
if (!isMissing(error))
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
29
150
|
},
|
|
30
|
-
|
|
31
|
-
|
|
151
|
+
async directUrl(storageKey, options) {
|
|
152
|
+
// The disposition and content type are **pinned on the URL**, not
|
|
153
|
+
// left to the object's metadata. A redirect discards the app's own
|
|
154
|
+
// `Content-Disposition`, `nosniff` and CSP, and `mime_type` is the
|
|
155
|
+
// uploader's unverified claim — so an uploaded `.html` served
|
|
156
|
+
// inline from the bucket would be stored XSS. These two response
|
|
157
|
+
// overrides are the whole reason this provider may declare
|
|
158
|
+
// `capabilities.directUrl`.
|
|
159
|
+
const fileName = options.fileName.replace(/"/g, '');
|
|
160
|
+
return (0, s3_request_presigner_1.getSignedUrl)(client, new client_s3_1.GetObjectCommand({
|
|
161
|
+
Bucket: bucket,
|
|
162
|
+
Key: storageKey,
|
|
163
|
+
ResponseContentDisposition: `${options.disposition}; filename="${fileName}"`,
|
|
164
|
+
ResponseContentType: options.contentType
|
|
165
|
+
}), { expiresIn: options.expiresInSeconds });
|
|
32
166
|
},
|
|
33
|
-
|
|
34
|
-
|
|
167
|
+
async verify() {
|
|
168
|
+
// One HEAD at boot. A wrong bucket, a dead endpoint or an expired
|
|
169
|
+
// key fails the start instead of the first upload, hours later,
|
|
170
|
+
// with nothing in the message naming the cause.
|
|
171
|
+
await client.send(new client_s3_1.HeadBucketCommand({ Bucket: bucket }));
|
|
35
172
|
}
|
|
36
173
|
};
|
|
37
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orthacms/media-provider-s3",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "@orthacms/media-provider-s3 — part of Ortha CMS.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/media/provider-s3",
|
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
"dist"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@
|
|
28
|
+
"@aws-sdk/client-s3": "^3.716.0",
|
|
29
|
+
"@aws-sdk/lib-storage": "^3.716.0",
|
|
30
|
+
"@aws-sdk/s3-request-presigner": "^3.716.0",
|
|
31
|
+
"@orthacms/media-server": "^0.4.0",
|
|
29
32
|
"tslib": "^2.3.0"
|
|
30
33
|
},
|
|
31
34
|
"publishConfig": {
|