koguma 0.5.0 → 0.6.0
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/package.json +1 -1
- package/src/admin/_bundle.ts +1 -1
- package/src/api/router.ts +5 -1
- package/src/media/index.ts +155 -26
package/src/api/router.ts
CHANGED
|
@@ -37,8 +37,10 @@ import {
|
|
|
37
37
|
serveMedia,
|
|
38
38
|
uploadMedia,
|
|
39
39
|
listMedia,
|
|
40
|
+
getMedia,
|
|
40
41
|
deleteMedia,
|
|
41
|
-
renameMedia
|
|
42
|
+
renameMedia,
|
|
43
|
+
replaceMedia
|
|
42
44
|
} from '../media/index.ts';
|
|
43
45
|
|
|
44
46
|
export function createApiRouter(config: KogumaConfig): Hono {
|
|
@@ -219,6 +221,8 @@ export function createApiRouter(config: KogumaConfig): Hono {
|
|
|
219
221
|
|
|
220
222
|
app.get('/api/admin/media', listMedia);
|
|
221
223
|
app.post('/api/admin/media', uploadMedia);
|
|
224
|
+
app.get('/api/admin/media/:id', getMedia);
|
|
225
|
+
app.put('/api/admin/media/:id/replace', replaceMedia);
|
|
222
226
|
app.patch('/api/admin/media/:id', renameMedia);
|
|
223
227
|
app.delete('/api/admin/media/:id', deleteMedia);
|
|
224
228
|
|
package/src/media/index.ts
CHANGED
|
@@ -3,6 +3,61 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { Context } from 'hono';
|
|
5
5
|
|
|
6
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
7
|
+
|
|
8
|
+
// ── Shared helpers ──────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** Parse a File from a multipart form and enforce size limit. */
|
|
11
|
+
function parseFileFromForm(
|
|
12
|
+
formData: FormData
|
|
13
|
+
): { file: File; title: string } | { error: string; status: 400 | 413 } {
|
|
14
|
+
const file = formData.get('file') as File | null;
|
|
15
|
+
const title = (formData.get('title') as string) ?? '';
|
|
16
|
+
if (!file) return { error: 'No file provided', status: 400 };
|
|
17
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
18
|
+
return {
|
|
19
|
+
error: `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.`,
|
|
20
|
+
status: 413
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { file, title };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read a file, extract dimensions, and upload to R2. */
|
|
27
|
+
async function processAndUploadFile(
|
|
28
|
+
media: R2Bucket,
|
|
29
|
+
file: File,
|
|
30
|
+
key: string
|
|
31
|
+
): Promise<{ width: number | null; height: number | null }> {
|
|
32
|
+
const buf = await file.arrayBuffer();
|
|
33
|
+
|
|
34
|
+
let width: number | null = null;
|
|
35
|
+
let height: number | null = null;
|
|
36
|
+
try {
|
|
37
|
+
const blob = new Blob([buf], { type: file.type });
|
|
38
|
+
const bmp = await createImageBitmap(blob);
|
|
39
|
+
width = bmp.width;
|
|
40
|
+
height = bmp.height;
|
|
41
|
+
bmp.close();
|
|
42
|
+
} catch {
|
|
43
|
+
// Not decodable (SVG, etc.) — leave null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await media.put(key, buf, {
|
|
47
|
+
httpMetadata: { contentType: file.type }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return { width, height };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build an R2 key from an ID and filename extension. */
|
|
54
|
+
function buildKey(id: string, fileName: string): string {
|
|
55
|
+
const ext = fileName.split('.').pop() ?? '';
|
|
56
|
+
return `${id}${ext ? '.' + ext : ''}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
6
61
|
/** Serve a media file from R2 */
|
|
7
62
|
export async function serveMedia(c: Context): Promise<Response> {
|
|
8
63
|
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
@@ -25,43 +80,67 @@ export async function uploadMedia(c: Context): Promise<Response> {
|
|
|
25
80
|
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
26
81
|
const db = (c.env as Record<string, D1Database>).DB;
|
|
27
82
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (!file) {
|
|
33
|
-
return c.json({ error: 'No file provided' }, 400);
|
|
34
|
-
}
|
|
83
|
+
const parsed = parseFileFromForm(await c.req.formData());
|
|
84
|
+
if ('error' in parsed) return c.json({ error: parsed.error }, parsed.status);
|
|
85
|
+
const { file, title } = parsed;
|
|
35
86
|
|
|
36
87
|
const id = crypto.randomUUID();
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
// Upload to R2
|
|
41
|
-
await media.put(key, await file.arrayBuffer(), {
|
|
42
|
-
httpMetadata: { contentType: file.type }
|
|
43
|
-
});
|
|
88
|
+
const key = buildKey(id, file.name);
|
|
89
|
+
const { width, height } = await processAndUploadFile(media, file, key);
|
|
44
90
|
|
|
45
|
-
// Register in _assets table
|
|
46
91
|
const url = `/api/media/${key}`;
|
|
47
92
|
await db
|
|
48
93
|
.prepare(
|
|
49
|
-
`INSERT INTO _assets (id, title, url, content_type, file_size) VALUES (?, ?, ?, ?, ?)`
|
|
94
|
+
`INSERT INTO _assets (id, title, url, content_type, file_size, width, height) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
50
95
|
)
|
|
51
|
-
.bind(id, title || file.name, url, file.type, file.size)
|
|
96
|
+
.bind(id, title || file.name, url, file.type, file.size, width, height)
|
|
52
97
|
.run();
|
|
53
98
|
|
|
54
|
-
return c.json({
|
|
99
|
+
return c.json({
|
|
100
|
+
id,
|
|
101
|
+
url,
|
|
102
|
+
title: title || file.name,
|
|
103
|
+
contentType: file.type,
|
|
104
|
+
width,
|
|
105
|
+
height
|
|
106
|
+
});
|
|
55
107
|
}
|
|
56
108
|
|
|
57
|
-
/** List all media assets */
|
|
109
|
+
/** List all media assets (lazy-backfills file_size from R2 when null) */
|
|
58
110
|
export async function listMedia(c: Context): Promise<Response> {
|
|
59
111
|
const db = (c.env as Record<string, D1Database>).DB;
|
|
112
|
+
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
60
113
|
const result = await db
|
|
61
114
|
.prepare('SELECT * FROM _assets ORDER BY created_at DESC')
|
|
62
115
|
.all();
|
|
63
116
|
|
|
64
|
-
|
|
117
|
+
const assets = (result.results ?? []) as Record<string, unknown>[];
|
|
118
|
+
|
|
119
|
+
// Lazy backfill: look up file_size from R2 for any assets missing it
|
|
120
|
+
const needsBackfill = assets.filter(a => a.file_size == null && a.url);
|
|
121
|
+
if (needsBackfill.length > 0) {
|
|
122
|
+
const backfillPromise = Promise.all(
|
|
123
|
+
needsBackfill.map(async asset => {
|
|
124
|
+
const key = (asset.url as string).replace('/api/media/', '');
|
|
125
|
+
try {
|
|
126
|
+
const head = await media.head(key);
|
|
127
|
+
if (head) {
|
|
128
|
+
asset.file_size = head.size;
|
|
129
|
+
await db
|
|
130
|
+
.prepare('UPDATE _assets SET file_size = ? WHERE id = ?')
|
|
131
|
+
.bind(head.size, asset.id)
|
|
132
|
+
.run();
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// R2 lookup failed — skip, will retry on next list
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
// Run backfill in background so the response isn't delayed
|
|
140
|
+
c.executionCtx.waitUntil(backfillPromise);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return c.json({ assets });
|
|
65
144
|
}
|
|
66
145
|
|
|
67
146
|
/** Rename a media asset (title only — ID and R2 key are unchanged) */
|
|
@@ -83,25 +162,75 @@ export async function renameMedia(c: Context): Promise<Response> {
|
|
|
83
162
|
return c.json({ ok: true, id, title: title.trim() });
|
|
84
163
|
}
|
|
85
164
|
|
|
165
|
+
/** Delete a media asset from R2 and _assets table */
|
|
86
166
|
export async function deleteMedia(c: Context): Promise<Response> {
|
|
87
167
|
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
88
168
|
const db = (c.env as Record<string, D1Database>).DB;
|
|
89
169
|
const id = c.req.param('id');
|
|
90
170
|
|
|
91
|
-
// Get the asset to find the R2 key
|
|
92
171
|
const asset = await db
|
|
93
172
|
.prepare('SELECT * FROM _assets WHERE id = ?')
|
|
94
173
|
.bind(id)
|
|
95
174
|
.first();
|
|
96
|
-
|
|
97
175
|
if (!asset) return c.notFound();
|
|
98
176
|
|
|
99
|
-
|
|
100
|
-
const url = asset.url as string;
|
|
101
|
-
const key = url.replace('/api/media/', '');
|
|
102
|
-
|
|
177
|
+
const key = (asset.url as string).replace('/api/media/', '');
|
|
103
178
|
await media.delete(key);
|
|
104
179
|
await db.prepare('DELETE FROM _assets WHERE id = ?').bind(id).run();
|
|
105
180
|
|
|
106
181
|
return c.json({ ok: true });
|
|
107
182
|
}
|
|
183
|
+
|
|
184
|
+
/** Get a single media asset by ID */
|
|
185
|
+
export async function getMedia(c: Context): Promise<Response> {
|
|
186
|
+
const db = (c.env as Record<string, D1Database>).DB;
|
|
187
|
+
const id = c.req.param('id');
|
|
188
|
+
const asset = await db
|
|
189
|
+
.prepare('SELECT * FROM _assets WHERE id = ?')
|
|
190
|
+
.bind(id)
|
|
191
|
+
.first();
|
|
192
|
+
if (!asset) return c.notFound();
|
|
193
|
+
return c.json(asset);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Replace an existing asset's file (keeps the same DB row ID) */
|
|
197
|
+
export async function replaceMedia(c: Context): Promise<Response> {
|
|
198
|
+
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
199
|
+
const db = (c.env as Record<string, D1Database>).DB;
|
|
200
|
+
const id = c.req.param('id');
|
|
201
|
+
|
|
202
|
+
const existing = await db
|
|
203
|
+
.prepare('SELECT * FROM _assets WHERE id = ?')
|
|
204
|
+
.bind(id)
|
|
205
|
+
.first();
|
|
206
|
+
if (!existing) return c.notFound();
|
|
207
|
+
|
|
208
|
+
const parsed = parseFileFromForm(await c.req.formData());
|
|
209
|
+
if ('error' in parsed) return c.json({ error: parsed.error }, parsed.status);
|
|
210
|
+
const { file } = parsed;
|
|
211
|
+
|
|
212
|
+
// Delete old R2 object
|
|
213
|
+
const oldKey = (existing.url as string).replace('/api/media/', '');
|
|
214
|
+
await media.delete(oldKey);
|
|
215
|
+
|
|
216
|
+
// Upload new file
|
|
217
|
+
const newKey = buildKey(id, file.name);
|
|
218
|
+
const { width, height } = await processAndUploadFile(media, file, newKey);
|
|
219
|
+
|
|
220
|
+
const newUrl = `/api/media/${newKey}`;
|
|
221
|
+
await db
|
|
222
|
+
.prepare(
|
|
223
|
+
`UPDATE _assets SET url = ?, content_type = ?, file_size = ?, width = ?, height = ?, updated_at = datetime('now') WHERE id = ?`
|
|
224
|
+
)
|
|
225
|
+
.bind(newUrl, file.type, file.size, width, height, id)
|
|
226
|
+
.run();
|
|
227
|
+
|
|
228
|
+
return c.json({
|
|
229
|
+
id,
|
|
230
|
+
url: newUrl,
|
|
231
|
+
content_type: file.type,
|
|
232
|
+
file_size: file.size,
|
|
233
|
+
width,
|
|
234
|
+
height
|
|
235
|
+
});
|
|
236
|
+
}
|