@headless-lms/adapter-storage-s3 0.2.2 → 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 +64 -12
- package/dist/index.d.ts +13 -3
- package/dist/index.js +30 -9
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ AWS S3 implementation of the `ObjectStorage` port from `@headless-lms/core/types
|
|
|
4
4
|
The bucket must already exist. Everything in it is private except the `public/`
|
|
5
5
|
prefix, which holds the images the API serves publicly (profile photos and the
|
|
6
6
|
thumbnails and covers of published items); private files are reached through
|
|
7
|
-
|
|
7
|
+
signed URLs, presigned on S3 or, with `cloudFront` set, signed for a CloudFront
|
|
8
|
+
distribution in front of the bucket. Also works against S3-compatible stores via `endpoint` + `forcePathStyle`.
|
|
8
9
|
|
|
9
10
|
The adapter reads no environment itself — the installation's `config.ts` parses
|
|
10
11
|
env into an `S3StorageConfig` and injects the constructed adapter:
|
|
@@ -70,6 +71,55 @@ the bucket once (console, CLI, or IaC); everything the app does is object-level.
|
|
|
70
71
|
4. Set `STORAGE_REGION`, `STORAGE_BUCKET`, and optionally the keys. Leave
|
|
71
72
|
`STORAGE_ENDPOINT` and `STORAGE_FORCE_PATH_STYLE` unset for real AWS.
|
|
72
73
|
|
|
74
|
+
## Serving through CloudFront
|
|
75
|
+
|
|
76
|
+
Without `cloudFront`, a private file's link is a presigned S3 URL. With it, the
|
|
77
|
+
link is a CloudFront signed URL on your distribution, so files are served and
|
|
78
|
+
cached at the edge while the bucket stays private. The API signs each link
|
|
79
|
+
locally when it returns the file; nothing calls AWS per file.
|
|
80
|
+
|
|
81
|
+
1. Create a distribution with the bucket as its origin and origin access
|
|
82
|
+
control, and let only that distribution read the bucket:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"Version": "2012-10-17",
|
|
87
|
+
"Statement": [
|
|
88
|
+
{
|
|
89
|
+
"Effect": "Allow",
|
|
90
|
+
"Principal": { "Service": "cloudfront.amazonaws.com" },
|
|
91
|
+
"Action": "s3:GetObject",
|
|
92
|
+
"Resource": "arn:aws:s3:::your-bucket/*",
|
|
93
|
+
"Condition": {
|
|
94
|
+
"StringEquals": {
|
|
95
|
+
"AWS:SourceArn": "arn:aws:cloudfront::<account>:distribution/<distribution-id>"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
2. Create a public key and a key group holding it. Keep the private key for
|
|
104
|
+
the adapter.
|
|
105
|
+
3. Add a `public/*` behavior with no viewer restriction, for public images.
|
|
106
|
+
4. On the default behavior, restrict viewer access to the key group, and use a
|
|
107
|
+
cache policy whose cache key holds only the query strings
|
|
108
|
+
`response-content-disposition` and `response-cache-control`. Those two are
|
|
109
|
+
forwarded to S3, which answers with them; the signature parameters stay out
|
|
110
|
+
of the key, so one viewer's request fills the edge cache for every viewer,
|
|
111
|
+
and CloudFront still checks the signature on every request. Use the managed
|
|
112
|
+
`CORS-S3Origin` origin request policy so the bucket's CORS rule answers
|
|
113
|
+
browser fetches.
|
|
114
|
+
5. Pass `cloudFront: { url, keyPairId, privateKey }`, where `url` is the
|
|
115
|
+
distribution's origin (its domain or your alias). `publicBaseUrl` defaults to
|
|
116
|
+
the same origin.
|
|
117
|
+
|
|
118
|
+
Every private file is served with `Cache-Control: public, max-age=31536000,
|
|
119
|
+
immutable`: an asset's bytes never change under its key, and its signed link
|
|
120
|
+
stays the same through each window the API signs for (`DELIVERY_URL_EXPIRY`),
|
|
121
|
+
so browsers and the edge keep it.
|
|
122
|
+
|
|
73
123
|
## Path style
|
|
74
124
|
|
|
75
125
|
S3 has two URL formats: virtual-hosted style (`https://bucket.s3.amazonaws.com/key`,
|
|
@@ -80,14 +130,16 @@ the bucket name cannot be a subdomain — they need path style. Set
|
|
|
80
130
|
|
|
81
131
|
## Environment variables (reference installation)
|
|
82
132
|
|
|
83
|
-
| Variable
|
|
84
|
-
|
|
|
85
|
-
| `STORAGE_REGION`
|
|
86
|
-
| `STORAGE_BUCKET`
|
|
87
|
-
| `STORAGE_ACCESS_KEY`
|
|
88
|
-
| `STORAGE_SECRET_KEY`
|
|
89
|
-
| `STORAGE_ENDPOINT`
|
|
90
|
-
| `STORAGE_FORCE_PATH_STYLE`
|
|
91
|
-
| `STORAGE_UPLOAD_EXPIRY`
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
133
|
+
| Variable | Required | Maps to | Notes |
|
|
134
|
+
| -------------------------------- | -------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
135
|
+
| `STORAGE_REGION` | yes | `region` | |
|
|
136
|
+
| `STORAGE_BUCKET` | yes | `bucket` | Must exist; kept private. |
|
|
137
|
+
| `STORAGE_ACCESS_KEY` | no | `accessKeyId` | Omit for the default credential chain. |
|
|
138
|
+
| `STORAGE_SECRET_KEY` | no | `secretAccessKey` | Omit for the default credential chain. |
|
|
139
|
+
| `STORAGE_ENDPOINT` | no | `endpoint` | Full URL, for S3-compatible stores. |
|
|
140
|
+
| `STORAGE_FORCE_PATH_STYLE` | no | `forcePathStyle` | `"true"` / `"false"`. |
|
|
141
|
+
| `STORAGE_UPLOAD_EXPIRY` | no | `uploadExpirySeconds` | Default 300. Stray member uploads older than this are swept. |
|
|
142
|
+
| `STORAGE_PUBLIC_URL` | no | `publicBaseUrl` | Origin serving `public/*`, such as `https://cdn.example.com` or `https://your-bucket.s3.amazonaws.com`. Required unless the CloudFront url is set, which it defaults to. |
|
|
143
|
+
| `STORAGE_CLOUDFRONT_URL` | no | `cloudFront.url` | Origin of the distribution in front of the bucket. Set all three CloudFront variables or none. |
|
|
144
|
+
| `STORAGE_CLOUDFRONT_KEY_PAIR_ID` | no | `cloudFront.keyPairId` | Id of the public key in the distribution's trusted key group. |
|
|
145
|
+
| `STORAGE_CLOUDFRONT_PRIVATE_KEY` | no | `cloudFront.privateKey` | PEM private key of that public key. |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { Logger, ObjectStorage, PresignDownloadInput, PresignedUpload, StoredObjectInfo } from "@headless-lms/core/types";
|
|
2
2
|
//#region src/index.d.ts
|
|
3
|
+
interface CloudFrontSigningConfig {
|
|
4
|
+
/** Origin of the distribution in front of the bucket, e.g. https://media.example.com. */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Id of a public key in the distribution's trusted key group. */
|
|
7
|
+
keyPairId: string;
|
|
8
|
+
/** PEM private key of that public key. */
|
|
9
|
+
privateKey: string;
|
|
10
|
+
}
|
|
3
11
|
interface S3StorageConfig {
|
|
4
12
|
region: string;
|
|
5
13
|
bucket: string;
|
|
@@ -10,17 +18,19 @@ interface S3StorageConfig {
|
|
|
10
18
|
endpoint?: string;
|
|
11
19
|
/** Required by most S3-compatible stores when `endpoint` is set. */
|
|
12
20
|
forcePathStyle?: boolean;
|
|
21
|
+
/** Origin public files are served from. Defaults to the CloudFront url. */
|
|
13
22
|
publicBaseUrl?: string;
|
|
23
|
+
/** Sign private file links for CloudFront instead of presigning them on S3. */
|
|
24
|
+
cloudFront?: CloudFrontSigningConfig;
|
|
14
25
|
uploadExpirySeconds: number;
|
|
15
|
-
downloadExpirySeconds: number;
|
|
16
26
|
}
|
|
17
27
|
declare class S3StorageAdapter implements ObjectStorage {
|
|
18
28
|
private readonly logger?;
|
|
19
29
|
private readonly client;
|
|
20
30
|
private readonly bucket;
|
|
21
31
|
private readonly publicBase;
|
|
32
|
+
private readonly cloudFront;
|
|
22
33
|
readonly uploadExpirySeconds: number;
|
|
23
|
-
private readonly downloadExpiry;
|
|
24
34
|
constructor(config: S3StorageConfig, logger?: Logger | undefined);
|
|
25
35
|
presignUpload(input: {
|
|
26
36
|
key: string;
|
|
@@ -42,5 +52,5 @@ declare class S3StorageAdapter implements ObjectStorage {
|
|
|
42
52
|
remove(key: string): Promise<void>;
|
|
43
53
|
}
|
|
44
54
|
//#endregion
|
|
45
|
-
export { S3StorageAdapter, S3StorageConfig };
|
|
55
|
+
export { CloudFrontSigningConfig, S3StorageAdapter, S3StorageConfig };
|
|
46
56
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
2
|
-
import { getSignedUrl } from "@aws-sdk/
|
|
2
|
+
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
|
|
3
|
+
import { getSignedUrl as getSignedUrl$1 } from "@aws-sdk/s3-request-presigner";
|
|
3
4
|
import { PUBLIC_CACHE_CONTROL } from "@headless-lms/core/types";
|
|
4
5
|
//#region src/index.ts
|
|
5
6
|
var S3StorageAdapter = class {
|
|
@@ -7,8 +8,8 @@ var S3StorageAdapter = class {
|
|
|
7
8
|
client;
|
|
8
9
|
bucket;
|
|
9
10
|
publicBase;
|
|
11
|
+
cloudFront;
|
|
10
12
|
uploadExpirySeconds;
|
|
11
|
-
downloadExpiry;
|
|
12
13
|
constructor(config, logger) {
|
|
13
14
|
this.logger = logger;
|
|
14
15
|
this.client = new S3Client({
|
|
@@ -22,8 +23,12 @@ var S3StorageAdapter = class {
|
|
|
22
23
|
});
|
|
23
24
|
this.bucket = config.bucket;
|
|
24
25
|
this.uploadExpirySeconds = config.uploadExpirySeconds;
|
|
25
|
-
this.
|
|
26
|
-
|
|
26
|
+
this.cloudFront = config.cloudFront ? {
|
|
27
|
+
...config.cloudFront,
|
|
28
|
+
url: config.cloudFront.url.replace(/\/+$/, "")
|
|
29
|
+
} : null;
|
|
30
|
+
const publicBase = config.publicBaseUrl ?? config.cloudFront?.url;
|
|
31
|
+
this.publicBase = publicBase ? publicBase.replace(/\/+$/, "") : null;
|
|
27
32
|
}
|
|
28
33
|
async presignUpload(input) {
|
|
29
34
|
const expiresInSeconds = input.expiresInSeconds ?? this.uploadExpirySeconds;
|
|
@@ -33,7 +38,7 @@ var S3StorageAdapter = class {
|
|
|
33
38
|
...input.contentType ? { ContentType: input.contentType } : {}
|
|
34
39
|
});
|
|
35
40
|
return {
|
|
36
|
-
url: await getSignedUrl(this.client, command, { expiresIn: expiresInSeconds }),
|
|
41
|
+
url: await getSignedUrl$1(this.client, command, { expiresIn: expiresInSeconds }),
|
|
37
42
|
method: "PUT",
|
|
38
43
|
key: input.key,
|
|
39
44
|
expiresInSeconds,
|
|
@@ -41,13 +46,26 @@ var S3StorageAdapter = class {
|
|
|
41
46
|
};
|
|
42
47
|
}
|
|
43
48
|
async presignDownload(input) {
|
|
44
|
-
const
|
|
49
|
+
const disposition = input.downloadFilename ? `attachment; filename="${input.downloadFilename.replace(/"/g, "")}"` : null;
|
|
50
|
+
if (this.cloudFront) {
|
|
51
|
+
const query = [`response-cache-control=${encodeURIComponent(PUBLIC_CACHE_CONTROL)}`, ...disposition ? [`response-content-disposition=${encodeURIComponent(disposition)}`] : []].join("&");
|
|
52
|
+
return getSignedUrl({
|
|
53
|
+
url: `${this.cloudFront.url}/${encodeKey(input.key)}?${query}`,
|
|
54
|
+
keyPairId: this.cloudFront.keyPairId,
|
|
55
|
+
privateKey: this.cloudFront.privateKey,
|
|
56
|
+
dateLessThan: input.expiresAt
|
|
57
|
+
});
|
|
58
|
+
}
|
|
45
59
|
const command = new GetObjectCommand({
|
|
46
60
|
Bucket: this.bucket,
|
|
47
61
|
Key: input.key,
|
|
48
|
-
|
|
62
|
+
ResponseCacheControl: PUBLIC_CACHE_CONTROL,
|
|
63
|
+
...disposition ? { ResponseContentDisposition: disposition } : {}
|
|
64
|
+
});
|
|
65
|
+
return getSignedUrl$1(this.client, command, {
|
|
66
|
+
signingDate: input.signedAt,
|
|
67
|
+
expiresIn: Math.round((input.expiresAt.getTime() - input.signedAt.getTime()) / 1e3)
|
|
49
68
|
});
|
|
50
|
-
return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });
|
|
51
69
|
}
|
|
52
70
|
async stat(key) {
|
|
53
71
|
try {
|
|
@@ -110,7 +128,7 @@ var S3StorageAdapter = class {
|
|
|
110
128
|
}
|
|
111
129
|
publicUrl(key) {
|
|
112
130
|
if (!this.publicBase) throw new Error("S3 storage has no publicBaseUrl, so it cannot serve public files");
|
|
113
|
-
return `${this.publicBase}/${key
|
|
131
|
+
return `${this.publicBase}/${encodeKey(key)}`;
|
|
114
132
|
}
|
|
115
133
|
async remove(key) {
|
|
116
134
|
await this.client.send(new DeleteObjectCommand({
|
|
@@ -119,6 +137,9 @@ var S3StorageAdapter = class {
|
|
|
119
137
|
}));
|
|
120
138
|
}
|
|
121
139
|
};
|
|
140
|
+
function encodeKey(key) {
|
|
141
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
142
|
+
}
|
|
122
143
|
function isNotFound(err) {
|
|
123
144
|
const e = err;
|
|
124
145
|
return e?.name === "NotFound" || e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["// AWS S3 implementation of the ObjectStorage port. Private bucket except the\n// public/ prefix; private files are reached by short-lived presigned URLs. The\n// bucket must already exist.\nimport {\n CopyObjectCommand,\n DeleteObjectCommand,\n GetObjectCommand,\n HeadObjectCommand,\n ListObjectsV2Command,\n PutObjectCommand,\n S3Client,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport {\n PUBLIC_CACHE_CONTROL,\n type Logger,\n type ObjectStorage,\n type PresignDownloadInput,\n type PresignedUpload,\n type StoredObjectInfo,\n} from \"@headless-lms/core/types\";\n\nexport interface S3StorageConfig {\n region: string;\n bucket: string;\n /** Omit both keys to use the default AWS credential chain (IAM role, env, profile). */\n accessKeyId?: string;\n secretAccessKey?: string;\n /** Custom endpoint for S3-compatible stores. */\n endpoint?: string;\n /** Required by most S3-compatible stores when `endpoint` is set. */\n forcePathStyle?: boolean;\n publicBaseUrl?: string;\n uploadExpirySeconds: number;\n downloadExpirySeconds: number;\n}\n\nexport class S3StorageAdapter implements ObjectStorage {\n private readonly client: S3Client;\n private readonly bucket: string;\n private readonly publicBase: string | null;\n readonly uploadExpirySeconds: number;\n private readonly downloadExpiry: number;\n\n constructor(\n config: S3StorageConfig,\n private readonly logger?: Logger,\n ) {\n this.client = new S3Client({\n region: config.region,\n ...(config.endpoint ? { endpoint: config.endpoint } : {}),\n ...(config.forcePathStyle !== undefined ? { forcePathStyle: config.forcePathStyle } : {}),\n ...(config.accessKeyId && config.secretAccessKey\n ? {\n credentials: {\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n },\n }\n : {}),\n });\n this.bucket = config.bucket;\n this.uploadExpirySeconds = config.uploadExpirySeconds;\n this.downloadExpiry = config.downloadExpirySeconds;\n this.publicBase = config.publicBaseUrl ? config.publicBaseUrl.replace(/\\/+$/, \"\") : null;\n }\n\n async presignUpload(input: {\n key: string;\n contentType?: string;\n expiresInSeconds?: number;\n }): Promise<PresignedUpload> {\n const expiresInSeconds = input.expiresInSeconds ?? this.uploadExpirySeconds;\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: input.key,\n ...(input.contentType ? { ContentType: input.contentType } : {}),\n });\n const url = await getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });\n return {\n url,\n method: \"PUT\",\n key: input.key,\n expiresInSeconds,\n headers: input.contentType ? { \"Content-Type\": input.contentType } : {},\n };\n }\n\n async presignDownload(input: PresignDownloadInput): Promise<string> {\n const expiresInSeconds = input.expiresInSeconds ?? this.downloadExpiry;\n const command = new GetObjectCommand({\n Bucket: this.bucket,\n Key: input.key,\n ...(input.downloadFilename\n ? {\n ResponseContentDisposition: `attachment; filename=\"${input.downloadFilename.replace(/\"/g, \"\")}\"`,\n }\n : {}),\n });\n return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });\n }\n\n async stat(key: string): Promise<StoredObjectInfo | null> {\n try {\n const s = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));\n return {\n key,\n size: s.ContentLength ?? 0,\n ...(s.ContentType ? { contentType: s.ContentType } : {}),\n ...(s.LastModified ? { lastModified: s.LastModified.toISOString() } : {}),\n };\n } catch (err) {\n if (isNotFound(err)) {\n return null;\n }\n throw err;\n }\n }\n\n async list(prefix: string): Promise<StoredObjectInfo[]> {\n const objects: StoredObjectInfo[] = [];\n let token: string | undefined;\n do {\n const page = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n ...(token ? { ContinuationToken: token } : {}),\n }),\n );\n for (const item of page.Contents ?? []) {\n if (!item.Key) {\n continue;\n }\n objects.push({\n key: item.Key,\n size: item.Size ?? 0,\n ...(item.LastModified ? { lastModified: item.LastModified.toISOString() } : {}),\n });\n }\n token = page.IsTruncated ? page.NextContinuationToken : undefined;\n } while (token);\n return objects;\n }\n\n async copy(input: { from: string; to: string }): Promise<void> {\n await this.client.send(\n new CopyObjectCommand({\n Bucket: this.bucket,\n Key: input.to,\n CopySource: encodeURI(`${this.bucket}/${input.from}`),\n }),\n );\n }\n\n async publish(input: { from: string; to: string }): Promise<void> {\n const source = await this.client.send(\n new HeadObjectCommand({ Bucket: this.bucket, Key: input.from }),\n );\n await this.client.send(\n new CopyObjectCommand({\n Bucket: this.bucket,\n Key: input.to,\n CopySource: encodeURI(`${this.bucket}/${input.from}`),\n MetadataDirective: \"REPLACE\",\n CacheControl: PUBLIC_CACHE_CONTROL,\n ...(source.ContentType ? { ContentType: source.ContentType } : {}),\n }),\n );\n }\n\n publicUrl(key: string): string {\n if (!this.publicBase) {\n throw new Error(\"S3 storage has no publicBaseUrl, so it cannot serve public files\");\n }\n return `${this.publicBase}/${key.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n }\n\n async remove(key: string): Promise<void> {\n await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));\n }\n}\n\nfunction isNotFound(err: unknown): boolean {\n const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };\n return e?.name === \"NotFound\" || e?.name === \"NoSuchKey\" || e?.$metadata?.httpStatusCode === 404;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["getSignedUrl","signCloudFrontUrl"],"sources":["../src/index.ts"],"sourcesContent":["// AWS S3 implementation of the ObjectStorage port. Private bucket except the\n// public/ prefix; private files are reached by signed URLs, from CloudFront when\n// it is configured and from S3 otherwise. The bucket must already exist.\nimport {\n CopyObjectCommand,\n DeleteObjectCommand,\n GetObjectCommand,\n HeadObjectCommand,\n ListObjectsV2Command,\n PutObjectCommand,\n S3Client,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl as signCloudFrontUrl } from \"@aws-sdk/cloudfront-signer\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport {\n PUBLIC_CACHE_CONTROL,\n type Logger,\n type ObjectStorage,\n type PresignDownloadInput,\n type PresignedUpload,\n type StoredObjectInfo,\n} from \"@headless-lms/core/types\";\n\nexport interface CloudFrontSigningConfig {\n /** Origin of the distribution in front of the bucket, e.g. https://media.example.com. */\n url: string;\n /** Id of a public key in the distribution's trusted key group. */\n keyPairId: string;\n /** PEM private key of that public key. */\n privateKey: string;\n}\n\nexport interface S3StorageConfig {\n region: string;\n bucket: string;\n /** Omit both keys to use the default AWS credential chain (IAM role, env, profile). */\n accessKeyId?: string;\n secretAccessKey?: string;\n /** Custom endpoint for S3-compatible stores. */\n endpoint?: string;\n /** Required by most S3-compatible stores when `endpoint` is set. */\n forcePathStyle?: boolean;\n /** Origin public files are served from. Defaults to the CloudFront url. */\n publicBaseUrl?: string;\n /** Sign private file links for CloudFront instead of presigning them on S3. */\n cloudFront?: CloudFrontSigningConfig;\n uploadExpirySeconds: number;\n}\n\nexport class S3StorageAdapter implements ObjectStorage {\n private readonly client: S3Client;\n private readonly bucket: string;\n private readonly publicBase: string | null;\n private readonly cloudFront: CloudFrontSigningConfig | null;\n readonly uploadExpirySeconds: number;\n\n constructor(\n config: S3StorageConfig,\n private readonly logger?: Logger,\n ) {\n this.client = new S3Client({\n region: config.region,\n ...(config.endpoint ? { endpoint: config.endpoint } : {}),\n ...(config.forcePathStyle !== undefined ? { forcePathStyle: config.forcePathStyle } : {}),\n ...(config.accessKeyId && config.secretAccessKey\n ? {\n credentials: {\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n },\n }\n : {}),\n });\n this.bucket = config.bucket;\n this.uploadExpirySeconds = config.uploadExpirySeconds;\n this.cloudFront = config.cloudFront\n ? { ...config.cloudFront, url: config.cloudFront.url.replace(/\\/+$/, \"\") }\n : null;\n const publicBase = config.publicBaseUrl ?? config.cloudFront?.url;\n this.publicBase = publicBase ? publicBase.replace(/\\/+$/, \"\") : null;\n }\n\n async presignUpload(input: {\n key: string;\n contentType?: string;\n expiresInSeconds?: number;\n }): Promise<PresignedUpload> {\n const expiresInSeconds = input.expiresInSeconds ?? this.uploadExpirySeconds;\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: input.key,\n ...(input.contentType ? { ContentType: input.contentType } : {}),\n });\n const url = await getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });\n return {\n url,\n method: \"PUT\",\n key: input.key,\n expiresInSeconds,\n headers: input.contentType ? { \"Content-Type\": input.contentType } : {},\n };\n }\n\n async presignDownload(input: PresignDownloadInput): Promise<string> {\n const disposition = input.downloadFilename\n ? `attachment; filename=\"${input.downloadFilename.replace(/\"/g, \"\")}\"`\n : null;\n if (this.cloudFront) {\n const query = [\n `response-cache-control=${encodeURIComponent(PUBLIC_CACHE_CONTROL)}`,\n ...(disposition ? [`response-content-disposition=${encodeURIComponent(disposition)}`] : []),\n ].join(\"&\");\n return signCloudFrontUrl({\n url: `${this.cloudFront.url}/${encodeKey(input.key)}?${query}`,\n keyPairId: this.cloudFront.keyPairId,\n privateKey: this.cloudFront.privateKey,\n dateLessThan: input.expiresAt,\n });\n }\n const command = new GetObjectCommand({\n Bucket: this.bucket,\n Key: input.key,\n ResponseCacheControl: PUBLIC_CACHE_CONTROL,\n ...(disposition ? { ResponseContentDisposition: disposition } : {}),\n });\n return getSignedUrl(this.client, command, {\n signingDate: input.signedAt,\n expiresIn: Math.round((input.expiresAt.getTime() - input.signedAt.getTime()) / 1000),\n });\n }\n\n async stat(key: string): Promise<StoredObjectInfo | null> {\n try {\n const s = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));\n return {\n key,\n size: s.ContentLength ?? 0,\n ...(s.ContentType ? { contentType: s.ContentType } : {}),\n ...(s.LastModified ? { lastModified: s.LastModified.toISOString() } : {}),\n };\n } catch (err) {\n if (isNotFound(err)) {\n return null;\n }\n throw err;\n }\n }\n\n async list(prefix: string): Promise<StoredObjectInfo[]> {\n const objects: StoredObjectInfo[] = [];\n let token: string | undefined;\n do {\n const page = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n ...(token ? { ContinuationToken: token } : {}),\n }),\n );\n for (const item of page.Contents ?? []) {\n if (!item.Key) {\n continue;\n }\n objects.push({\n key: item.Key,\n size: item.Size ?? 0,\n ...(item.LastModified ? { lastModified: item.LastModified.toISOString() } : {}),\n });\n }\n token = page.IsTruncated ? page.NextContinuationToken : undefined;\n } while (token);\n return objects;\n }\n\n async copy(input: { from: string; to: string }): Promise<void> {\n await this.client.send(\n new CopyObjectCommand({\n Bucket: this.bucket,\n Key: input.to,\n CopySource: encodeURI(`${this.bucket}/${input.from}`),\n }),\n );\n }\n\n async publish(input: { from: string; to: string }): Promise<void> {\n const source = await this.client.send(\n new HeadObjectCommand({ Bucket: this.bucket, Key: input.from }),\n );\n await this.client.send(\n new CopyObjectCommand({\n Bucket: this.bucket,\n Key: input.to,\n CopySource: encodeURI(`${this.bucket}/${input.from}`),\n MetadataDirective: \"REPLACE\",\n CacheControl: PUBLIC_CACHE_CONTROL,\n ...(source.ContentType ? { ContentType: source.ContentType } : {}),\n }),\n );\n }\n\n publicUrl(key: string): string {\n if (!this.publicBase) {\n throw new Error(\"S3 storage has no publicBaseUrl, so it cannot serve public files\");\n }\n return `${this.publicBase}/${encodeKey(key)}`;\n }\n\n async remove(key: string): Promise<void> {\n await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));\n }\n}\n\nfunction encodeKey(key: string): string {\n return key.split(\"/\").map(encodeURIComponent).join(\"/\");\n}\n\nfunction isNotFound(err: unknown): boolean {\n const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };\n return e?.name === \"NotFound\" || e?.name === \"NoSuchKey\" || e?.$metadata?.httpStatusCode === 404;\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@headless-lms/adapter-storage-s3",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@aws-sdk/client-s3": "^3.712.0",
|
|
15
|
+
"@aws-sdk/cloudfront-signer": "^3.1166.0",
|
|
15
16
|
"@aws-sdk/s3-request-presigner": "^3.712.0",
|
|
16
|
-
"@headless-lms/core": "0.
|
|
17
|
+
"@headless-lms/core": "0.8.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"@types/node": "^22.10.2",
|