@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
@@ -12,7 +12,11 @@ import {
12
12
  validateMediaForPostType,
13
13
  } from "../../lib/schemas.js";
14
14
  import { requireAuthApi } from "../../middleware/auth.js";
15
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
15
+ import {
16
+ getMediaUrl,
17
+ getImageUrl,
18
+ getPublicUrlForProvider,
19
+ } from "../../lib/image.js";
16
20
 
17
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
18
22
 
@@ -25,8 +29,14 @@ function toMediaAttachment(
25
29
  m: Media,
26
30
  r2PublicUrl?: string,
27
31
  imageTransformUrl?: string,
32
+ s3PublicUrl?: string,
28
33
  ) {
29
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
34
+ const publicUrl = getPublicUrlForProvider(
35
+ m.provider,
36
+ r2PublicUrl,
37
+ s3PublicUrl,
38
+ );
39
+ const url = getMediaUrl(m.id, m.storageKey, publicUrl);
30
40
  const previewUrl = getImageUrl(url, imageTransformUrl, {
31
41
  width: 400,
32
42
  quality: 80,
@@ -66,13 +76,14 @@ postsApiRoutes.get("/", async (c) => {
66
76
  const mediaMap = await c.var.services.media.getByPostIds(postIds);
67
77
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
68
78
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
79
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
69
80
 
70
81
  return c.json({
71
82
  posts: posts.map((p) => ({
72
83
  ...p,
73
84
  sqid: sqid.encode(p.id),
74
85
  mediaAttachments: (mediaMap.get(p.id) ?? []).map((m) =>
75
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
86
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
76
87
  ),
77
88
  })),
78
89
 
@@ -94,12 +105,13 @@ postsApiRoutes.get("/:id", async (c) => {
94
105
  const mediaList = await c.var.services.media.getByPostId(post.id);
95
106
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
96
107
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
108
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
97
109
 
98
110
  return c.json({
99
111
  ...post,
100
112
  sqid: sqid.encode(post.id),
101
113
  mediaAttachments: mediaList.map((m) =>
102
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
114
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
103
115
  ),
104
116
  });
105
117
  });
@@ -157,13 +169,14 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
157
169
  const mediaList = await c.var.services.media.getByPostId(post.id);
158
170
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
159
171
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
172
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
160
173
 
161
174
  return c.json(
162
175
  {
163
176
  ...post,
164
177
  sqid: sqid.encode(post.id),
165
178
  mediaAttachments: mediaList.map((m) =>
166
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
179
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
167
180
  ),
168
181
  },
169
182
  201,
@@ -233,12 +246,13 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
233
246
  const mediaList = await c.var.services.media.getByPostId(post.id);
234
247
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
235
248
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
249
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
236
250
 
237
251
  return c.json({
238
252
  ...post,
239
253
  sqid: sqid.encode(post.id),
240
254
  mediaAttachments: mediaList.map((m) =>
241
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
255
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
242
256
  ),
243
257
  });
244
258
  });
@@ -49,7 +49,13 @@ timelineApiRoutes.get("/", async (c) => {
49
49
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
50
50
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
51
51
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
52
- const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
52
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
53
+ const mediaMap = buildMediaMap(
54
+ rawMediaMap,
55
+ r2PublicUrl,
56
+ imageTransformUrl,
57
+ s3PublicUrl,
58
+ );
53
59
 
54
60
  // Get reply counts to identify thread roots
55
61
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
@@ -74,6 +80,7 @@ timelineApiRoutes.get("/", async (c) => {
74
80
  await c.var.services.media.getByPostIds(previewReplyIds),
75
81
  r2PublicUrl,
76
82
  imageTransformUrl,
83
+ s3PublicUrl,
77
84
  )
78
85
  : new Map();
79
86
 
@@ -11,7 +11,11 @@ import { uuidv7 } from "uuidv7";
11
11
  import type { Bindings } from "../../types.js";
12
12
  import type { AppVariables } from "../../app.js";
13
13
  import { requireAuthApi } from "../../middleware/auth.js";
14
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
14
+ import {
15
+ getMediaUrl,
16
+ getImageUrl,
17
+ getPublicUrlForProvider,
18
+ } from "../../lib/image.js";
15
19
  import { sse, dsSignals } from "../../lib/sse.js";
16
20
 
17
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -27,16 +31,16 @@ uploadApiRoutes.use("*", requireAuthApi());
27
31
  function renderMediaCard(
28
32
  media: {
29
33
  id: string;
30
- r2Key: string;
34
+ storageKey: string;
31
35
  mimeType: string;
32
36
  originalName: string;
33
37
  alt: string | null;
34
38
  size: number;
35
39
  },
36
- r2PublicUrl?: string,
40
+ publicUrl?: string,
37
41
  imageTransformUrl?: string,
38
42
  ): string {
39
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
43
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
40
44
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
41
45
  width: 300,
42
46
  quality: 80,
@@ -116,11 +120,12 @@ function wantsSSE(c: {
116
120
 
117
121
  // Upload a file
118
122
  uploadApiRoutes.post("/", async (c) => {
119
- if (!c.env.R2) {
123
+ const storage = c.var.storage;
124
+ if (!storage) {
120
125
  if (wantsSSE(c)) {
121
- return dsSignals({ _uploadError: "R2 storage not configured" });
126
+ return dsSignals({ _uploadError: "Storage not configured" });
122
127
  }
123
- return c.json({ error: "R2 storage not configured" }, 500);
128
+ return c.json({ error: "Storage not configured" }, 500);
124
129
  }
125
130
 
126
131
  const formData = await c.req.formData();
@@ -164,14 +169,12 @@ uploadApiRoutes.post("/", async (c) => {
164
169
  const year = date.getUTCFullYear();
165
170
  const month = String(date.getUTCMonth() + 1).padStart(2, "0");
166
171
  const filename = `${id}.${ext}`;
167
- const r2Key = `media/${year}/${month}/${filename}`;
172
+ const storageKey = `media/${year}/${month}/${filename}`;
168
173
 
169
174
  try {
170
- // Upload to R2
171
- await c.env.R2.put(r2Key, file.stream(), {
172
- httpMetadata: {
173
- contentType: file.type,
174
- },
175
+ // Upload to storage
176
+ await storage.put(storageKey, file.stream(), {
177
+ contentType: file.type,
175
178
  });
176
179
 
177
180
  // Save to database
@@ -181,14 +184,21 @@ uploadApiRoutes.post("/", async (c) => {
181
184
  originalName: file.name,
182
185
  mimeType: file.type,
183
186
  size: file.size,
184
- r2Key,
187
+ storageKey,
188
+ provider: c.env.STORAGE_DRIVER || "r2",
185
189
  });
186
190
 
187
191
  // SSE response for Datastar
188
192
  if (wantsSSE(c)) {
193
+ const provider = c.env.STORAGE_DRIVER || "r2";
194
+ const mediaPublicUrl = getPublicUrlForProvider(
195
+ provider,
196
+ c.env.R2_PUBLIC_URL,
197
+ c.env.S3_PUBLIC_URL,
198
+ );
189
199
  const cardHtml = renderMediaCard(
190
200
  media,
191
- c.env.R2_PUBLIC_URL,
201
+ mediaPublicUrl,
192
202
  c.env.IMAGE_TRANSFORM_URL,
193
203
  );
194
204
 
@@ -203,7 +213,13 @@ uploadApiRoutes.post("/", async (c) => {
203
213
  }
204
214
 
205
215
  // JSON response for API clients
206
- const publicUrl = getMediaUrl(media.id, r2Key, c.env.R2_PUBLIC_URL);
216
+ const provider = c.env.STORAGE_DRIVER || "r2";
217
+ const mediaPublicUrl = getPublicUrlForProvider(
218
+ provider,
219
+ c.env.R2_PUBLIC_URL,
220
+ c.env.S3_PUBLIC_URL,
221
+ );
222
+ const publicUrl = getMediaUrl(media.id, storageKey, mediaPublicUrl);
207
223
  return c.json({
208
224
  id: media.id,
209
225
  filename: media.filename,
@@ -229,12 +245,18 @@ uploadApiRoutes.post("/", async (c) => {
229
245
  uploadApiRoutes.get("/", async (c) => {
230
246
  const limit = parseInt(c.req.query("limit") ?? "50", 10);
231
247
  const mediaList = await c.var.services.media.list(limit);
248
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
249
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
232
250
 
233
251
  return c.json({
234
252
  media: mediaList.map((m) => ({
235
253
  id: m.id,
236
254
  filename: m.filename,
237
- url: getMediaUrl(m.id, m.r2Key, c.env.R2_PUBLIC_URL),
255
+ url: getMediaUrl(
256
+ m.id,
257
+ m.storageKey,
258
+ getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl),
259
+ ),
238
260
  mimeType: m.mimeType,
239
261
  size: m.size,
240
262
  createdAt: m.createdAt,
@@ -250,13 +272,14 @@ uploadApiRoutes.delete("/:id", async (c) => {
250
272
  return c.json({ error: "Not found" }, 404);
251
273
  }
252
274
 
253
- // Delete from R2
254
- if (c.env.R2) {
275
+ // Delete from storage
276
+ const storage = c.var.storage;
277
+ if (storage) {
255
278
  try {
256
- await c.env.R2.delete(media.r2Key);
279
+ await storage.delete(media.storageKey);
257
280
  } catch (err) {
258
281
  // eslint-disable-next-line no-console -- Error logging is intentional
259
- console.error("R2 delete error:", err);
282
+ console.error("Storage delete error:", err);
260
283
  }
261
284
  }
262
285
 
@@ -13,7 +13,11 @@ import type { AppVariables } from "../../app.js";
13
13
  import { DashLayout } from "../../theme/layouts/index.js";
14
14
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
15
15
  import * as time from "../../lib/time.js";
16
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
16
+ import {
17
+ getMediaUrl,
18
+ getImageUrl,
19
+ getPublicUrlForProvider,
20
+ } from "../../lib/image.js";
17
21
  import { dsRedirect } from "../../lib/sse.js";
18
22
 
19
23
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -36,12 +40,19 @@ function MediaCard({
36
40
  media,
37
41
  r2PublicUrl,
38
42
  imageTransformUrl,
43
+ s3PublicUrl,
39
44
  }: {
40
45
  media: Media;
41
46
  r2PublicUrl?: string;
42
47
  imageTransformUrl?: string;
48
+ s3PublicUrl?: string;
43
49
  }) {
44
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
50
+ const publicUrl = getPublicUrlForProvider(
51
+ media.provider,
52
+ r2PublicUrl,
53
+ s3PublicUrl,
54
+ );
55
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
45
56
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
46
57
  width: 300,
47
58
  quality: 80,
@@ -96,10 +107,12 @@ function MediaListContent({
96
107
  mediaList,
97
108
  r2PublicUrl,
98
109
  imageTransformUrl,
110
+ s3PublicUrl,
99
111
  }: {
100
112
  mediaList: Media[];
101
113
  r2PublicUrl?: string;
102
114
  imageTransformUrl?: string;
115
+ s3PublicUrl?: string;
103
116
  }) {
104
117
  const { t } = useLingui();
105
118
 
@@ -187,6 +200,7 @@ function MediaListContent({
187
200
  media={m}
188
201
  r2PublicUrl={r2PublicUrl}
189
202
  imageTransformUrl={imageTransformUrl}
203
+ s3PublicUrl={s3PublicUrl}
190
204
  />
191
205
  ))}
192
206
  </div>
@@ -217,13 +231,20 @@ function ViewMediaContent({
217
231
  media,
218
232
  r2PublicUrl,
219
233
  imageTransformUrl,
234
+ s3PublicUrl,
220
235
  }: {
221
236
  media: Media;
222
237
  r2PublicUrl?: string;
223
238
  imageTransformUrl?: string;
239
+ s3PublicUrl?: string;
224
240
  }) {
225
241
  const { t } = useLingui();
226
- const url = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
242
+ const publicUrl = getPublicUrlForProvider(
243
+ media.provider,
244
+ r2PublicUrl,
245
+ s3PublicUrl,
246
+ );
247
+ const url = getMediaUrl(media.id, media.storageKey, publicUrl);
227
248
  const thumbnailUrl = getImageUrl(url, imageTransformUrl, {
228
249
  width: 600,
229
250
  quality: 85,
@@ -401,6 +422,7 @@ mediaRoutes.get("/", async (c) => {
401
422
  const siteName = await getSiteName(c);
402
423
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
403
424
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
425
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
404
426
 
405
427
  return c.html(
406
428
  <DashLayout
@@ -413,6 +435,7 @@ mediaRoutes.get("/", async (c) => {
413
435
  mediaList={mediaList}
414
436
  r2PublicUrl={r2PublicUrl}
415
437
  imageTransformUrl={imageTransformUrl}
438
+ s3PublicUrl={s3PublicUrl}
416
439
  />
417
440
  </DashLayout>,
418
441
  );
@@ -424,6 +447,7 @@ mediaRoutes.get("/picker", async (c) => {
424
447
  const mediaList = await c.var.services.media.list(100);
425
448
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
426
449
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
450
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
427
451
 
428
452
  if (mediaList.length === 0) {
429
453
  return c.html(
@@ -438,7 +462,12 @@ mediaRoutes.get("/picker", async (c) => {
438
462
  {mediaList
439
463
  .filter((m) => m.mimeType.startsWith("image/"))
440
464
  .map((m) => {
441
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
465
+ const pUrl = getPublicUrlForProvider(
466
+ m.provider,
467
+ r2PublicUrl,
468
+ s3PublicUrl,
469
+ );
470
+ const url = getMediaUrl(m.id, m.storageKey, pUrl);
442
471
  const thumbUrl = getImageUrl(url, imageTransformUrl, {
443
472
  width: 150,
444
473
  quality: 80,
@@ -477,6 +506,7 @@ mediaRoutes.get("/:id", async (c) => {
477
506
  const siteName = await getSiteName(c);
478
507
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
479
508
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
509
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
480
510
 
481
511
  return c.html(
482
512
  <DashLayout
@@ -489,6 +519,7 @@ mediaRoutes.get("/:id", async (c) => {
489
519
  media={media}
490
520
  r2PublicUrl={r2PublicUrl}
491
521
  imageTransformUrl={imageTransformUrl}
522
+ s3PublicUrl={s3PublicUrl}
492
523
  />
493
524
  </DashLayout>,
494
525
  );
@@ -500,13 +531,14 @@ mediaRoutes.post("/:id/delete", async (c) => {
500
531
  const media = await c.var.services.media.getById(id);
501
532
  if (!media) return c.notFound();
502
533
 
503
- // Delete from R2
504
- if (c.env.R2) {
534
+ // Delete from storage
535
+ const storage = c.var.storage;
536
+ if (storage) {
505
537
  try {
506
- await c.env.R2.delete(media.r2Key);
538
+ await storage.delete(media.storageKey);
507
539
  } catch (err) {
508
540
  // eslint-disable-next-line no-console -- Error logging is intentional
509
- console.error("R2 delete error:", err);
541
+ console.error("Storage delete error:", err);
510
542
  }
511
543
  }
512
544
 
@@ -168,6 +168,7 @@ function EditPostContent({
168
168
  mediaAttachments,
169
169
  r2PublicUrl,
170
170
  imageTransformUrl,
171
+ s3PublicUrl,
171
172
  collections,
172
173
  postCollectionIds,
173
174
  }: {
@@ -175,6 +176,7 @@ function EditPostContent({
175
176
  mediaAttachments: Media[];
176
177
  r2PublicUrl?: string;
177
178
  imageTransformUrl?: string;
179
+ s3PublicUrl?: string;
178
180
  collections: Collection[];
179
181
  postCollectionIds: number[];
180
182
  }) {
@@ -190,6 +192,7 @@ function EditPostContent({
190
192
  mediaAttachments={mediaAttachments}
191
193
  r2PublicUrl={r2PublicUrl}
192
194
  imageTransformUrl={imageTransformUrl}
195
+ s3PublicUrl={s3PublicUrl}
193
196
  collections={collections}
194
197
  postCollectionIds={postCollectionIds}
195
198
  />
@@ -232,6 +235,7 @@ postsRoutes.get("/:id/edit", async (c) => {
232
235
  const mediaAttachments = await c.var.services.media.getByPostId(post.id);
233
236
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
234
237
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
238
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
235
239
  const collections = await c.var.services.collections.list();
236
240
  const postCollections =
237
241
  await c.var.services.collections.getCollectionsForPost(post.id);
@@ -249,6 +253,7 @@ postsRoutes.get("/:id/edit", async (c) => {
249
253
  mediaAttachments={mediaAttachments}
250
254
  r2PublicUrl={r2PublicUrl}
251
255
  imageTransformUrl={imageTransformUrl}
256
+ s3PublicUrl={s3PublicUrl}
252
257
  collections={collections}
253
258
  postCollectionIds={postCollectionIds}
254
259
  />
@@ -7,7 +7,7 @@ import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
9
  import * as time from "../../lib/time.js";
10
- import { getMediaUrl } from "../../lib/image.js";
10
+ import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
11
11
 
12
12
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
13
 
@@ -20,6 +20,7 @@ rssRoutes.get("/", async (c) => {
20
20
  const siteDescription = all["SITE_DESCRIPTION"] ?? "";
21
21
  const siteUrl = c.env.SITE_URL;
22
22
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
23
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
23
24
 
24
25
  const posts = await c.var.services.posts.list({
25
26
  visibility: ["featured", "quiet"],
@@ -40,7 +41,7 @@ rssRoutes.get("/", async (c) => {
40
41
  const postMedia = mediaMap.get(post.id);
41
42
  const firstMedia = postMedia?.[0];
42
43
  const enclosure = firstMedia
43
- ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.r2Key, r2PublicUrl)}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>`
44
+ ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.storageKey, getPublicUrlForProvider(firstMedia.provider, r2PublicUrl, s3PublicUrl))}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>`
44
45
  : "";
45
46
 
46
47
  return `
@@ -70,7 +70,13 @@ homeRoutes.get("/", async (c) => {
70
70
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
71
71
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
72
72
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
73
- const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
73
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
74
+ const mediaMap = buildMediaMap(
75
+ rawMediaMap,
76
+ r2PublicUrl,
77
+ imageTransformUrl,
78
+ s3PublicUrl,
79
+ );
74
80
 
75
81
  // Get reply counts to identify thread roots
76
82
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
@@ -95,6 +101,7 @@ homeRoutes.get("/", async (c) => {
95
101
  await c.var.services.media.getByPostIds(previewReplyIds),
96
102
  r2PublicUrl,
97
103
  imageTransformUrl,
104
+ s3PublicUrl,
98
105
  )
99
106
  : new Map();
100
107
 
@@ -10,7 +10,11 @@ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
10
10
  import { MediaGallery } from "../../theme/components/index.js";
11
11
  import * as sqid from "../../lib/sqid.js";
12
12
  import * as time from "../../lib/time.js";
13
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
13
+ import {
14
+ getMediaUrl,
15
+ getImageUrl,
16
+ getPublicUrlForProvider,
17
+ } from "../../lib/image.js";
14
18
  import { getNavigationData } from "../../lib/navigation.js";
15
19
 
16
20
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -87,22 +91,30 @@ postRoutes.get("/:id", async (c) => {
87
91
  const rawMedia = await c.var.services.media.getByPostId(post.id);
88
92
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
89
93
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
90
-
91
- const mediaAttachments: MediaAttachment[] = rawMedia.map((m) => ({
92
- id: m.id,
93
- url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
94
- previewUrl: getImageUrl(
95
- getMediaUrl(m.id, m.r2Key, r2PublicUrl),
96
- imageTransformUrl,
97
- { width: 400, quality: 80, format: "auto", fit: "cover" },
98
- ),
99
- alt: m.alt,
100
- blurhash: m.blurhash,
101
- width: m.width,
102
- height: m.height,
103
- position: m.position,
104
- mimeType: m.mimeType,
105
- }));
94
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
95
+
96
+ const mediaAttachments: MediaAttachment[] = rawMedia.map((m) => {
97
+ const publicUrl = getPublicUrlForProvider(
98
+ m.provider,
99
+ r2PublicUrl,
100
+ s3PublicUrl,
101
+ );
102
+ return {
103
+ id: m.id,
104
+ url: getMediaUrl(m.id, m.storageKey, publicUrl),
105
+ previewUrl: getImageUrl(
106
+ getMediaUrl(m.id, m.storageKey, publicUrl),
107
+ imageTransformUrl,
108
+ { width: 400, quality: 80, format: "auto", fit: "cover" },
109
+ ),
110
+ alt: m.alt,
111
+ blurhash: m.blurhash,
112
+ width: m.width,
113
+ height: m.height,
114
+ position: m.position,
115
+ mimeType: m.mimeType,
116
+ };
117
+ });
106
118
 
107
119
  const navData = await getNavigationData(c);
108
120
  const title = post.title || navData.siteName;