@jant/core 0.3.8 → 0.3.9

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.
Files changed (44) hide show
  1. package/dist/app.js +7 -4
  2. package/dist/db/schema.js +2 -1
  3. package/dist/lib/image.js +39 -15
  4. package/dist/lib/media-helpers.js +14 -8
  5. package/dist/lib/storage.js +164 -0
  6. package/dist/routes/api/posts.js +12 -7
  7. package/dist/routes/api/timeline.js +3 -2
  8. package/dist/routes/api/upload.js +27 -20
  9. package/dist/routes/dash/media.js +24 -14
  10. package/dist/routes/dash/posts.js +4 -1
  11. package/dist/routes/feed/rss.js +3 -2
  12. package/dist/routes/pages/home.js +3 -2
  13. package/dist/routes/pages/post.js +9 -5
  14. package/dist/services/media.js +7 -5
  15. package/dist/theme/components/PostForm.js +4 -3
  16. package/dist/types.js +32 -0
  17. package/package.json +2 -1
  18. package/src/__tests__/helpers/app.ts +1 -0
  19. package/src/__tests__/helpers/db.ts +10 -0
  20. package/src/app.tsx +8 -7
  21. package/src/db/migrations/0004_add_storage_provider.sql +3 -0
  22. package/src/db/migrations/meta/_journal.json +7 -0
  23. package/src/db/schema.ts +2 -1
  24. package/src/i18n/locales/en.po +67 -67
  25. package/src/i18n/locales/zh-Hans.po +67 -67
  26. package/src/i18n/locales/zh-Hant.po +67 -67
  27. package/src/lib/__tests__/image.test.ts +96 -0
  28. package/src/lib/__tests__/storage.test.ts +162 -0
  29. package/src/lib/image.ts +46 -16
  30. package/src/lib/media-helpers.ts +29 -18
  31. package/src/lib/storage.ts +236 -0
  32. package/src/routes/api/__tests__/posts.test.ts +8 -8
  33. package/src/routes/api/posts.ts +20 -6
  34. package/src/routes/api/timeline.tsx +8 -1
  35. package/src/routes/api/upload.ts +44 -21
  36. package/src/routes/dash/media.tsx +40 -8
  37. package/src/routes/dash/posts.tsx +5 -0
  38. package/src/routes/feed/rss.ts +3 -2
  39. package/src/routes/pages/home.tsx +8 -1
  40. package/src/routes/pages/post.tsx +29 -17
  41. package/src/services/__tests__/media.test.ts +44 -26
  42. package/src/services/media.ts +10 -7
  43. package/src/theme/components/PostForm.tsx +13 -2
  44. package/src/types.ts +41 -1
