@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.
- package/dist/app.js +7 -4
- package/dist/db/schema.js +2 -1
- package/dist/lib/image.js +39 -15
- package/dist/lib/media-helpers.js +14 -8
- package/dist/lib/storage.js +164 -0
- package/dist/routes/api/posts.js +12 -7
- package/dist/routes/api/timeline.js +3 -2
- package/dist/routes/api/upload.js +27 -20
- package/dist/routes/dash/media.js +24 -14
- package/dist/routes/dash/posts.js +4 -1
- package/dist/routes/feed/rss.js +3 -2
- package/dist/routes/pages/home.js +3 -2
- package/dist/routes/pages/post.js +9 -5
- package/dist/services/media.js +7 -5
- package/dist/theme/components/PostForm.js +4 -3
- package/dist/types.js +32 -0
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +1 -0
- package/src/__tests__/helpers/db.ts +10 -0
- package/src/app.tsx +8 -7
- package/src/db/migrations/0004_add_storage_provider.sql +3 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +2 -1
- package/src/i18n/locales/en.po +67 -67
- package/src/i18n/locales/zh-Hans.po +67 -67
- package/src/i18n/locales/zh-Hant.po +67 -67
- package/src/lib/__tests__/image.test.ts +96 -0
- package/src/lib/__tests__/storage.test.ts +162 -0
- package/src/lib/image.ts +46 -16
- package/src/lib/media-helpers.ts +29 -18
- package/src/lib/storage.ts +236 -0
- package/src/routes/api/__tests__/posts.test.ts +8 -8
- package/src/routes/api/posts.ts +20 -6
- package/src/routes/api/timeline.tsx +8 -1
- package/src/routes/api/upload.ts +44 -21
- package/src/routes/dash/media.tsx +40 -8
- package/src/routes/dash/posts.tsx +5 -0
- package/src/routes/feed/rss.ts +3 -2
- package/src/routes/pages/home.tsx +8 -1
- package/src/routes/pages/post.tsx +29 -17
- package/src/services/__tests__/media.test.ts +44 -26
- package/src/services/media.ts +10 -7
- package/src/theme/components/PostForm.tsx +13 -2
- 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 `
|
|
78
|
-
* with the
|
|
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
|
|
82
|
-
* @param
|
|
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
|
|
88
|
-
* getMediaUrl("01902a9f-1a2b-7c3d
|
|
89
|
-
* // Returns: "/media/01902a9f-1a2b-7c3d
|
|
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
|
|
92
|
-
* getMediaUrl("01902a9f-1a2b-7c3d
|
|
93
|
-
* // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d
|
|
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
|
-
|
|
99
|
-
|
|
128
|
+
storageKey: string,
|
|
129
|
+
publicUrl?: string,
|
|
100
130
|
): string {
|
|
101
|
-
if (
|
|
102
|
-
return `${
|
|
131
|
+
if (publicUrl) {
|
|
132
|
+
return `${publicUrl.replace(/\/+$/, "")}/${storageKey}`;
|
|
103
133
|
}
|
|
104
|
-
// Extract extension from
|
|
105
|
-
const ext =
|
|
134
|
+
// Extract extension from storage key
|
|
135
|
+
const ext = storageKey.split(".").pop() || "bin";
|
|
106
136
|
return `/media/${mediaId}.${ext}`;
|
|
107
137
|
}
|
package/src/lib/media-helpers.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
+
storageKey: "media/2025/01/a.jpg",
|
|
447
447
|
});
|
|
448
448
|
|
|
449
449
|
await services.media.attachToPost(post.id, [m1.id]);
|