@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
package/dist/app.js CHANGED
@@ -40,6 +40,7 @@ import { requireOnboarding } from "./middleware/onboarding.js";
40
40
  import { BaseLayout } from "./theme/layouts/index.js";
41
41
  import { dsRedirect, dsToast } from "./lib/sse.js";
42
42
  import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
43
+ import { createStorageDriver } from "./lib/storage.js";
43
44
  /**
44
45
  * Create a Jant application
45
46
  *
@@ -72,6 +73,7 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
72
73
  const services = createServices(db, session);
73
74
  c.set("services", services);
74
75
  c.set("config", config);
76
+ c.set("storage", createStorageDriver(c.env));
75
77
  if (c.env.AUTH_SECRET) {
76
78
  const auth = createAuth(session, {
77
79
  secret: c.env.AUTH_SECRET,
@@ -633,9 +635,10 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
633
635
  // API routes
634
636
  app.route("/api/upload", uploadApiRoutes);
635
637
  app.route("/api/search", searchApiRoutes);
636
- // Media files from R2 (UUIDv7-based URLs with extension)
638
+ // Media files from storage (UUIDv7-based URLs with extension)
637
639
  app.get("/media/:idWithExt", async (c)=>{
638
- if (!c.env.R2) {
640
+ const storage = c.var.storage;
641
+ if (!storage) {
639
642
  return c.notFound();
640
643
  }
641
644
  // Extract ID from "uuid.ext" format
@@ -645,12 +648,12 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
645
648
  if (!media) {
646
649
  return c.notFound();
647
650
  }
648
- const object = await c.env.R2.get(media.r2Key);
651
+ const object = await storage.get(media.storageKey);
649
652
  if (!object) {
650
653
  return c.notFound();
651
654
  }
652
655
  const headers = new Headers();
653
- headers.set("Content-Type", object.httpMetadata?.contentType || media.mimeType);
656
+ headers.set("Content-Type", object.contentType || media.mimeType);
654
657
  headers.set("Cache-Control", "public, max-age=31536000, immutable");
655
658
  return new Response(object.body, {
656
659
  headers
package/dist/db/schema.js CHANGED
@@ -52,7 +52,8 @@ export const media = sqliteTable("media", {
52
52
  originalName: text("original_name").notNull(),
53
53
  mimeType: text("mime_type").notNull(),
54
54
  size: integer("size").notNull(),
55
- r2Key: text("r2_key").notNull(),
55
+ storageKey: text("storage_key").notNull(),
56
+ provider: text("provider").notNull().default("r2"),
56
57
  width: integer("width"),
57
58
  height: integer("height"),
58
59
  alt: text("alt"),
package/dist/lib/image.js CHANGED
@@ -46,32 +46,56 @@
46
46
  }
47
47
  return `${transformUrl}/${params.join(",")}/${originalUrl}`;
48
48
  }
49
+ /**
50
+ * Returns the appropriate public URL base for a given storage provider.
51
+ *
52
+ * For `"s3"` provider, returns `s3PublicUrl`. For all other providers
53
+ * (including `"r2"`), returns `r2PublicUrl`. Falls back to `undefined`
54
+ * if the matching URL is not configured.
55
+ *
56
+ * @param provider - The storage provider identifier (e.g., `"r2"`, `"s3"`)
57
+ * @param r2PublicUrl - Optional R2 public URL
58
+ * @param s3PublicUrl - Optional S3 public URL
59
+ * @returns The public URL base for the provider, or undefined
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * getPublicUrlForProvider("r2", "https://r2.example.com", "https://s3.example.com");
64
+ * // Returns: "https://r2.example.com"
65
+ *
66
+ * getPublicUrlForProvider("s3", "https://r2.example.com", "https://s3.example.com");
67
+ * // Returns: "https://s3.example.com"
68
+ * ```
69
+ */ export function getPublicUrlForProvider(provider, r2PublicUrl, s3PublicUrl) {
70
+ if (provider === "s3") return s3PublicUrl;
71
+ return r2PublicUrl;
72
+ }
49
73
  /**
50
74
  * Generates a media URL using UUIDv7-based paths.
51
75
  *
52
- * Returns a public URL for a media file. If `r2PublicUrl` is set, uses that directly
53
- * with the r2Key. Otherwise, generates a `/media/{id}.{ext}` URL.
76
+ * Returns a public URL for a media file. If `publicUrl` is set, uses that directly
77
+ * with the storage key. Otherwise, generates a `/media/{id}.{ext}` local proxy URL.
54
78
  *
55
79
  * @param mediaId - The UUIDv7 database ID of the media
56
- * @param r2Key - The R2 storage key (used to extract extension)
57
- * @param r2PublicUrl - Optional R2 public URL for direct CDN access
80
+ * @param storageKey - The storage object key (used to build CDN path and extract extension)
81
+ * @param publicUrl - Optional public URL base for direct CDN access
58
82
  * @returns The public URL for the media file
59
83
  *
60
84
  * @example
61
85
  * ```ts
62
- * // Without R2 public URL - uses UUID with extension
63
- * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "media/2025/01/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp");
64
- * // Returns: "/media/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp"
86
+ * // Without public URL - uses local proxy with UUID and extension
87
+ * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp");
88
+ * // Returns: "/media/01902a9f-1a2b-7c3d.webp"
65
89
  *
66
- * // With R2 public URL - uses direct CDN
67
- * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "media/2025/01/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp", "https://cdn.example.com");
68
- * // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp"
90
+ * // With public URL - uses direct CDN
91
+ * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp", "https://cdn.example.com");
92
+ * // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d.webp"
69
93
  * ```
70
- */ export function getMediaUrl(mediaId, r2Key, r2PublicUrl) {
71
- if (r2PublicUrl) {
72
- return `${r2PublicUrl}/${r2Key}`;
94
+ */ export function getMediaUrl(mediaId, storageKey, publicUrl) {
95
+ if (publicUrl) {
96
+ return `${publicUrl.replace(/\/+$/, "")}/${storageKey}`;
73
97
  }
74
- // Extract extension from r2Key
75
- const ext = r2Key.split(".").pop() || "bin";
98
+ // Extract extension from storage key
99
+ const ext = storageKey.split(".").pop() || "bin";
76
100
  return `/media/${mediaId}.${ext}`;
77
101
  }
@@ -2,30 +2,35 @@
2
2
  * Media Helper Utilities
3
3
  *
4
4
  * Shared logic for building MediaAttachment maps from raw media data.
5
- */ import { getMediaUrl, getImageUrl } from "./image.js";
5
+ */ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
6
6
  /**
7
7
  * Builds a map of post IDs to MediaAttachment arrays from raw media data.
8
8
  *
9
- * Transforms raw Media objects (with R2 keys) into MediaAttachment objects
9
+ * Transforms raw Media objects (with storage keys) into MediaAttachment objects
10
10
  * (with public URLs and preview URLs) suitable for rendering.
11
+ * Automatically resolves the correct public URL based on each media item's
12
+ * storage provider (`"r2"` or `"s3"`).
11
13
  *
12
14
  * @param rawMediaMap - Map of post IDs to raw Media arrays from the media service
13
15
  * @param r2PublicUrl - Optional R2 public URL for direct CDN access
14
16
  * @param imageTransformUrl - Optional image transformation service URL
17
+ * @param s3PublicUrl - Optional S3 public URL for direct CDN access
15
18
  * @returns Map of post IDs to MediaAttachment arrays
16
19
  *
17
20
  * @example
18
21
  * ```ts
19
22
  * const rawMediaMap = await services.media.getByPostIds(postIds);
20
- * const mediaMap = buildMediaMap(rawMediaMap, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL);
23
+ * const mediaMap = buildMediaMap(rawMediaMap, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL, c.env.S3_PUBLIC_URL);
21
24
  * ```
22
- */ export function buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl) {
25
+ */ export function buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl, s3PublicUrl) {
23
26
  const mediaMap = new Map();
24
27
  for (const [postId, mediaList] of rawMediaMap){
25
- mediaMap.set(postId, mediaList.map((m)=>({
28
+ mediaMap.set(postId, mediaList.map((m)=>{
29
+ const publicUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
30
+ return {
26
31
  id: m.id,
27
- url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
28
- previewUrl: getImageUrl(getMediaUrl(m.id, m.r2Key, r2PublicUrl), imageTransformUrl, {
32
+ url: getMediaUrl(m.id, m.storageKey, publicUrl),
33
+ previewUrl: getImageUrl(getMediaUrl(m.id, m.storageKey, publicUrl), imageTransformUrl, {
29
34
  width: 400,
30
35
  quality: 80,
31
36
  format: "auto",
@@ -37,7 +42,8 @@
37
42
  height: m.height,
38
43
  position: m.position,
39
44
  mimeType: m.mimeType
40
- })));
45
+ };
46
+ }));
41
47
  }
42
48
  return mediaMap;
43
49
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Storage Driver Abstraction
3
+ *
4
+ * Provides a common interface for file storage with R2 and S3-compatible backends.
5
+ */ /**
6
+ * Creates an R2 storage driver that delegates to a Cloudflare R2 bucket binding.
7
+ *
8
+ * @param r2 - The R2 bucket binding from the Cloudflare Workers environment
9
+ * @returns A StorageDriver backed by R2
10
+ */ export function createR2Driver(r2) {
11
+ return {
12
+ async put (key, body, opts) {
13
+ await r2.put(key, body, {
14
+ httpMetadata: opts?.contentType ? {
15
+ contentType: opts.contentType
16
+ } : undefined
17
+ });
18
+ },
19
+ async get (key) {
20
+ const object = await r2.get(key);
21
+ if (!object) return null;
22
+ return {
23
+ body: object.body,
24
+ contentType: object.httpMetadata?.contentType ?? undefined
25
+ };
26
+ },
27
+ async delete (key) {
28
+ await r2.delete(key);
29
+ }
30
+ };
31
+ }
32
+ /**
33
+ * Creates an S3-compatible storage driver using the AWS SDK.
34
+ *
35
+ * Supports any S3-compatible service: AWS S3, Backblaze B2, MinIO, etc.
36
+ * Uses path-style addressing for non-AWS endpoints.
37
+ *
38
+ * @param config - S3 connection configuration
39
+ * @returns A StorageDriver backed by S3
40
+ */ export function createS3Driver(config) {
41
+ // Lazy-load the AWS SDK to avoid bundling it when using R2
42
+ let clientPromise = null;
43
+ function getClient() {
44
+ if (!clientPromise) {
45
+ clientPromise = import("@aws-sdk/client-s3").then((sdk)=>{
46
+ const forcePathStyle = !config.endpoint.includes("amazonaws.com");
47
+ const client = new sdk.S3Client({
48
+ endpoint: config.endpoint,
49
+ region: config.region,
50
+ credentials: {
51
+ accessKeyId: config.accessKeyId,
52
+ secretAccessKey: config.secretAccessKey
53
+ },
54
+ forcePathStyle
55
+ });
56
+ return {
57
+ send: (cmd)=>client.send(cmd),
58
+ S3Client: sdk.S3Client,
59
+ PutObjectCommand: sdk.PutObjectCommand,
60
+ GetObjectCommand: sdk.GetObjectCommand,
61
+ DeleteObjectCommand: sdk.DeleteObjectCommand,
62
+ bucket: config.bucket
63
+ };
64
+ });
65
+ }
66
+ return clientPromise;
67
+ }
68
+ return {
69
+ async put (key, body, opts) {
70
+ const s3 = await getClient();
71
+ // Buffer the stream to Uint8Array for the S3 SDK
72
+ let bodyBytes;
73
+ if (body instanceof Uint8Array) {
74
+ bodyBytes = body;
75
+ } else {
76
+ const reader = body.getReader();
77
+ const chunks = [];
78
+ for(;;){
79
+ const { done, value } = await reader.read();
80
+ if (done) break;
81
+ chunks.push(value);
82
+ }
83
+ let totalLength = 0;
84
+ for (const chunk of chunks)totalLength += chunk.length;
85
+ bodyBytes = new Uint8Array(totalLength);
86
+ let offset = 0;
87
+ for (const chunk of chunks){
88
+ bodyBytes.set(chunk, offset);
89
+ offset += chunk.length;
90
+ }
91
+ }
92
+ const command = new s3.PutObjectCommand({
93
+ Bucket: s3.bucket,
94
+ Key: key,
95
+ Body: bodyBytes,
96
+ ContentType: opts?.contentType
97
+ });
98
+ await s3.send(command);
99
+ },
100
+ async get (key) {
101
+ const s3 = await getClient();
102
+ try {
103
+ const command = new s3.GetObjectCommand({
104
+ Bucket: s3.bucket,
105
+ Key: key
106
+ });
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- AWS SDK response type
108
+ const response = await s3.send(command);
109
+ if (!response.Body) return null;
110
+ return {
111
+ body: response.Body.transformToWebStream(),
112
+ contentType: response.ContentType ?? undefined
113
+ };
114
+ } catch (err) {
115
+ // NoSuchKey → return null instead of throwing
116
+ if (err instanceof Error && (err.name === "NoSuchKey" || err.name === "NotFound")) {
117
+ return null;
118
+ }
119
+ throw err;
120
+ }
121
+ },
122
+ async delete (key) {
123
+ const s3 = await getClient();
124
+ const command = new s3.DeleteObjectCommand({
125
+ Bucket: s3.bucket,
126
+ Key: key
127
+ });
128
+ await s3.send(command);
129
+ }
130
+ };
131
+ }
132
+ /**
133
+ * Creates the appropriate storage driver based on environment configuration.
134
+ *
135
+ * Returns `null` if no storage is configured (no R2 binding and no S3 config).
136
+ *
137
+ * @param env - The Cloudflare Workers environment bindings
138
+ * @returns A StorageDriver instance or null
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * const storage = createStorageDriver(c.env);
143
+ * if (storage) {
144
+ * await storage.put("media/file.jpg", stream, { contentType: "image/jpeg" });
145
+ * }
146
+ * ```
147
+ */ export function createStorageDriver(env) {
148
+ const driver = env.STORAGE_DRIVER || "r2";
149
+ if (driver === "s3") {
150
+ if (!env.S3_ENDPOINT || !env.S3_BUCKET || !env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {
151
+ return null;
152
+ }
153
+ return createS3Driver({
154
+ endpoint: env.S3_ENDPOINT,
155
+ bucket: env.S3_BUCKET,
156
+ accessKeyId: env.S3_ACCESS_KEY_ID,
157
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
158
+ region: env.S3_REGION || "auto"
159
+ });
160
+ }
161
+ // Default: R2
162
+ if (!env.R2) return null;
163
+ return createR2Driver(env.R2);
164
+ }
@@ -4,12 +4,13 @@
4
4
  import * as sqid from "../../lib/sqid.js";
5
5
  import { CreatePostSchema, UpdatePostSchema, validateMediaForPostType } from "../../lib/schemas.js";
6
6
  import { requireAuthApi } from "../../middleware/auth.js";
7
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
7
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
8
8
  export const postsApiRoutes = new Hono();
9
9
  /**
10
10
  * Converts a Media record to a MediaAttachment API response shape.
11
- */ function toMediaAttachment(m, r2PublicUrl, imageTransformUrl) {
12
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
11
+ */ function toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl) {
12
+ const publicUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
13
+ const url = getMediaUrl(m.id, m.storageKey, publicUrl);
13
14
  const previewUrl = getImageUrl(url, imageTransformUrl, {
14
15
  width: 400,
15
16
  quality: 80,
@@ -50,11 +51,12 @@ postsApiRoutes.get("/", async (c)=>{
50
51
  const mediaMap = await c.var.services.media.getByPostIds(postIds);
51
52
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
52
53
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
54
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
53
55
  return c.json({
54
56
  posts: posts.map((p)=>({
55
57
  ...p,
56
58
  sqid: sqid.encode(p.id),
57
- mediaAttachments: (mediaMap.get(p.id) ?? []).map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
59
+ mediaAttachments: (mediaMap.get(p.id) ?? []).map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
58
60
  })),
59
61
  nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]?.id ?? 0) : null
60
62
  });
@@ -72,10 +74,11 @@ postsApiRoutes.get("/:id", async (c)=>{
72
74
  const mediaList = await c.var.services.media.getByPostId(post.id);
73
75
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
74
76
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
77
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
75
78
  return c.json({
76
79
  ...post,
77
80
  sqid: sqid.encode(post.id),
78
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
81
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
79
82
  });
80
83
  });
81
84
  // Create post (requires auth)
@@ -126,10 +129,11 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
126
129
  const mediaList = await c.var.services.media.getByPostId(post.id);
127
130
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
128
131
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
132
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
129
133
  return c.json({
130
134
  ...post,
131
135
  sqid: sqid.encode(post.id),
132
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
136
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
133
137
  }, 201);
134
138
  });
135
139
  // Update post (requires auth)
@@ -195,10 +199,11 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
195
199
  const mediaList = await c.var.services.media.getByPostId(post.id);
196
200
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
197
201
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
202
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
198
203
  return c.json({
199
204
  ...post,
200
205
  sqid: sqid.encode(post.id),
201
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
206
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
202
207
  });
203
208
  });
204
209
  // Delete post (requires auth)
@@ -43,7 +43,8 @@ timelineApiRoutes.get("/", async (c)=>{
43
43
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
44
44
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
45
45
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
46
- const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
46
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
47
+ const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl, s3PublicUrl);
47
48
  // Get reply counts to identify thread roots
48
49
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
49
50
  const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
@@ -56,7 +57,7 @@ timelineApiRoutes.get("/", async (c)=>{
56
57
  previewReplyIds.push(reply.id);
57
58
  }
58
59
  }
59
- const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl) : new Map();
60
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl, s3PublicUrl) : new Map();
60
61
  // Assemble timeline items
61
62
  const items = displayPosts.map((post)=>{
62
63
  const postWithMedia = {
@@ -7,15 +7,15 @@
7
7
  import { html } from "hono/html";
8
8
  import { uuidv7 } from "uuidv7";
9
9
  import { requireAuthApi } from "../../middleware/auth.js";
10
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
10
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
11
11
  import { sse, dsSignals } from "../../lib/sse.js";
12
12
  export const uploadApiRoutes = new Hono();
13
13
  // Require auth for all upload routes
14
14
  uploadApiRoutes.use("*", requireAuthApi());
15
15
  /**
16
16
  * Render a media card HTML string for SSE response
17
- */ function renderMediaCard(media, r2PublicUrl, imageTransformUrl) {
18
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
17
+ */ function renderMediaCard(media, publicUrl, imageTransformUrl) {
18
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
19
19
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
20
20
  width: 300,
21
21
  quality: 80,
@@ -87,14 +87,15 @@ function formatSize(bytes) {
87
87
  }
88
88
  // Upload a file
89
89
  uploadApiRoutes.post("/", async (c)=>{
90
- if (!c.env.R2) {
90
+ const storage = c.var.storage;
91
+ if (!storage) {
91
92
  if (wantsSSE(c)) {
92
93
  return dsSignals({
93
- _uploadError: "R2 storage not configured"
94
+ _uploadError: "Storage not configured"
94
95
  });
95
96
  }
96
97
  return c.json({
97
- error: "R2 storage not configured"
98
+ error: "Storage not configured"
98
99
  }, 500);
99
100
  }
100
101
  const formData = await c.req.formData();
@@ -146,13 +147,11 @@ uploadApiRoutes.post("/", async (c)=>{
146
147
  const year = date.getUTCFullYear();
147
148
  const month = String(date.getUTCMonth() + 1).padStart(2, "0");
148
149
  const filename = `${id}.${ext}`;
149
- const r2Key = `media/${year}/${month}/${filename}`;
150
+ const storageKey = `media/${year}/${month}/${filename}`;
150
151
  try {
151
- // Upload to R2
152
- await c.env.R2.put(r2Key, file.stream(), {
153
- httpMetadata: {
154
- contentType: file.type
155
- }
152
+ // Upload to storage
153
+ await storage.put(storageKey, file.stream(), {
154
+ contentType: file.type
156
155
  });
157
156
  // Save to database
158
157
  const media = await c.var.services.media.create({
@@ -161,11 +160,14 @@ uploadApiRoutes.post("/", async (c)=>{
161
160
  originalName: file.name,
162
161
  mimeType: file.type,
163
162
  size: file.size,
164
- r2Key
163
+ storageKey,
164
+ provider: c.env.STORAGE_DRIVER || "r2"
165
165
  });
166
166
  // SSE response for Datastar
167
167
  if (wantsSSE(c)) {
168
- const cardHtml = renderMediaCard(media, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL);
168
+ const provider = c.env.STORAGE_DRIVER || "r2";
169
+ const mediaPublicUrl = getPublicUrlForProvider(provider, c.env.R2_PUBLIC_URL, c.env.S3_PUBLIC_URL);
170
+ const cardHtml = renderMediaCard(media, mediaPublicUrl, c.env.IMAGE_TRANSFORM_URL);
169
171
  return sse(c, async (stream)=>{
170
172
  // Replace placeholder with real media card
171
173
  await stream.patchElements(cardHtml, {
@@ -176,7 +178,9 @@ uploadApiRoutes.post("/", async (c)=>{
176
178
  });
177
179
  }
178
180
  // JSON response for API clients
179
- const publicUrl = getMediaUrl(media.id, r2Key, c.env.R2_PUBLIC_URL);
181
+ const provider = c.env.STORAGE_DRIVER || "r2";
182
+ const mediaPublicUrl = getPublicUrlForProvider(provider, c.env.R2_PUBLIC_URL, c.env.S3_PUBLIC_URL);
183
+ const publicUrl = getMediaUrl(media.id, storageKey, mediaPublicUrl);
180
184
  return c.json({
181
185
  id: media.id,
182
186
  filename: media.filename,
@@ -202,11 +206,13 @@ uploadApiRoutes.post("/", async (c)=>{
202
206
  uploadApiRoutes.get("/", async (c)=>{
203
207
  const limit = parseInt(c.req.query("limit") ?? "50", 10);
204
208
  const mediaList = await c.var.services.media.list(limit);
209
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
210
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
205
211
  return c.json({
206
212
  media: mediaList.map((m)=>({
207
213
  id: m.id,
208
214
  filename: m.filename,
209
- url: getMediaUrl(m.id, m.r2Key, c.env.R2_PUBLIC_URL),
215
+ url: getMediaUrl(m.id, m.storageKey, getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl)),
210
216
  mimeType: m.mimeType,
211
217
  size: m.size,
212
218
  createdAt: m.createdAt
@@ -222,13 +228,14 @@ uploadApiRoutes.delete("/:id", async (c)=>{
222
228
  error: "Not found"
223
229
  }, 404);
224
230
  }
225
- // Delete from R2
226
- if (c.env.R2) {
231
+ // Delete from storage
232
+ const storage = c.var.storage;
233
+ if (storage) {
227
234
  try {
228
- await c.env.R2.delete(media.r2Key);
235
+ await storage.delete(media.storageKey);
229
236
  } catch (err) {
230
237
  // eslint-disable-next-line no-console -- Error logging is intentional
231
- console.error("R2 delete error:", err);
238
+ console.error("Storage delete error:", err);
232
239
  }
233
240
  }
234
241
  // Delete from database