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/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
 
@@ -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 formData = await c.req.formData();
29
- const file = formData.get('file') as File | null;
30
- const title = (formData.get('title') as string) ?? '';
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 ext = file.name.split('.').pop() ?? '';
38
- const key = `${id}${ext ? '.' + ext : ''}`;
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({ id, url, title: title || file.name, contentType: file.type });
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
- return c.json({ assets: result.results ?? [] });
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
- // Extract R2 key from URL (/api/media/{key})
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
+ }