@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
@@ -10,7 +10,7 @@ import { useLingui as $_useLingui } from "@jant/core/i18n";
10
10
  import { DashLayout } from "../../theme/layouts/index.js";
11
11
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
12
12
  import * as time from "../../lib/time.js";
13
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
13
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
14
14
  import { dsRedirect } from "../../lib/sse.js";
15
15
  export const mediaRoutes = new Hono();
16
16
  /**
@@ -22,8 +22,9 @@ export const mediaRoutes = new Hono();
22
22
  }
23
23
  /**
24
24
  * Media card component for the grid
25
- */ function MediaCard({ media, r2PublicUrl, imageTransformUrl }) {
26
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
25
+ */ function MediaCard({ media, r2PublicUrl, imageTransformUrl, s3PublicUrl }) {
26
+ const publicUrl = getPublicUrlForProvider(media.provider, r2PublicUrl, s3PublicUrl);
27
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
27
28
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
28
29
  width: 300,
29
30
  quality: 80,
@@ -73,7 +74,7 @@ export const mediaRoutes = new Hono();
73
74
  * Media list page content
74
75
  *
75
76
  * Upload is handled by media-upload.ts (client module) + Datastar @post for SSE.
76
- */ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl }) {
77
+ */ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl, s3PublicUrl }) {
77
78
  const { i18n: $__i18n, _: $__ } = $_useLingui();
78
79
  const processingText = $__i18n._({
79
80
  id: "k1ifdL",
@@ -161,7 +162,8 @@ export const mediaRoutes = new Hono();
161
162
  children: mediaList.map((m)=>/*#__PURE__*/ _jsx(MediaCard, {
162
163
  media: m,
163
164
  r2PublicUrl: r2PublicUrl,
164
- imageTransformUrl: imageTransformUrl
165
+ imageTransformUrl: imageTransformUrl,
166
+ s3PublicUrl: s3PublicUrl
165
167
  }, m.id))
166
168
  })
167
169
  }),
@@ -181,9 +183,10 @@ export const mediaRoutes = new Hono();
181
183
  }
