@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
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
|
|
638
|
+
// Media files from storage (UUIDv7-based URLs with extension)
|
|
637
639
|
app.get("/media/:idWithExt", async (c)=>{
|
|
638
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 `
|
|
53
|
-
* with the
|
|
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
|
|
57
|
-
* @param
|
|
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
|
|
63
|
-
* getMediaUrl("01902a9f-1a2b-7c3d
|
|
64
|
-
* // Returns: "/media/01902a9f-1a2b-7c3d
|
|
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
|
|
67
|
-
* getMediaUrl("01902a9f-1a2b-7c3d
|
|
68
|
-
* // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d
|
|
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,
|
|
71
|
-
if (
|
|
72
|
-
return `${
|
|
94
|
+
*/ export function getMediaUrl(mediaId, storageKey, publicUrl) {
|
|
95
|
+
if (publicUrl) {
|
|
96
|
+
return `${publicUrl.replace(/\/+$/, "")}/${storageKey}`;
|
|
73
97
|
}
|
|
74
|
-
// Extract extension from
|
|
75
|
-
const ext =
|
|
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
|
|
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.
|
|
28
|
-
previewUrl: getImageUrl(getMediaUrl(m.id, m.
|
|
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
|
+
}
|
package/dist/routes/api/posts.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
18
|
-
const fullUrl = getMediaUrl(media.id, media.
|
|
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
|
-
|
|
90
|
+
const storage = c.var.storage;
|
|
91
|
+
if (!storage) {
|
|
91
92
|
if (wantsSSE(c)) {
|
|
92
93
|
return dsSignals({
|
|
93
|
-
_uploadError: "
|
|
94
|
+
_uploadError: "Storage not configured"
|
|
94
95
|
});
|
|
95
96
|
}
|
|
96
97
|
return c.json({
|
|
97
|
-
error: "
|
|
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
|
|
150
|
+
const storageKey = `media/${year}/${month}/${filename}`;
|
|
150
151
|
try {
|
|
151
|
-
// Upload to
|
|
152
|
-
await
|
|
153
|
-
|
|
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
|
-
|
|
163
|
+
storageKey,
|
|
164
|
+
provider: c.env.STORAGE_DRIVER || "r2"
|
|
165
165
|
});
|
|
166
166
|
// SSE response for Datastar
|
|
167
167
|
if (wantsSSE(c)) {
|
|
168
|
-
const
|
|
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
|
|
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.
|
|
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
|
|
226
|
-
|
|
231
|
+
// Delete from storage
|
|
232
|
+
const storage = c.var.storage;
|
|
233
|
+
if (storage) {
|
|
227
234
|
try {
|
|
228
|
-
await
|
|
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("
|
|
238
|
+
console.error("Storage delete error:", err);
|
|
232
239
|
}
|
|
233
240
|
}
|
|
234
241
|
// Delete from database
|