@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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
465
|
-
|
|
473
|
+
// Delete from storage
|
|
474
|
+
const storage = c.var.storage;
|
|
475
|
+
if (storage) {
|
|
466
476
|
try {
|
|
467
|
-
await
|
|
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("
|
|
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
|
})
|
package/dist/routes/feed/rss.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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.
|
|
77
|
-
previewUrl: getImageUrl(getMediaUrl(m.id, m.
|
|
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, {
|
package/dist/services/media.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media Service
|
|
3
3
|
*
|
|
4
|
-
* Handles media upload and management with
|
|
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
|
-
|
|
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
|
|
60
|
-
const result = await db.select().from(media).where(eq(media.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|
|
@@ -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
|
|
674
|
+
// Media files from storage (UUIDv7-based URLs with extension)
|
|
672
675
|
app.get("/media/:idWithExt", async (c) => {
|
|
673
|
-
|
|
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
|
|
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 });
|
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
|
-
|
|
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"),
|