182
184
  /**
183
185
  * View single media content
184
- */ function ViewMediaContent({ media, r2PublicUrl, imageTransformUrl }) {
186
+ */ function ViewMediaContent({ media, r2PublicUrl, imageTransformUrl, s3PublicUrl }) {
185
187
  const { i18n: $__i18n, _: $__ } = $_useLingui();
186
- const url = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
188
+ const publicUrl = getPublicUrlForProvider(media.provider, r2PublicUrl, s3PublicUrl);
189
+ const url = getMediaUrl(media.id, media.storageKey, publicUrl);
187
190
  const thumbnailUrl = getImageUrl(url, imageTransformUrl, {
188
191
  width: 600,
189
192
  quality: 85,
@@ -386,6 +389,7 @@ mediaRoutes.get("/", async (c)=>{
386
389
  const siteName = await getSiteName(c);
387
390
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
388
391
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
392
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
389
393
  return c.html(/*#__PURE__*/ _jsx(DashLayout, {
390
394
  c: c,
391
395
  title: "Media",
@@ -394,7 +398,8 @@ mediaRoutes.get("/", async (c)=>{
394
398
  children: /*#__PURE__*/ _jsx(MediaListContent, {
395
399
  mediaList: mediaList,
396
400
  r2PublicUrl: r2PublicUrl,
397
- imageTransformUrl: imageTransformUrl
401
+ imageTransformUrl: imageTransformUrl,
402
+ s3PublicUrl: s3PublicUrl
398
403
  })
399
404
  }));
400
405
  });
@@ -404,6 +409,7 @@ mediaRoutes.get("/picker", async (c)=>{
404
409
  const mediaList = await c.var.services.media.list(100);
405
410
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
406
411
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
412
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
407
413
  if (mediaList.length === 0) {
408
414
  return c.html(/*#__PURE__*/ _jsx("p", {
409
415
  class: "text-muted-foreground text-sm col-span-4",
@@ -412,7 +418,8 @@ mediaRoutes.get("/picker", async (c)=>{
412
418
  }
413
419
  return c.html(/*#__PURE__*/ _jsx(_Fragment, {
414
420
  children: mediaList.filter((m)=>m.mimeType.startsWith("image/")).map((m)=>{
415
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
421
+ const pUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
422
+ const url = getMediaUrl(m.id, m.storageKey, pUrl);
416
423
  const thumbUrl = getImageUrl(url, imageTransformUrl, {
417
424
  width: 150,
418
425
  quality: 80,
@@ -444,6 +451,7 @@ mediaRoutes.get("/:id", async (c)=>{
444
451
  const siteName = await getSiteName(c);
445
452
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
446
453
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
454
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
447
455
  return c.html(/*#__PURE__*/ _jsx(DashLayout, {
448
456
  c: c,
449
457
  title: media.originalName,
@@ -452,7 +460,8 @@ mediaRoutes.get("/:id", async (c)=>{
452
460
  children: /*#__PURE__*/ _jsx(ViewMediaContent, {
453
461
  media: media,
454
462
  r2PublicUrl: r2PublicUrl,
455
- imageTransformUrl: imageTransformUrl
463
+ imageTransformUrl: imageTransformUrl,
464
+ s3PublicUrl: s3PublicUrl
456
465
  })
457
466
  }));
458
467
  });
@@ -461,13 +470,14 @@ mediaRoutes.post("/:id/delete", async (c)=>{
461
470
  const id = c.req.param("id");
462
471
  const media = await c.var.services.media.getById(id);
463
472
  if (!media) return c.notFound();
464
- // Delete from R2
465
- if (c.env.R2) {
473
+ // Delete from storage
474
+ const storage = c.var.storage;
475
+ if (storage) {
466
476
  try {
467
- await c.env.R2.delete(media.r2Key);
477
+ await storage.delete(media.storageKey);
468
478
  } catch (err) {
469
479
  // eslint-disable-next-line no-console -- Error logging is intentional
470
- console.error("R2 delete error:", err);
480
+ console.error("Storage delete error:", err);
471
481
  }
472
482
  }
473
483
  // Delete from database
@@ -148,7 +148,7 @@ function ViewPostContent({ post }) {
148
148
  ]
149
149
  });
150
150
  }
151
- function EditPostContent({ post, mediaAttachments, r2PublicUrl, imageTransformUrl, collections, postCollectionIds }) {
151
+ function EditPostContent({ post, mediaAttachments, r2PublicUrl, imageTransformUrl, s3PublicUrl, collections, postCollectionIds }) {
152
152
  const { i18n: $__i18n, _: $__ } = $_useLingui();
153
153
  return /*#__PURE__*/ _jsxs(_Fragment, {
154
154
  children: [
@@ -165,6 +165,7 @@ function EditPostContent({ post, mediaAttachments, r2PublicUrl, imageTransformUr
165
165
  mediaAttachments: mediaAttachments,
166
166
  r2PublicUrl: r2PublicUrl,
167
167
  imageTransformUrl: imageTransformUrl,
168
+ s3PublicUrl: s3PublicUrl,
168
169
  collections: collections,
169
170
  postCollectionIds: postCollectionIds
170
171
  })
@@ -199,6 +200,7 @@ postsRoutes.get("/:id/edit", async (c)=>{
199
200
  const mediaAttachments = await c.var.services.media.getByPostId(post.id);
200
201
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
201
202
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
203
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
202
204
  const collections = await c.var.services.collections.list();
203
205
  const postCollections = await c.var.services.collections.getCollectionsForPost(post.id);
204
206
  const postCollectionIds = postCollections.map((col)=>col.id);
@@ -212,6 +214,7 @@ postsRoutes.get("/:id/edit", async (c)=>{
212
214
  mediaAttachments: mediaAttachments,
213
215
  r2PublicUrl: r2PublicUrl,
214
216
  imageTransformUrl: imageTransformUrl,
217
+ s3PublicUrl: s3PublicUrl,
215
218
  collections: collections,
216
219
  postCollectionIds: postCollectionIds
217
220
  })
@@ -3,7 +3,7 @@
3
3
  */ import { Hono } from "hono";
4
4
  import * as sqid from "../../lib/sqid.js";
5
5
  import * as time from "../../lib/time.js";
6
- import { getMediaUrl } from "../../lib/image.js";
6
+ import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
7
7
  export const rssRoutes = new Hono();
8
8
  // RSS 2.0 Feed - main feed at /feed
9
9
  rssRoutes.get("/", async (c)=>{
@@ -12,6 +12,7 @@ rssRoutes.get("/", async (c)=>{
12
12
  const siteDescription = all["SITE_DESCRIPTION"] ?? "";
13
13
  const siteUrl = c.env.SITE_URL;
14
14
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
15
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
15
16
  const posts = await c.var.services.posts.list({
16
17
  visibility: [
17
18
  "featured",
@@ -29,7 +30,7 @@ rssRoutes.get("/", async (c)=>{
29
30
  // Add enclosure for first media attachment
30
31
  const postMedia = mediaMap.get(post.id);
31
32
  const firstMedia = postMedia?.[0];
32
- const enclosure = firstMedia ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.r2Key, r2PublicUrl)}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>` : "";
33
+ const enclosure = firstMedia ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.storageKey, getPublicUrlForProvider(firstMedia.provider, r2PublicUrl, s3PublicUrl))}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>` : "";
33
34
  return `
34
35
  <item>
35
36
  <title><![CDATA[${escapeXml(title)}]]></title>
@@ -47,7 +47,8 @@ homeRoutes.get("/", async (c)=>{
47
47
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
48
48
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
49
49
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
50
- const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
50
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
51
+ const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl, s3PublicUrl);
51
52
  // Get reply counts to identify thread roots
52
53
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
53
54
  const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
@@ -60,7 +61,7 @@ homeRoutes.get("/", async (c)=>{
60
61
  previewReplyIds.push(reply.id);
61
62
  }
62
63
  }
63
- const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl) : new Map();
64
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl, s3PublicUrl) : new Map();
64
65
  // Assemble timeline items
65
66
  const items = displayPosts.map((post)=>{
66
67
  const postWithMedia = {
@@ -7,7 +7,7 @@ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
7
7
  import { MediaGallery } from "../../theme/components/index.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
9
  import * as time from "../../lib/time.js";
10
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
10
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
11
11
  import { getNavigationData } from "../../lib/navigation.js";
12
12
  export const postRoutes = new Hono();
13
13
  function PostContent({ post, mediaAttachments }) {
@@ -71,10 +71,13 @@ postRoutes.get("/:id", async (c)=>{
71
71
  const rawMedia = await c.var.services.media.getByPostId(post.id);
72
72
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
73
73
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
74
- const mediaAttachments = rawMedia.map((m)=>({
74
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
75
+ const mediaAttachments = rawMedia.map((m)=>{
76
+ const publicUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
77
+ return {
75
78
  id: m.id,
76
- url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
77
- previewUrl: getImageUrl(getMediaUrl(m.id, m.r2Key, r2PublicUrl), imageTransformUrl, {
79
+ url: getMediaUrl(m.id, m.storageKey, publicUrl),
80
+ previewUrl: getImageUrl(getMediaUrl(m.id, m.storageKey, publicUrl), imageTransformUrl, {
78
81
  width: 400,
79
82
  quality: 80,
80
83
  format: "auto",
@@ -86,7 +89,8 @@ postRoutes.get("/:id", async (c)=>{
86
89
  height: m.height,
87
90
  position: m.position,
88
91
  mimeType: m.mimeType
89
- }));
92
+ };
93
+ });
90
94
  const navData = await getNavigationData(c);
91
95
  const title = post.title || navData.siteName;
92
96
  return c.html(/*#__PURE__*/ _jsx(BaseLayout, {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Media Service
3
3
  *
4
- * Handles media upload and management with R2 storage
4
+ * Handles media upload and management with pluggable storage backends.
5
5
  */ import { eq, desc, inArray, asc } from "drizzle-orm";
6
6
  import { uuidv7 } from "uuidv7";
7
7
  import { media } from "../db/schema.js";
@@ -15,7 +15,8 @@ export function createMediaService(db) {
15
15
  originalName: row.originalName,
16
16
  mimeType: row.mimeType,
17
17
  size: row.size,
18
- r2Key: row.r2Key,
18
+ storageKey: row.storageKey,
19
+ provider: row.provider,
19
20
  width: row.width,
20
21
  height: row.height,
21
22
  alt: row.alt,
@@ -56,8 +57,8 @@ export function createMediaService(db) {
56
57
  }
57
58
  return result;
58
59
  },
59
- async getByR2Key (r2Key) {
60
- const result = await db.select().from(media).where(eq(media.r2Key, r2Key)).limit(1);
60
+ async getByStorageKey (storageKey) {
61
+ const result = await db.select().from(media).where(eq(media.storageKey, storageKey)).limit(1);
61
62
  return result[0] ? toMedia(result[0]) : null;
62
63
  },
63
64
  async list (limit = 100) {
@@ -74,7 +75,8 @@ export function createMediaService(db) {
74
75
  originalName: data.originalName,
75
76
  mimeType: data.mimeType,
76
77
  size: data.size,
77
- r2Key: data.r2Key,
78
+ storageKey: data.storageKey,
79
+ provider: data.provider ?? "r2",
78
80
  width: data.width ?? null,
79
81
  height: data.height ?? null,
80
82
  alt: data.alt ?? null,
@@ -2,8 +2,8 @@
2
2
  * Post Creation/Edit Form
3
3
  */ import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
4
4
  import { useLingui as $_useLingui } from "@jant/core/i18n";
5
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
6
- export const PostForm = ({ post, action, mediaAttachments, r2PublicUrl, imageTransformUrl, collections, postCollectionIds })=>{
5
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
6
+ export const PostForm = ({ post, action, mediaAttachments, r2PublicUrl, imageTransformUrl, s3PublicUrl, collections, postCollectionIds })=>{
7
7
  const { i18n: $__i18n, _: $__ } = $_useLingui();
8
8
  const isEdit = !!post;
9
9
  const existingMediaIds = (mediaAttachments ?? []).map((m)=>m.id);
@@ -150,7 +150,8 @@ export const PostForm = ({ post, action, mediaAttachments, r2PublicUrl, imageTra
150
150
  mediaAttachments && mediaAttachments.length > 0 && /*#__PURE__*/ _jsx("div", {
151
151
  class: "grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2",
152
152
  children: mediaAttachments.map((m)=>{
153
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
153
+ const pUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
154
+ const url = getMediaUrl(m.id, m.storageKey, pUrl);
154
155
  const thumbUrl = getImageUrl(url, imageTransformUrl, {
155
156
  width: 150,
156
157
  quality: 80,
package/dist/types.js CHANGED
@@ -38,6 +38,10 @@ export const MAX_MEDIA_ATTACHMENTS = 20;
38
38
  ],
39
39
  page: null
40
40
  };
41
+ export const STORAGE_DRIVERS = [
42
+ "r2",
43
+ "s3"
44
+ ];
41
45
  export const VISIBILITY_LEVELS = [
42
46
  "featured",
43
47
  "quiet",
@@ -94,5 +98,33 @@ export const VISIBILITY_LEVELS = [
94
98
  DEMO_PASSWORD: {
95
99
  defaultValue: "",
96
100
  envOnly: true
101
+ },
102
+ STORAGE_DRIVER: {
103
+ defaultValue: "r2",
104
+ envOnly: true
105
+ },
106
+ S3_ENDPOINT: {
107
+ defaultValue: "",
108
+ envOnly: true
109
+ },
110
+ S3_BUCKET: {
111
+ defaultValue: "",
112
+ envOnly: true
113
+ },
114
+ S3_ACCESS_KEY_ID: {
115
+ defaultValue: "",
116
+ envOnly: true
117
+ },
118
+ S3_SECRET_ACCESS_KEY: {
119
+ defaultValue: "",
120
+ envOnly: true
121
+ },
122
+ S3_REGION: {
123
+ defaultValue: "auto",
124
+ envOnly: true
125
+ },
126
+ S3_PUBLIC_URL: {
127
+ defaultValue: "",
128
+ envOnly: true
97
129
  }
98
130
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
+ "@aws-sdk/client-s3": "^3.987.0",
34
35
  "@lingui/core": "^5.9.0",
35
36
  "basecoat-css": "^0.3.10",
36
37
  "better-auth": "^1.4.18",
@@ -58,6 +58,7 @@ export function createTestApp(options: TestAppOptions = {}) {
58
58
 
59
59
  c.set("services", services as AppVariables["services"]);
60
60
  c.set("config", {});
61
+ c.set("storage", null);
61
62
 
62
63
  if (options.authenticated) {
63
64
  // Mock auth that always returns a session
@@ -99,6 +99,16 @@ export function createTestDatabase(options?: { fts?: boolean }) {
99
99
  if (trimmed) sqlite.exec(trimmed);
100
100
  }
101
101
 
102
+ // Apply storage provider migration
103
+ const migration4 = readFileSync(
104
+ resolve(MIGRATIONS_DIR, "0004_add_storage_provider.sql"),
105
+ "utf-8",
106
+ );
107
+ for (const sql of migration4.split("--> statement-breakpoint")) {
108
+ const trimmed = sql.trim();
109
+ if (trimmed) sqlite.exec(trimmed);
110
+ }
111
+
102
112
  const db = drizzle(sqlite, { schema });
103
113
 
104
114
  return { db, sqlite };
package/src/app.tsx CHANGED
@@ -49,6 +49,7 @@ import { requireOnboarding } from "./middleware/onboarding.js";
49
49
  import { BaseLayout } from "./theme/layouts/index.js";
50
50
  import { dsRedirect, dsToast } from "./lib/sse.js";
51
51
  import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
52
+ import { createStorageDriver, type StorageDriver } from "./lib/storage.js";
52
53
 
53
54
  // Extend Hono's context variables
54
55
  export interface AppVariables {
@@ -56,6 +57,7 @@ export interface AppVariables {
56
57
  auth: Auth;
57
58
  config: JantConfig;
58
59
  themeStyle: string;
60
+ storage: StorageDriver | null;
59
61
  }
60
62
 
61
63
  export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
@@ -95,6 +97,7 @@ export function createApp(config: JantConfig = {}): App {
95
97
  const services = createServices(db, session as unknown as D1Database);
96
98
  c.set("services", services);
97
99
  c.set("config", config);
100
+ c.set("storage", createStorageDriver(c.env));
98
101
 
99
102
  if (c.env.AUTH_SECRET) {
100
103
  const auth = createAuth(session as unknown as D1Database, {
@@ -668,9 +671,10 @@ export function createApp(config: JantConfig = {}): App {
668
671
  app.route("/api/upload", uploadApiRoutes);
669
672
  app.route("/api/search", searchApiRoutes);
670
673
 
671
- // Media files from R2 (UUIDv7-based URLs with extension)
674
+ // Media files from storage (UUIDv7-based URLs with extension)
672
675
  app.get("/media/:idWithExt", async (c) => {
673
- if (!c.env.R2) {
676
+ const storage = c.var.storage;
677
+ if (!storage) {
674
678
  return c.notFound();
675
679
  }
676
680
 
@@ -683,16 +687,13 @@ export function createApp(config: JantConfig = {}): App {
683
687
  return c.notFound();
684
688
  }
685
689
 
686
- const object = await c.env.R2.get(media.r2Key);
690
+ const object = await storage.get(media.storageKey);
687
691
  if (!object) {
688
692
  return c.notFound();
689
693
  }
690
694
 
691
695
  const headers = new Headers();
692
- headers.set(
693
- "Content-Type",
694
- object.httpMetadata?.contentType || media.mimeType,
695
- );
696
+ headers.set("Content-Type", object.contentType || media.mimeType);
696
697
  headers.set("Cache-Control", "public, max-age=31536000, immutable");
697
698
 
698
699
  return new Response(object.body, { headers });
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `media` ADD COLUMN `provider` text NOT NULL DEFAULT 'r2';
2
+ --> statement-breakpoint
3
+ ALTER TABLE `media` RENAME COLUMN `r2_key` TO `storage_key`;
@@ -29,6 +29,13 @@
29
29
  "when": 1770746168873,
30
30
  "tag": "0003_add_navigation_links",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1770946168874,
37
+ "tag": "0004_add_storage_provider",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }
package/src/db/schema.ts CHANGED
@@ -51,7 +51,8 @@ export const media = sqliteTable("media", {
51
51
  originalName: text("original_name").notNull(),
52
52
  mimeType: text("mime_type").notNull(),
53
53
  size: integer("size").notNull(),
54
- r2Key: text("r2_key").notNull(),
54
+ storageKey: text("storage_key").notNull(),
55
+ provider: text("provider").notNull().default("r2"),
55
56
  width: integer("width"),
56
57
  height: integer("height"),
57
58
  alt: text("alt"),