@@ -0,0 +1,162 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { createR2Driver, createStorageDriver } from "../storage.js";
4
+ import type { Bindings } from "../../types.js";
5
+
6
+ describe("createStorageDriver", () => {
7
+ it("returns null when no storage is configured", () => {
8
+ const env = { DB: {} } as Bindings;
9
+ const driver = createStorageDriver(env);
10
+ expect(driver).toBeNull();
11
+ });
12
+
13
+ it("returns R2 driver when R2 binding is present", () => {
14
+ const env = {
15
+ DB: {},
16
+ R2: { put: vi.fn(), get: vi.fn(), delete: vi.fn() },
17
+ } as unknown as Bindings;
18
+ const driver = createStorageDriver(env);
19
+ expect(driver).not.toBeNull();
20
+ });
21
+
22
+ it("returns R2 driver by default even with STORAGE_DRIVER unset", () => {
23
+ const env = {
24
+ DB: {},
25
+ R2: { put: vi.fn(), get: vi.fn(), delete: vi.fn() },
26
+ } as unknown as Bindings;
27
+ const driver = createStorageDriver(env);
28
+ expect(driver).not.toBeNull();
29
+ });
30
+
31
+ it("returns null for S3 driver when S3 config is incomplete", () => {
32
+ const env = {
33
+ DB: {},
34
+ STORAGE_DRIVER: "s3",
35
+ S3_ENDPOINT: "https://s3.example.com",
36
+ // Missing S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
37
+ } as unknown as Bindings;
38
+ const driver = createStorageDriver(env);
39
+ expect(driver).toBeNull();
40
+ });
41
+
42
+ it("returns S3 driver when fully configured", () => {
43
+ const env = {
44
+ DB: {},
45
+ STORAGE_DRIVER: "s3",
46
+ S3_ENDPOINT: "https://s3.example.com",
47
+ S3_BUCKET: "my-bucket",
48
+ S3_ACCESS_KEY_ID: "access-key",
49
+ S3_SECRET_ACCESS_KEY: "secret-key",
50
+ S3_REGION: "us-east-1",
51
+ } as unknown as Bindings;
52
+ const driver = createStorageDriver(env);
53
+ expect(driver).not.toBeNull();
54
+ });
55
+
56
+ it("defaults S3_REGION to 'auto' when not set", () => {
57
+ const env = {
58
+ DB: {},
59
+ STORAGE_DRIVER: "s3",
60
+ S3_ENDPOINT: "https://s3.example.com",
61
+ S3_BUCKET: "my-bucket",
62
+ S3_ACCESS_KEY_ID: "access-key",
63
+ S3_SECRET_ACCESS_KEY: "secret-key",
64
+ } as unknown as Bindings;
65
+ // Should not throw - region defaults to "auto"
66
+ const driver = createStorageDriver(env);
67
+ expect(driver).not.toBeNull();
68
+ });
69
+
70
+ it("prefers S3 driver over R2 when STORAGE_DRIVER=s3", () => {
71
+ const env = {
72
+ DB: {},
73
+ R2: { put: vi.fn(), get: vi.fn(), delete: vi.fn() },
74
+ STORAGE_DRIVER: "s3",
75
+ S3_ENDPOINT: "https://s3.example.com",
76
+ S3_BUCKET: "my-bucket",
77
+ S3_ACCESS_KEY_ID: "access-key",
78
+ S3_SECRET_ACCESS_KEY: "secret-key",
79
+ } as unknown as Bindings;
80
+ const driver = createStorageDriver(env);
81
+ expect(driver).not.toBeNull();
82
+ });
83
+ });
84
+
85
+ describe("createR2Driver", () => {
86
+ it("delegates put to R2 bucket", async () => {
87
+ const mockR2 = {
88
+ put: vi.fn().mockResolvedValue(undefined),
89
+ get: vi.fn(),
90
+ delete: vi.fn(),
91
+ } as unknown as R2Bucket;
92
+
93
+ const driver = createR2Driver(mockR2);
94
+ const body = new ReadableStream();
95
+ await driver.put("media/test.jpg", body, { contentType: "image/jpeg" });
96
+
97
+ expect(mockR2.put).toHaveBeenCalledWith("media/test.jpg", body, {
98
+ httpMetadata: { contentType: "image/jpeg" },
99
+ });
100
+ });
101
+
102
+ it("delegates put without contentType", async () => {
103
+ const mockR2 = {
104
+ put: vi.fn().mockResolvedValue(undefined),
105
+ get: vi.fn(),
106
+ delete: vi.fn(),
107
+ } as unknown as R2Bucket;
108
+
109
+ const driver = createR2Driver(mockR2);
110
+ await driver.put("media/test.jpg", new ReadableStream());
111
+
112
+ expect(mockR2.put).toHaveBeenCalledWith(
113
+ "media/test.jpg",
114
+ expect.any(ReadableStream),
115
+ { httpMetadata: undefined },
116
+ );
117
+ });
118
+
119
+ it("delegates get and returns body and contentType", async () => {
120
+ const mockBody = new ReadableStream();
121
+ const mockR2 = {
122
+ put: vi.fn(),
123
+ get: vi.fn().mockResolvedValue({
124
+ body: mockBody,
125
+ httpMetadata: { contentType: "image/jpeg" },
126
+ }),
127
+ delete: vi.fn(),
128
+ } as unknown as R2Bucket;
129
+
130
+ const driver = createR2Driver(mockR2);
131
+ const result = await driver.get("media/test.jpg");
132
+
133
+ expect(result).not.toBeNull();
134
+ expect(result!.body).toBe(mockBody);
135
+ expect(result!.contentType).toBe("image/jpeg");
136
+ });
137
+
138
+ it("returns null when R2 get returns null", async () => {
139
+ const mockR2 = {
140
+ put: vi.fn(),
141
+ get: vi.fn().mockResolvedValue(null),
142
+ delete: vi.fn(),
143
+ } as unknown as R2Bucket;
144
+
145
+ const driver = createR2Driver(mockR2);
146
+ const result = await driver.get("nonexistent");
147
+ expect(result).toBeNull();
148
+ });
149
+
150
+ it("delegates delete to R2 bucket", async () => {
151
+ const mockR2 = {
152
+ put: vi.fn(),
153
+ get: vi.fn(),
154
+ delete: vi.fn().mockResolvedValue(undefined),
155
+ } as unknown as R2Bucket;
156
+
157
+ const driver = createR2Driver(mockR2);
158
+ await driver.delete("media/test.jpg");
159
+
160
+ expect(mockR2.delete).toHaveBeenCalledWith("media/test.jpg");
161
+ });
162
+ });
package/src/lib/image.ts CHANGED
@@ -71,37 +71,67 @@ export function getImageUrl(
71
71
  return `${transformUrl}/${params.join(",")}/${originalUrl}`;
72
72
  }
