@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 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
- short-lived presigned URLs. Also works against S3-compatible stores via `endpoint` + `forcePathStyle`.
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 | Required | Maps to | Notes |
84
- | -------------------------- | -------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
85
- | `STORAGE_REGION` | yes | `region` | |
86
- | `STORAGE_BUCKET` | yes | `bucket` | Must exist; kept private. |
87
- | `STORAGE_ACCESS_KEY` | no | `accessKeyId` | Omit for the default credential chain. |
88
- | `STORAGE_SECRET_KEY` | no | `secretAccessKey` | Omit for the default credential chain. |
89
- | `STORAGE_ENDPOINT` | no | `endpoint` | Full URL, for S3-compatible stores. |
90
- | `STORAGE_FORCE_PATH_STYLE` | no | `forcePathStyle` | `"true"` / `"false"`. |
91
- | `STORAGE_UPLOAD_EXPIRY` | no | `uploadExpirySeconds` | Default 300. Stray member uploads older than this are swept. |
92
- | `STORAGE_DOWNLOAD_EXPIRY` | no | `downloadExpirySeconds` | Download lifetime when the caller names none. Default 86400. The API names `DELIVERY_URL_EXPIRY` on every URL it serves. |
93
- | `STORAGE_PUBLIC_URL` | yes | `publicBaseUrl` | Origin serving `public/*`, such as `https://cdn.example.com` or `https://your-bucket.s3.amazonaws.com`. |
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/s3-request-presigner";
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.downloadExpiry = config.downloadExpirySeconds;
26
- this.publicBase = config.publicBaseUrl ? config.publicBaseUrl.replace(/\/+$/, "") : null;
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 expiresInSeconds = input.expiresInSeconds ?? this.downloadExpiry;
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
- ...input.downloadFilename ? { ResponseContentDisposition: `attachment; filename="${input.downloadFilename.replace(/"/g, "")}"` } : {}
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.split("/").map(encodeURIComponent).join("/")}`;
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.2.2",
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.7.0"
17
+ "@headless-lms/core": "0.8.0"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@types/node": "^22.10.2",