@podosoft/podokit 1.3.0 → 1.4.1
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 +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts
CHANGED
|
@@ -1,8 +1,51 @@
|
|
|
1
|
-
import { afterAll, describe, expect, it } from "bun:test";
|
|
2
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
1
|
+
import { afterAll, describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
IN_MEMORY_READ_BYTES,
|
|
5
|
+
MIN_MULTIPART_PART_BYTES,
|
|
6
|
+
MultipartUploadError,
|
|
7
|
+
objectResponse,
|
|
8
|
+
} from "@podosoft/podokit-runtime";
|
|
3
9
|
import { ReadinessService } from "../health/readiness.service";
|
|
4
10
|
import { S3ObjectStore } from "./s3-object.store";
|
|
5
|
-
import {
|
|
11
|
+
import { EMPTY_PAYLOAD_HASH, signS3Request } from "./s3-signer";
|
|
12
|
+
import { storageSettings } from "./storage.config";
|
|
13
|
+
import { STREAM_PART_BYTES, StorageService } from "./storage.service";
|
|
14
|
+
|
|
15
|
+
async function* chunks(content: Uint8Array, size = 256 * 1024): AsyncGenerator<Uint8Array> {
|
|
16
|
+
for (let offset = 0; offset < content.byteLength; offset += size) yield content.subarray(offset, offset + size);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function digest(content: Uint8Array): string {
|
|
20
|
+
return createHash("sha256").update(content).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readAll(store: S3ObjectStore, key: string): Promise<Uint8Array> {
|
|
24
|
+
const object = await store.get(key);
|
|
25
|
+
if (!object) throw new Error(`expected ${key}`);
|
|
26
|
+
return new Uint8Array(await new Response(object.body).arrayBuffer());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lists unfinished multipart uploads of one key (path-style endpoints only).
|
|
31
|
+
* MinIO-compatible services match the prefix against the whole key.
|
|
32
|
+
*/
|
|
33
|
+
async function pendingUploads(key: string): Promise<string[]> {
|
|
34
|
+
const settings = storageSettings();
|
|
35
|
+
const signed = signS3Request({
|
|
36
|
+
method: "GET",
|
|
37
|
+
url: new URL(`${settings.endpoint}/${settings.bucket}/`),
|
|
38
|
+
query: [["uploads", ""], ["prefix", key]],
|
|
39
|
+
payloadHash: EMPTY_PAYLOAD_HASH,
|
|
40
|
+
}, {
|
|
41
|
+
accessKeyId: settings.accessKeyId ?? "",
|
|
42
|
+
secretAccessKey: settings.secretAccessKey ?? "",
|
|
43
|
+
}, settings.region);
|
|
44
|
+
const response = await fetch(signed.url, { headers: signed.headers });
|
|
45
|
+
const xml = await response.text();
|
|
46
|
+
if (!response.ok) throw new Error(`ListMultipartUploads failed: ${xml}`);
|
|
47
|
+
return [...xml.matchAll(/<UploadId>([^<]+)<\/UploadId>/g)].map((match) => match[1] ?? "");
|
|
48
|
+
}
|
|
6
49
|
|
|
7
50
|
// Runs against the bucket in the S3_* settings, like the storage API spec.
|
|
8
51
|
const integration = process.env.S3_ENDPOINT ? describe : describe.skip;
|
|
@@ -69,12 +112,182 @@ integration("S3ObjectStore against a live bucket", () => {
|
|
|
69
112
|
expect(object.size).toBe(6);
|
|
70
113
|
});
|
|
71
114
|
|
|
72
|
-
it("
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
await store.put(key,
|
|
115
|
+
it("streams a body larger than one part and removes nothing on success", async () => {
|
|
116
|
+
const content = randomBytes(2 * STREAM_PART_BYTES + 12345);
|
|
117
|
+
const key = `${prefix}streamed.bin`;
|
|
118
|
+
created.push(key);
|
|
119
|
+
const stored = await store.put(key, chunks(content), { contentType: "application/octet-stream" });
|
|
120
|
+
expect(stored).toMatchObject({ key, size: content.byteLength });
|
|
121
|
+
expect(await store.stat(key)).toMatchObject({ size: content.byteLength, contentType: "application/octet-stream" });
|
|
122
|
+
expect(digest(await readAll(store, key))).toBe(digest(content));
|
|
123
|
+
|
|
124
|
+
const fromStream = `${prefix}from-stream.bin`;
|
|
125
|
+
created.push(fromStream);
|
|
126
|
+
await store.put(fromStream, new Response(content).body as ReadableStream<Uint8Array>);
|
|
127
|
+
expect(digest(await readAll(store, fromStream))).toBe(digest(content));
|
|
128
|
+
|
|
129
|
+
const empty = `${prefix}empty.bin`;
|
|
130
|
+
created.push(empty);
|
|
131
|
+
expect(await store.put(empty, chunks(new Uint8Array(0)))).toMatchObject({ size: 0 });
|
|
132
|
+
expect(await store.stat(empty)).toMatchObject({ size: 0 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("aborts a streamed write whose source fails, leaving no object or upload", async () => {
|
|
136
|
+
const key = `${prefix}failed/stream.bin`;
|
|
137
|
+
async function* failing(): AsyncGenerator<Uint8Array> {
|
|
138
|
+
yield* chunks(randomBytes(STREAM_PART_BYTES + 1024));
|
|
139
|
+
throw new Error("client went away");
|
|
140
|
+
}
|
|
141
|
+
await expect(store.put(key, failing())).rejects.toThrow("client went away");
|
|
142
|
+
expect(await store.exists(key)).toBe(false);
|
|
143
|
+
expect(await pendingUploads(key)).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("assembles parts uploaded out of order under a key with reserved characters", async () => {
|
|
147
|
+
const key = `${prefix}multipart/a b+c&d=é.bin`;
|
|
148
|
+
created.push(key);
|
|
149
|
+
const first = randomBytes(MIN_MULTIPART_PART_BYTES);
|
|
150
|
+
const second = randomBytes(1000);
|
|
151
|
+
const { uploadId } = await store.createMultipartUpload(key, { contentType: "video/mp4" });
|
|
152
|
+
expect(await pendingUploads(key)).toEqual([uploadId]);
|
|
77
153
|
|
|
78
|
-
await
|
|
154
|
+
const uploadedSecond = await store.uploadPart(key, uploadId, 2, chunks(second, 100));
|
|
155
|
+
const uploadedFirst = await store.uploadPart(key, uploadId, 1, new Blob([first]));
|
|
156
|
+
expect(uploadedFirst.size).toBe(first.byteLength);
|
|
157
|
+
expect(uploadedSecond).toMatchObject({ size: second.byteLength });
|
|
158
|
+
|
|
159
|
+
const info = await store.completeMultipartUpload(key, uploadId, [
|
|
160
|
+
{ partNumber: 2, etag: uploadedSecond.etag },
|
|
161
|
+
{ partNumber: 1, etag: uploadedFirst.etag },
|
|
162
|
+
]);
|
|
163
|
+
expect(info).toMatchObject({ key, size: first.byteLength + second.byteLength, contentType: "video/mp4" });
|
|
164
|
+
expect(info.etag).toMatch(/^"[^"]+-2"$/);
|
|
165
|
+
expect(digest(await readAll(store, key))).toBe(digest(Buffer.concat([first, second])));
|
|
166
|
+
expect((await store.list(`${prefix}multipart/`)).keys).toEqual([key]);
|
|
167
|
+
expect(await pendingUploads(key)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("reports multipart failures with provider-neutral codes", async () => {
|
|
171
|
+
const key = `${prefix}multipart-errors.bin`;
|
|
172
|
+
const codeOf = async (operation: Promise<unknown>): Promise<string | undefined> => {
|
|
173
|
+
const error = await operation.then(() => undefined, (failure: unknown) => failure);
|
|
174
|
+
return error instanceof MultipartUploadError ? error.code : undefined;
|
|
175
|
+
};
|
|
176
|
+
const { uploadId } = await store.createMultipartUpload(key);
|
|
177
|
+
const small = await store.uploadPart(key, uploadId, 1, "small");
|
|
178
|
+
const last = await store.uploadPart(key, uploadId, 2, "last");
|
|
179
|
+
|
|
180
|
+
expect(await codeOf(store.completeMultipartUpload(key, uploadId, [{ partNumber: 1, etag: "\"0\"" }])))
|
|
181
|
+
.toBe("INVALID_PART");
|
|
182
|
+
expect(await codeOf(store.completeMultipartUpload(key, uploadId, [
|
|
183
|
+
{ partNumber: 1, etag: small.etag },
|
|
184
|
+
{ partNumber: 2, etag: last.etag },
|
|
185
|
+
]))).toBe("PART_TOO_SMALL");
|
|
186
|
+
|
|
187
|
+
await store.abortMultipartUpload(key, uploadId);
|
|
188
|
+
await store.abortMultipartUpload(key, uploadId);
|
|
189
|
+
expect(await codeOf(store.uploadPart(key, uploadId, 1, "late"))).toBe("UPLOAD_NOT_FOUND");
|
|
190
|
+
expect(await codeOf(store.completeMultipartUpload(key, uploadId, [{ partNumber: 1, etag: small.etag }])))
|
|
191
|
+
.toBe("UPLOAD_NOT_FOUND");
|
|
192
|
+
expect(await store.exists(key)).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rejects keys that a request URL would rewrite, for put and multipart alike", async () => {
|
|
196
|
+
for (const key of [`${prefix}a/../b`, `${prefix}./b`, ""]) {
|
|
197
|
+
await expect(store.put(key, "x")).rejects.toThrow("Object key");
|
|
198
|
+
await expect(store.createMultipartUpload(key)).rejects.toThrow("Object key");
|
|
199
|
+
}
|
|
200
|
+
await expect(store.uploadPart(`${prefix}a`, "id", 0, "x")).rejects.toThrow(RangeError);
|
|
79
201
|
});
|
|
202
|
+
|
|
203
|
+
it("serves objects through objectResponse, with the Content-Length of those read into memory", async () => {
|
|
204
|
+
const server = Bun.serve({
|
|
205
|
+
port: 0,
|
|
206
|
+
async fetch(request): Promise<Response> {
|
|
207
|
+
const object = await store.get(`${prefix}${new URL(request.url).pathname.slice(1)}`);
|
|
208
|
+
return object ? objectResponse(request, object) : new Response(null, { status: 404 });
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
for (const size of [68, 100 * 1024, IN_MEMORY_READ_BYTES, IN_MEMORY_READ_BYTES + 1]) {
|
|
213
|
+
const content = randomBytes(size);
|
|
214
|
+
const name = `served/${size}.bin`;
|
|
215
|
+
await put(name, content, "image/png");
|
|
216
|
+
const response = await fetch(new URL(name, server.url));
|
|
217
|
+
expect(response.status).toBe(200);
|
|
218
|
+
expect(response.headers.get("content-type")).toBe("image/png");
|
|
219
|
+
if (size <= IN_MEMORY_READ_BYTES) {
|
|
220
|
+
expect(response.headers.get("content-length")).toBe(String(size));
|
|
221
|
+
expect(response.headers.get("transfer-encoding")).toBeNull();
|
|
222
|
+
} else {
|
|
223
|
+
// Streamed from S3, which Bun may send chunked; a length must still be right.
|
|
224
|
+
expect(response.headers.get("content-length") ?? String(size)).toBe(String(size));
|
|
225
|
+
}
|
|
226
|
+
expect(digest(new Uint8Array(await response.arrayBuffer()))).toBe(digest(content));
|
|
227
|
+
}
|
|
228
|
+
const missing = await fetch(new URL("served/missing.bin", server.url));
|
|
229
|
+
expect(missing.status).toBe(404);
|
|
230
|
+
} finally {
|
|
231
|
+
server.stop(true);
|
|
232
|
+
}
|
|
233
|
+
}, 30_000);
|
|
234
|
+
|
|
235
|
+
it("releases a streamed download when objectResponse sends no body", async () => {
|
|
236
|
+
const key = await put("unsent.bin", randomBytes(16 * 1024 * 1024));
|
|
237
|
+
const info = await store.stat(key);
|
|
238
|
+
if (!info) throw new Error("expected the stored object");
|
|
239
|
+
const downloads = spyOn(globalThis, "fetch");
|
|
240
|
+
try {
|
|
241
|
+
const requests: RequestInit[] = [{ headers: { "if-none-match": info.etag } }, { method: "HEAD" }];
|
|
242
|
+
for (const init of requests) {
|
|
243
|
+
downloads.mockClear();
|
|
244
|
+
const object = await store.get(key);
|
|
245
|
+
if (!object) throw new Error("expected the stored object");
|
|
246
|
+
const response = objectResponse(new Request("http://localhost/unsent.bin", init), object);
|
|
247
|
+
expect(response.status).toBe(init.method === "HEAD" ? 200 : 304);
|
|
248
|
+
expect(downloads).toHaveBeenCalledTimes(1);
|
|
249
|
+
const download = await downloads.mock.results[0]?.value;
|
|
250
|
+
if (!(download instanceof Response)) throw new Error("expected the download response");
|
|
251
|
+
// A cancelled body reads as finished instead of yielding the object.
|
|
252
|
+
expect(await download.body?.getReader().read()).toEqual({ done: true, value: undefined });
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
downloads.mockRestore();
|
|
256
|
+
}
|
|
257
|
+
}, 30_000);
|
|
258
|
+
|
|
259
|
+
it("reports a missing bucket as an error rather than a missing object", async () => {
|
|
260
|
+
const bucket = process.env.S3_BUCKET;
|
|
261
|
+
process.env.S3_BUCKET = `podokit-missing-${randomUUID()}`;
|
|
262
|
+
const elsewhere = new StorageService(new ReadinessService());
|
|
263
|
+
if (bucket === undefined) delete process.env.S3_BUCKET;
|
|
264
|
+
else process.env.S3_BUCKET = bucket;
|
|
265
|
+
try {
|
|
266
|
+
await expect(new S3ObjectStore(elsewhere).get("anything")).rejects.toThrow("NoSuchBucket");
|
|
267
|
+
} finally {
|
|
268
|
+
elsewhere.close();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("never sends content written during a read under the metadata of that read", async () => {
|
|
273
|
+
// One GET describes and streams one version. An overwrite while a large
|
|
274
|
+
// object streams may end the stream early, as Silo does, but never switches
|
|
275
|
+
// it to the new content; an object read into memory is already complete.
|
|
276
|
+
for (const size of [1024 * 1024, 16 * 1024 * 1024]) {
|
|
277
|
+
const before = randomBytes(size);
|
|
278
|
+
const key = await put(`overwritten/${size}.bin`, before);
|
|
279
|
+
const info = await store.stat(key);
|
|
280
|
+
const object = await store.get(key);
|
|
281
|
+
if (!object) throw new Error("expected the stored object");
|
|
282
|
+
expect(object).toMatchObject({ size, etag: info?.etag ?? "" });
|
|
283
|
+
|
|
284
|
+
const reading = new Response(object.body).arrayBuffer().then(
|
|
285
|
+
(bytes) => new Uint8Array(bytes),
|
|
286
|
+
() => null,
|
|
287
|
+
);
|
|
288
|
+
await store.put(key, randomBytes(size + 1000));
|
|
289
|
+
const read = await reading;
|
|
290
|
+
if (size <= IN_MEMORY_READ_BYTES || read !== null) expect(read && digest(read)).toBe(digest(before));
|
|
291
|
+
}
|
|
292
|
+
}, 60_000);
|
|
80
293
|
});
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts
CHANGED
|
@@ -1,31 +1,45 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import {
|
|
2
|
+
assertPartNumber,
|
|
3
|
+
normalizeCompletedParts,
|
|
4
|
+
type CompletedPart,
|
|
5
|
+
type CreateMultipartUploadOptions,
|
|
6
|
+
type DownloadUrlOptions,
|
|
7
|
+
type ListObjectsOptions,
|
|
8
|
+
type MultipartUpload,
|
|
9
|
+
type ObjectBody,
|
|
10
|
+
type ObjectData,
|
|
11
|
+
type ObjectInfo,
|
|
12
|
+
type ObjectPage,
|
|
13
|
+
type ObjectStore,
|
|
14
|
+
type PutObjectOptions,
|
|
15
|
+
type StoredObject,
|
|
16
|
+
type UploadedPart,
|
|
11
17
|
} from "@podosoft/podokit-runtime";
|
|
12
18
|
import type { StorageService } from "./storage.service";
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const MAX_KEY_BYTES = 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Checks a key for writing. S3 limits keys to 1024 bytes, and a "." or ".."
|
|
24
|
+
* segment would be resolved away in the request URL, writing another key.
|
|
25
|
+
*/
|
|
26
|
+
export function s3ObjectKey(key: string): string {
|
|
27
|
+
if (key.length === 0 || Buffer.byteLength(key) > MAX_KEY_BYTES) {
|
|
28
|
+
throw new Error(`Object key must be 1 to ${MAX_KEY_BYTES} bytes`);
|
|
29
|
+
}
|
|
30
|
+
if (key.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
31
|
+
throw new Error("Object key must not contain . or .. segments");
|
|
32
|
+
}
|
|
33
|
+
return key;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
export class S3ObjectStore implements ObjectStore {
|
|
23
37
|
constructor(private readonly storage: StorageService) {}
|
|
24
38
|
|
|
25
39
|
async put(key: string, body: ObjectBody, options: PutObjectOptions = {}): Promise<StoredObject> {
|
|
26
|
-
const
|
|
27
|
-
await this.storage.put(
|
|
28
|
-
return { key, size
|
|
40
|
+
const objectKey = s3ObjectKey(key);
|
|
41
|
+
const size = await this.storage.put(objectKey, body, options.contentType);
|
|
42
|
+
return { key: objectKey, size, ...options };
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
get(key: string): Promise<ObjectData | null> {
|
|
@@ -52,5 +66,27 @@ export class S3ObjectStore implements ObjectStore {
|
|
|
52
66
|
return this.storage.presignedGetUrl(key, options.expiresInSeconds);
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
async createMultipartUpload(key: string, options: CreateMultipartUploadOptions = {}): Promise<MultipartUpload> {
|
|
70
|
+
return { uploadId: await this.storage.createMultipartUpload(s3ObjectKey(key), options.contentType) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async uploadPart(key: string, uploadId: string, partNumber: number, body: ObjectBody): Promise<UploadedPart> {
|
|
74
|
+
const objectKey = s3ObjectKey(key);
|
|
75
|
+
assertPartNumber(partNumber);
|
|
76
|
+
return this.storage.uploadPart(objectKey, uploadId, partNumber, body);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async completeMultipartUpload(key: string, uploadId: string, parts: readonly CompletedPart[]): Promise<ObjectInfo> {
|
|
80
|
+
const objectKey = s3ObjectKey(key);
|
|
81
|
+
await this.storage.completeMultipartUpload(objectKey, uploadId, normalizeCompletedParts(parts));
|
|
82
|
+
const info = await this.storage.stat(objectKey);
|
|
83
|
+
if (!info) throw new Error(`Object ${objectKey} was removed right after its upload completed`);
|
|
84
|
+
return info;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async abortMultipartUpload(key: string, uploadId: string): Promise<void> {
|
|
88
|
+
await this.storage.abortMultipartUpload(s3ObjectKey(key), uploadId);
|
|
89
|
+
}
|
|
90
|
+
|
|
55
91
|
close(): void {}
|
|
56
92
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { EMPTY_PAYLOAD_HASH, encodeRfc3986, signS3Request } from "./s3-signer";
|
|
3
|
+
|
|
4
|
+
// The examples in the AWS documentation for Signature Version 4 header signing.
|
|
5
|
+
const credentials = {
|
|
6
|
+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
7
|
+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
8
|
+
};
|
|
9
|
+
const now = new Date("2013-05-24T00:00:00Z");
|
|
10
|
+
|
|
11
|
+
function signature(authorization: string | undefined): string | undefined {
|
|
12
|
+
return /Signature=([0-9a-f]{64})$/.exec(authorization ?? "")?.[1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("signS3Request", () => {
|
|
16
|
+
it("matches the AWS example for a ranged GET", () => {
|
|
17
|
+
const signed = signS3Request({
|
|
18
|
+
method: "GET",
|
|
19
|
+
url: new URL("https://examplebucket.s3.amazonaws.com/test.txt"),
|
|
20
|
+
headers: { Range: "bytes=0-9" },
|
|
21
|
+
payloadHash: EMPTY_PAYLOAD_HASH,
|
|
22
|
+
}, credentials, "us-east-1", now);
|
|
23
|
+
expect(signed.headers.authorization).toBe(
|
|
24
|
+
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " +
|
|
25
|
+
"SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, " +
|
|
26
|
+
"Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41",
|
|
27
|
+
);
|
|
28
|
+
expect(signed.headers["x-amz-date"]).toBe("20130524T000000Z");
|
|
29
|
+
expect(signed.headers.host).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("matches the AWS examples for query parameters and an encoded key", () => {
|
|
33
|
+
const list = signS3Request({
|
|
34
|
+
method: "GET",
|
|
35
|
+
url: new URL("https://examplebucket.s3.amazonaws.com/"),
|
|
36
|
+
query: [["prefix", "J"], ["max-keys", "2"]],
|
|
37
|
+
payloadHash: EMPTY_PAYLOAD_HASH,
|
|
38
|
+
}, credentials, "us-east-1", now);
|
|
39
|
+
expect(signature(list.headers.authorization)).toBe("34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7");
|
|
40
|
+
expect(list.url).toBe("https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J");
|
|
41
|
+
|
|
42
|
+
const lifecycle = signS3Request({
|
|
43
|
+
method: "GET",
|
|
44
|
+
url: new URL("https://examplebucket.s3.amazonaws.com/"),
|
|
45
|
+
query: [["lifecycle", ""]],
|
|
46
|
+
payloadHash: EMPTY_PAYLOAD_HASH,
|
|
47
|
+
}, credentials, "us-east-1", now);
|
|
48
|
+
expect(signature(lifecycle.headers.authorization)).toBe("fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543");
|
|
49
|
+
|
|
50
|
+
const put = signS3Request({
|
|
51
|
+
method: "PUT",
|
|
52
|
+
url: new URL(`https://examplebucket.s3.amazonaws.com/${encodeRfc3986("test$file.text")}`),
|
|
53
|
+
headers: { date: "Fri, 24 May 2013 00:00:00 GMT", "x-amz-storage-class": "REDUCED_REDUNDANCY" },
|
|
54
|
+
payloadHash: "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072",
|
|
55
|
+
}, credentials, "us-east-1", now);
|
|
56
|
+
expect(signature(put.headers.authorization)).toBe("98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("signs a session token and encodes every reserved character", () => {
|
|
60
|
+
const signed = signS3Request({
|
|
61
|
+
method: "DELETE",
|
|
62
|
+
url: new URL("http://localhost:9000/podokit/a"),
|
|
63
|
+
query: [["uploadId", "a+b/c=d e"]],
|
|
64
|
+
payloadHash: EMPTY_PAYLOAD_HASH,
|
|
65
|
+
}, { ...credentials, sessionToken: "token" }, "us-east-1", now);
|
|
66
|
+
expect(signed.headers["x-amz-security-token"]).toBe("token");
|
|
67
|
+
expect(signed.headers.authorization).toContain("SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token,");
|
|
68
|
+
expect(signed.url).toBe("http://localhost:9000/podokit/a?uploadId=a%2Bb%2Fc%3Dd%20e");
|
|
69
|
+
expect(encodeRfc3986("a b!*'()~._-")).toBe("a%20b%21%2A%27%28%29~._-");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface S3Credentials {
|
|
4
|
+
accessKeyId: string;
|
|
5
|
+
secretAccessKey: string;
|
|
6
|
+
sessionToken?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SignedRequestInput {
|
|
10
|
+
method: string;
|
|
11
|
+
/** The request URL without a query string; the path must already be encoded. */
|
|
12
|
+
url: URL;
|
|
13
|
+
query?: ReadonlyArray<readonly [string, string]>;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
/** The hex SHA-256 of the body, or UNSIGNED-PAYLOAD. */
|
|
16
|
+
payloadHash: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SignedRequest {
|
|
20
|
+
url: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const EMPTY_PAYLOAD_HASH = createHash("sha256").update("").digest("hex");
|
|
25
|
+
export const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
26
|
+
|
|
27
|
+
/** Percent-encodes everything but RFC 3986 unreserved characters, as SigV4 requires. */
|
|
28
|
+
export function encodeRfc3986(value: string): string {
|
|
29
|
+
return encodeURIComponent(value).replace(
|
|
30
|
+
/[!'()*]/g,
|
|
31
|
+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hmac(key: string | Buffer, value: string): Buffer {
|
|
36
|
+
return createHmac("sha256", key).update(value, "utf8").digest();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hex(value: string): string {
|
|
40
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function amzDate(now: Date): string {
|
|
44
|
+
return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Signs an S3 request with AWS Signature Version 4 in the Authorization header.
|
|
49
|
+
* Bun's S3Client signs its own requests but exposes no multipart operations,
|
|
50
|
+
* so the multipart calls are signed here.
|
|
51
|
+
*/
|
|
52
|
+
export function signS3Request(
|
|
53
|
+
input: SignedRequestInput,
|
|
54
|
+
credentials: S3Credentials,
|
|
55
|
+
region: string,
|
|
56
|
+
now: Date = new Date(),
|
|
57
|
+
): SignedRequest {
|
|
58
|
+
const timestamp = amzDate(now);
|
|
59
|
+
const day = timestamp.slice(0, 8);
|
|
60
|
+
const headers: Record<string, string> = {};
|
|
61
|
+
for (const [name, value] of Object.entries(input.headers ?? {})) headers[name.toLowerCase()] = value;
|
|
62
|
+
headers.host = input.url.host;
|
|
63
|
+
headers["x-amz-date"] = timestamp;
|
|
64
|
+
headers["x-amz-content-sha256"] = input.payloadHash;
|
|
65
|
+
if (credentials.sessionToken) headers["x-amz-security-token"] = credentials.sessionToken;
|
|
66
|
+
|
|
67
|
+
const names = Object.keys(headers).sort();
|
|
68
|
+
const canonicalHeaders = names
|
|
69
|
+
.map((name) => `${name}:${(headers[name] ?? "").trim().replace(/\s+/g, " ")}\n`)
|
|
70
|
+
.join("");
|
|
71
|
+
const signedHeaders = names.join(";");
|
|
72
|
+
const query = (input.query ?? [])
|
|
73
|
+
.map(([name, value]) => [encodeRfc3986(name), encodeRfc3986(value)] as const)
|
|
74
|
+
.sort(([leftName, leftValue], [rightName, rightValue]) =>
|
|
75
|
+
leftName < rightName ? -1 : leftName > rightName ? 1 : leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0
|
|
76
|
+
)
|
|
77
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
78
|
+
.join("&");
|
|
79
|
+
const canonicalRequest = [
|
|
80
|
+
input.method,
|
|
81
|
+
input.url.pathname,
|
|
82
|
+
query,
|
|
83
|
+
canonicalHeaders,
|
|
84
|
+
signedHeaders,
|
|
85
|
+
input.payloadHash,
|
|
86
|
+
].join("\n");
|
|
87
|
+
const scope = `${day}/${region}/s3/aws4_request`;
|
|
88
|
+
const stringToSign = ["AWS4-HMAC-SHA256", timestamp, scope, hex(canonicalRequest)].join("\n");
|
|
89
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secretAccessKey}`, day), region), "s3"), "aws4_request");
|
|
90
|
+
const signature = createHmac("sha256", signingKey).update(stringToSign, "utf8").digest("hex");
|
|
91
|
+
|
|
92
|
+
headers.authorization =
|
|
93
|
+
`AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, ` +
|
|
94
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
95
|
+
// Bun's fetch sets Host from the URL.
|
|
96
|
+
delete headers.host;
|
|
97
|
+
return { url: query ? `${input.url.href}?${query}` : input.url.href, headers };
|
|
98
|
+
}
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
|
+
import { objectBodyChunks } from "@podosoft/podokit-runtime";
|
|
3
4
|
import { Elysia } from "elysia";
|
|
4
5
|
import type { RequestIdentity } from "../core/services";
|
|
5
6
|
import { storageDemoRoutes, type DemoStorage } from "./storage.module";
|
|
@@ -16,9 +17,11 @@ function memoryStorage(): MemoryStorage {
|
|
|
16
17
|
objects,
|
|
17
18
|
presigned,
|
|
18
19
|
put: async (key, body) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const chunks: Uint8Array[] = [];
|
|
21
|
+
for await (const chunk of objectBodyChunks(body)) chunks.push(chunk);
|
|
22
|
+
const content = Buffer.concat(chunks);
|
|
23
|
+
objects.set(key, content.toString("utf8"));
|
|
24
|
+
return content.byteLength;
|
|
22
25
|
},
|
|
23
26
|
open: async (key) => {
|
|
24
27
|
const content = objects.get(key);
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts
CHANGED
|
@@ -5,14 +5,18 @@ export interface StorageSettings {
|
|
|
5
5
|
endpoint?: string;
|
|
6
6
|
accessKeyId?: string;
|
|
7
7
|
secretAccessKey?: string;
|
|
8
|
+
sessionToken?: string;
|
|
8
9
|
virtualHostedStyle: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
// The same S3_* then AWS_* credential variables Bun.S3Client reads, so the
|
|
13
|
+
// signed multipart requests use the credentials of every other request.
|
|
11
14
|
export function storageSettings(): StorageSettings {
|
|
12
15
|
const provider = process.env.STORAGE_PROVIDER === "aws" ? "aws" : "minio";
|
|
13
16
|
const endpoint = process.env.S3_ENDPOINT;
|
|
14
|
-
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
|
|
15
|
-
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
|
17
|
+
const accessKeyId = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
|
|
18
|
+
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
|
|
19
|
+
const sessionToken = process.env.S3_SESSION_TOKEN || process.env.AWS_SESSION_TOKEN;
|
|
16
20
|
return {
|
|
17
21
|
provider,
|
|
18
22
|
bucket: process.env.S3_BUCKET ?? "podokit",
|
|
@@ -23,5 +27,6 @@ export function storageSettings(): StorageSettings {
|
|
|
23
27
|
...(endpoint ? { endpoint } : {}),
|
|
24
28
|
...(accessKeyId ? { accessKeyId } : {}),
|
|
25
29
|
...(secretAccessKey ? { secretAccessKey } : {}),
|
|
30
|
+
...(sessionToken ? { sessionToken } : {}),
|
|
26
31
|
};
|
|
27
32
|
}
|