73
73
 
74
+ /**
75
+ * Returns the appropriate public URL base for a given storage provider.
76
+ *
77
+ * For `"s3"` provider, returns `s3PublicUrl`. For all other providers
78
+ * (including `"r2"`), returns `r2PublicUrl`. Falls back to `undefined`
79
+ * if the matching URL is not configured.
80
+ *
81
+ * @param provider - The storage provider identifier (e.g., `"r2"`, `"s3"`)
82
+ * @param r2PublicUrl - Optional R2 public URL
83
+ * @param s3PublicUrl - Optional S3 public URL
84
+ * @returns The public URL base for the provider, or undefined
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * getPublicUrlForProvider("r2", "https://r2.example.com", "https://s3.example.com");
89
+ * // Returns: "https://r2.example.com"
90
+ *
91
+ * getPublicUrlForProvider("s3", "https://r2.example.com", "https://s3.example.com");
92
+ * // Returns: "https://s3.example.com"
93
+ * ```
94
+ */
95
+ export function getPublicUrlForProvider(
96
+ provider: string,
97
+ r2PublicUrl?: string,
98
+ s3PublicUrl?: string,
99
+ ): string | undefined {
100
+ if (provider === "s3") return s3PublicUrl;
101
+ return r2PublicUrl;
102
+ }
103
+
74
104
  /**
75
105
  * Generates a media URL using UUIDv7-based paths.
76
106
  *
77
- * Returns a public URL for a media file. If `r2PublicUrl` is set, uses that directly
78
- * with the r2Key. Otherwise, generates a `/media/{id}.{ext}` URL.
107
+ * Returns a public URL for a media file. If `publicUrl` is set, uses that directly
108
+ * with the storage key. Otherwise, generates a `/media/{id}.{ext}` local proxy URL.
79
109
  *
80
110
  * @param mediaId - The UUIDv7 database ID of the media
81
- * @param r2Key - The R2 storage key (used to extract extension)
82
- * @param r2PublicUrl - Optional R2 public URL for direct CDN access
111
+ * @param storageKey - The storage object key (used to build CDN path and extract extension)
112
+ * @param publicUrl - Optional public URL base for direct CDN access
83
113
  * @returns The public URL for the media file
84
114
  *
85
115
  * @example
86
116
  * ```ts
87
- * // Without R2 public URL - uses UUID with extension
88
- * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "media/2025/01/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp");
89
- * // Returns: "/media/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp"
117
+ * // Without public URL - uses local proxy with UUID and extension
118
+ * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp");
119
+ * // Returns: "/media/01902a9f-1a2b-7c3d.webp"
90
120
  *
91
- * // With R2 public URL - uses direct CDN
92
- * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "media/2025/01/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp", "https://cdn.example.com");
93
- * // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp"
121
+ * // With public URL - uses direct CDN
122
+ * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp", "https://cdn.example.com");
123
+ * // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d.webp"
94
124
  * ```
95
125
  */
96
126
  export function getMediaUrl(
97
127
  mediaId: string,
98
- r2Key: string,
99
- r2PublicUrl?: string,
128
+ storageKey: string,
129
+ publicUrl?: string,
100
130
  ): string {
101
- if (r2PublicUrl) {
102
- return `${r2PublicUrl}/${r2Key}`;
131
+ if (publicUrl) {
132
+ return `${publicUrl.replace(/\/+$/, "")}/${storageKey}`;
103
133
  }
104
- // Extract extension from r2Key
105
- const ext = r2Key.split(".").pop() || "bin";
134
+ // Extract extension from storage key
135
+ const ext = storageKey.split(".").pop() || "bin";
106
136
  return `/media/${mediaId}.${ext}`;
107
137
  }
@@ -5,49 +5,60 @@
5
5
  */
6
6
 
7
7
  import type { Media, MediaAttachment } from "../types.js";
8
- import { getMediaUrl, getImageUrl } from "./image.js";
8
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
9
9
 
10
10
  /**
11
11
  * Builds a map of post IDs to MediaAttachment arrays from raw media data.
12
12
  *
13
- * Transforms raw Media objects (with R2 keys) into MediaAttachment objects
13
+ * Transforms raw Media objects (with storage keys) into MediaAttachment objects
14
14
  * (with public URLs and preview URLs) suitable for rendering.
15
+ * Automatically resolves the correct public URL based on each media item's
16
+ * storage provider (`"r2"` or `"s3"`).
15
17
  *
16
18
  * @param rawMediaMap - Map of post IDs to raw Media arrays from the media service
17
19
  * @param r2PublicUrl - Optional R2 public URL for direct CDN access
18
20
  * @param imageTransformUrl - Optional image transformation service URL
21
+ * @param s3PublicUrl - Optional S3 public URL for direct CDN access
19
22
  * @returns Map of post IDs to MediaAttachment arrays
20
23
  *
21
24
  * @example
22
25
  * ```ts
23
26
  * const rawMediaMap = await services.media.getByPostIds(postIds);
24
- * const mediaMap = buildMediaMap(rawMediaMap, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL);
27
+ * const mediaMap = buildMediaMap(rawMediaMap, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL, c.env.S3_PUBLIC_URL);
25
28
  * ```
26
29
  */
27
30
  export function buildMediaMap(
28
31
  rawMediaMap: Map<number, Media[]>,
29
32
  r2PublicUrl?: string,
30
33
  imageTransformUrl?: string,
34
+ s3PublicUrl?: string,
31
35
  ): Map<number, MediaAttachment[]> {
32
36
  const mediaMap = new Map<number, MediaAttachment[]>();
33
37
  for (const [postId, mediaList] of rawMediaMap) {
34
38
  mediaMap.set(
35
39
  postId,
36
- mediaList.map((m) => ({
37
- id: m.id,
38
- url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
39
- previewUrl: getImageUrl(
40
- getMediaUrl(m.id, m.r2Key, r2PublicUrl),
41
- imageTransformUrl,
42
- { width: 400, quality: 80, format: "auto", fit: "cover" },
43
- ),
44
- alt: m.alt,
45
- blurhash: m.blurhash,
46
- width: m.width,
47
- height: m.height,
48
- position: m.position,
49
- mimeType: m.mimeType,
50
- })),
40
+ mediaList.map((m) => {
41
+ const publicUrl = getPublicUrlForProvider(
42
+ m.provider,
43
+ r2PublicUrl,
44
+ s3PublicUrl,
45
+ );
46
+ return {
47
+ id: m.id,
48
+ url: getMediaUrl(m.id, m.storageKey, publicUrl),
49
+ previewUrl: getImageUrl(
50
+ getMediaUrl(m.id, m.storageKey, publicUrl),
51
+ imageTransformUrl,
52
+ { width: 400, quality: 80, format: "auto", fit: "cover" },
53
+ ),
54
+ alt: m.alt,
55
+ blurhash: m.blurhash,
56
+ width: m.width,
57
+ height: m.height,
58
+ position: m.position,
59
+ mimeType: m.mimeType,
60
+ };
61
+ }),
51
62
  );
52
63
  }
53
64
  return mediaMap;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Storage Driver Abstraction
3
+ *
4
+ * Provides a common interface for file storage with R2 and S3-compatible backends.
5
+ */
6
+
7
+ import type { Bindings } from "../types.js";
8
+
9
+ /**
10
+ * Common interface for storage operations.
11
+ *
12
+ * Both R2 and S3-compatible drivers implement this interface,
13
+ * allowing the rest of the application to be storage-agnostic.
14
+ */
15
+ export interface StorageDriver {
16
+ /** Upload a file to storage */
17
+ put(
18
+ key: string,
19
+ body: ReadableStream | Uint8Array,
20
+ opts?: { contentType?: string },
21
+ ): Promise<void>;
22
+
23
+ /** Retrieve a file from storage. Returns null if not found. */
24
+ get(
25
+ key: string,
26
+ ): Promise<{ body: ReadableStream; contentType?: string } | null>;
27
+
28
+ /** Delete a file from storage */
29
+ delete(key: string): Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * Creates an R2 storage driver that delegates to a Cloudflare R2 bucket binding.
34
+ *
35
+ * @param r2 - The R2 bucket binding from the Cloudflare Workers environment
36
+ * @returns A StorageDriver backed by R2
37
+ */
38
+ export function createR2Driver(r2: R2Bucket): StorageDriver {
39
+ return {
40
+ async put(key, body, opts) {
41
+ await r2.put(key, body, {
42
+ httpMetadata: opts?.contentType
43
+ ? { contentType: opts.contentType }
44
+ : undefined,
45
+ });
46
+ },
47
+
48
+ async get(key) {
49
+ const object = await r2.get(key);
50
+ if (!object) return null;
51
+ return {
52
+ body: object.body,
53
+ contentType: object.httpMetadata?.contentType ?? undefined,
54
+ };
55
+ },
56
+
57
+ async delete(key) {
58
+ await r2.delete(key);
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Configuration for the S3-compatible storage driver.
65
+ */
66
+ export interface S3DriverConfig {
67
+ endpoint: string;
68
+ bucket: string;
69
+ accessKeyId: string;
70
+ secretAccessKey: string;
71
+ region: string;
72
+ }
73
+
74
+ /**
75
+ * Creates an S3-compatible storage driver using the AWS SDK.
76
+ *
77
+ * Supports any S3-compatible service: AWS S3, Backblaze B2, MinIO, etc.
78
+ * Uses path-style addressing for non-AWS endpoints.
79
+ *
80
+ * @param config - S3 connection configuration
81
+ * @returns A StorageDriver backed by S3
82
+ */
83
+ export function createS3Driver(config: S3DriverConfig): StorageDriver {
84
+ // Lazy-load the AWS SDK to avoid bundling it when using R2
85
+ let clientPromise: Promise<{
86
+ send: (command: unknown) => Promise<unknown>;
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
88
+ S3Client: any;
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
90
+ PutObjectCommand: any;
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
92
+ GetObjectCommand: any;
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
94
+ DeleteObjectCommand: any;
95
+ bucket: string;
96
+ }> | null = null;
97
+
98
+ function getClient() {
99
+ if (!clientPromise) {
100
+ clientPromise = import("@aws-sdk/client-s3").then((sdk) => {
101
+ const forcePathStyle = !config.endpoint.includes("amazonaws.com");
102
+ const client = new sdk.S3Client({
103
+ endpoint: config.endpoint,
104
+ region: config.region,
105
+ credentials: {
106
+ accessKeyId: config.accessKeyId,
107
+ secretAccessKey: config.secretAccessKey,
108
+ },
109
+ forcePathStyle,
110
+ });
111
+ return {
112
+ send: (cmd: unknown) => client.send(cmd as never),
113
+ S3Client: sdk.S3Client,
114
+ PutObjectCommand: sdk.PutObjectCommand,
115
+ GetObjectCommand: sdk.GetObjectCommand,
116
+ DeleteObjectCommand: sdk.DeleteObjectCommand,
117
+ bucket: config.bucket,
118
+ };
119
+ });
120
+ }
121
+ return clientPromise;
122
+ }
123
+
124
+ return {
125
+ async put(key, body, opts) {
126
+ const s3 = await getClient();
127
+
128
+ // Buffer the stream to Uint8Array for the S3 SDK
129
+ let bodyBytes: Uint8Array;
130
+ if (body instanceof Uint8Array) {
131
+ bodyBytes = body;
132
+ } else {
133
+ const reader = body.getReader();
134
+ const chunks: Uint8Array[] = [];
135
+ for (;;) {
136
+ const { done, value } = await reader.read();
137
+ if (done) break;
138
+ chunks.push(value);
139
+ }
140
+ let totalLength = 0;
141
+ for (const chunk of chunks) totalLength += chunk.length;
142
+ bodyBytes = new Uint8Array(totalLength);
143
+ let offset = 0;
144
+ for (const chunk of chunks) {
145
+ bodyBytes.set(chunk, offset);
146
+ offset += chunk.length;
147
+ }
148
+ }
149
+
150
+ const command = new s3.PutObjectCommand({
151
+ Bucket: s3.bucket,
152
+ Key: key,
153
+ Body: bodyBytes,
154
+ ContentType: opts?.contentType,
155
+ });
156
+ await s3.send(command);
157
+ },
158
+
159
+ async get(key) {
160
+ const s3 = await getClient();
161
+ try {
162
+ const command = new s3.GetObjectCommand({
163
+ Bucket: s3.bucket,
164
+ Key: key,
165
+ });
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- AWS SDK response type
167
+ const response = (await s3.send(command)) as any;
168
+ if (!response.Body) return null;
169
+ return {
170
+ body: response.Body.transformToWebStream() as ReadableStream,
171
+ contentType: response.ContentType ?? undefined,
172
+ };
173
+ } catch (err: unknown) {
174
+ // NoSuchKey → return null instead of throwing
175
+ if (
176
+ err instanceof Error &&
177
+ (err.name === "NoSuchKey" || err.name === "NotFound")
178
+ ) {
179
+ return null;
180
+ }
181
+ throw err;
182
+ }
183
+ },
184
+
185
+ async delete(key) {
186
+ const s3 = await getClient();
187
+ const command = new s3.DeleteObjectCommand({
188
+ Bucket: s3.bucket,
189
+ Key: key,
190
+ });
191
+ await s3.send(command);
192
+ },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Creates the appropriate storage driver based on environment configuration.
198
+ *
199
+ * Returns `null` if no storage is configured (no R2 binding and no S3 config).
200
+ *
201
+ * @param env - The Cloudflare Workers environment bindings
202
+ * @returns A StorageDriver instance or null
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * const storage = createStorageDriver(c.env);
207
+ * if (storage) {
208
+ * await storage.put("media/file.jpg", stream, { contentType: "image/jpeg" });
209
+ * }
210
+ * ```
211
+ */
212
+ export function createStorageDriver(env: Bindings): StorageDriver | null {
213
+ const driver = env.STORAGE_DRIVER || "r2";
214
+
215
+ if (driver === "s3") {
216
+ if (
217
+ !env.S3_ENDPOINT ||
218
+ !env.S3_BUCKET ||
219
+ !env.S3_ACCESS_KEY_ID ||
220
+ !env.S3_SECRET_ACCESS_KEY
221
+ ) {
222
+ return null;
223
+ }
224
+ return createS3Driver({
225
+ endpoint: env.S3_ENDPOINT,
226
+ bucket: env.S3_BUCKET,
227
+ accessKeyId: env.S3_ACCESS_KEY_ID,
228
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
229
+ region: env.S3_REGION || "auto",
230
+ });
231
+ }
232
+
233
+ // Default: R2
234
+ if (!env.R2) return null;
235
+ return createR2Driver(env.R2);
236
+ }
@@ -50,7 +50,7 @@ describe("Posts API Routes", () => {
50
50
  originalName: "test.jpg",
51
51
  mimeType: "image/jpeg",
52
52
  size: 1024,
53
- r2Key: "media/2025/01/test.jpg",
53
+ storageKey: "media/2025/01/test.jpg",
54
54
  width: 800,
55
55
  height: 600,
56
56
  });
@@ -145,7 +145,7 @@ describe("Posts API Routes", () => {
145
145
  originalName: "test.jpg",
146
146
  mimeType: "image/jpeg",
147
147
  size: 1024,
148
- r2Key: "media/2025/01/test.jpg",
148
+ storageKey: "media/2025/01/test.jpg",
149
149
  });
150
150
 
151
151
  await services.media.attachToPost(post.id, [media.id]);
@@ -223,14 +223,14 @@ describe("Posts API Routes", () => {
223
223
  originalName: "a.jpg",
224
224
  mimeType: "image/jpeg",
225
225
  size: 1024,
226
- r2Key: "media/2025/01/a.jpg",
226
+ storageKey: "media/2025/01/a.jpg",
227
227
  });
228
228
  const m2 = await services.media.create({
229
229
  filename: "b.jpg",
230
230
  originalName: "b.jpg",
231
231
  mimeType: "image/jpeg",
232
232
  size: 2048,
233
- r2Key: "media/2025/01/b.jpg",
233
+ storageKey: "media/2025/01/b.jpg",
234
234
  });
235
235
 
236
236
  const res = await app.request("/api/posts", {
@@ -282,7 +282,7 @@ describe("Posts API Routes", () => {
282
282
  originalName: "a.jpg",
283
283
  mimeType: "image/jpeg",
284
284
  size: 1024,
285
- r2Key: "media/2025/01/a.jpg",
285
+ storageKey: "media/2025/01/a.jpg",
286
286
  });
287
287
 
288
288
  const res = await app.request("/api/posts", {
@@ -404,7 +404,7 @@ describe("Posts API Routes", () => {
404
404
  originalName: "a.jpg",
405
405
  mimeType: "image/jpeg",
406
406
  size: 1024,
407
- r2Key: "media/2025/01/a.jpg",
407
+ storageKey: "media/2025/01/a.jpg",
408
408
  });
409
409
 
410
410
  await services.media.attachToPost(post.id, [m1.id]);
@@ -414,7 +414,7 @@ describe("Posts API Routes", () => {
414
414
  originalName: "b.jpg",
415
415
  mimeType: "image/jpeg",
416
416
  size: 2048,
417
- r2Key: "media/2025/01/b.jpg",
417
+ storageKey: "media/2025/01/b.jpg",
418
418
  });
419
419
 
420
420
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
@@ -443,7 +443,7 @@ describe("Posts API Routes", () => {
443
443
  originalName: "a.jpg",
444
444
  mimeType: "image/jpeg",
445
445
  size: 1024,
446
- r2Key: "media/2025/01/a.jpg",
446
+ storageKey: "media/2025/01/a.jpg",
447
447
  });
448
448
 
449
449
  await services.media.attachToPost(post.id, [m1.id]);