koguma 0.4.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/LICENSE +21 -0
- package/README.md +297 -0
- package/cli/index.ts +1150 -0
- package/package.json +60 -0
- package/src/admin/_bundle.ts +3 -0
- package/src/admin/dashboard.ts +27 -0
- package/src/api/router.ts +357 -0
- package/src/auth/index.ts +138 -0
- package/src/client/index.ts +61 -0
- package/src/config/define.ts +157 -0
- package/src/config/field.ts +182 -0
- package/src/config/index.ts +27 -0
- package/src/config/meta.ts +189 -0
- package/src/config/types.ts +35 -0
- package/src/db/migrate.ts +146 -0
- package/src/db/queries.ts +293 -0
- package/src/db/schema.ts +115 -0
- package/src/media/index.ts +89 -0
- package/src/react/index.ts +70 -0
- package/src/worker.ts +51 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Koguma Admin Dashboard — served inline by the Worker.
|
|
3
|
+
*
|
|
4
|
+
* Imports the pre-built admin bundle from _bundle.ts (auto-generated
|
|
5
|
+
* by scripts/bundle-admin.ts after building the admin Vite app).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { adminJS, adminCSS } from "./_bundle.ts";
|
|
9
|
+
|
|
10
|
+
export function adminHtml(siteName: string): string {
|
|
11
|
+
return `<!DOCTYPE html>
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="UTF-8" />
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
16
|
+
<title>Admin — ${siteName}</title>
|
|
17
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
18
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
19
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
20
|
+
<style>${adminCSS}</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<div id="root"></div>
|
|
24
|
+
<script>${adminJS}</script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono API router — auto-generates routes from the Koguma config.
|
|
3
|
+
*
|
|
4
|
+
* Public routes (no auth):
|
|
5
|
+
* GET /api/content/:type → list entries
|
|
6
|
+
* GET /api/content/:type/:id → get one entry
|
|
7
|
+
* GET /api/media/:key → serve media from R2
|
|
8
|
+
*
|
|
9
|
+
* Admin routes (auth required):
|
|
10
|
+
* POST /api/auth/login → login
|
|
11
|
+
* POST /api/auth/logout → logout
|
|
12
|
+
* GET /api/auth/me → check session
|
|
13
|
+
* GET /api/admin/:type → list entries
|
|
14
|
+
* GET /api/admin/:type/:id → get one entry
|
|
15
|
+
* POST /api/admin/:type → create entry
|
|
16
|
+
* PUT /api/admin/:type/:id → update entry
|
|
17
|
+
* DELETE /api/admin/:type/:id → delete entry
|
|
18
|
+
* GET /api/admin/media → list assets
|
|
19
|
+
* POST /api/admin/media → upload asset
|
|
20
|
+
* DELETE /api/admin/media/:id → delete asset
|
|
21
|
+
* GET /api/admin/schema → return content type schemas (for admin UI)
|
|
22
|
+
*/
|
|
23
|
+
import { Hono } from 'hono';
|
|
24
|
+
import { cors } from 'hono/cors';
|
|
25
|
+
import { z } from 'zod/v4';
|
|
26
|
+
import type { KogumaConfig, ContentTypeConfig } from '../config/define.ts';
|
|
27
|
+
import {
|
|
28
|
+
getEntry,
|
|
29
|
+
getEntries,
|
|
30
|
+
createEntry,
|
|
31
|
+
updateEntry,
|
|
32
|
+
deleteEntry
|
|
33
|
+
} from '../db/queries.ts';
|
|
34
|
+
import { handleLogin, handleLogout, requireAuth } from '../auth/index.ts';
|
|
35
|
+
import {
|
|
36
|
+
serveMedia,
|
|
37
|
+
uploadMedia,
|
|
38
|
+
listMedia,
|
|
39
|
+
deleteMedia
|
|
40
|
+
} from '../media/index.ts';
|
|
41
|
+
|
|
42
|
+
export function createApiRouter(config: KogumaConfig): Hono {
|
|
43
|
+
const app = new Hono();
|
|
44
|
+
|
|
45
|
+
// CORS for local dev
|
|
46
|
+
app.use('/api/*', cors());
|
|
47
|
+
|
|
48
|
+
// Content type lookup
|
|
49
|
+
const ctMap = new Map<string, ContentTypeConfig>();
|
|
50
|
+
for (const ct of config.contentTypes) {
|
|
51
|
+
ctMap.set(ct.id, ct);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Reference resolution ─────────────────────────────────────────
|
|
55
|
+
// Resolves ref, refs, and image fields from flat IDs into nested objects.
|
|
56
|
+
// depth=1 resolves the entry's refs; depth=2 resolves refs-of-refs.
|
|
57
|
+
async function resolveEntry(
|
|
58
|
+
db: Parameters<typeof getEntry>[0],
|
|
59
|
+
entry: Record<string, unknown>,
|
|
60
|
+
ct: ContentTypeConfig,
|
|
61
|
+
depth = 1
|
|
62
|
+
): Promise<Record<string, unknown>> {
|
|
63
|
+
if (depth < 1) return entry;
|
|
64
|
+
const resolved = { ...entry };
|
|
65
|
+
|
|
66
|
+
for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
|
|
67
|
+
if (meta.fieldType === 'image') {
|
|
68
|
+
const assetId = entry[fieldId] as string | null;
|
|
69
|
+
if (assetId) {
|
|
70
|
+
const asset = await db
|
|
71
|
+
.prepare('SELECT * FROM _assets WHERE id = ?')
|
|
72
|
+
.bind(assetId)
|
|
73
|
+
.first();
|
|
74
|
+
resolved[fieldId] = asset ?? null;
|
|
75
|
+
}
|
|
76
|
+
} else if (meta.fieldType === 'reference' && meta.refContentType) {
|
|
77
|
+
const refId = entry[fieldId] as string | null;
|
|
78
|
+
if (refId) {
|
|
79
|
+
const refCt = ctMap.get(meta.refContentType);
|
|
80
|
+
if (refCt) {
|
|
81
|
+
const refEntry = await getEntry(db, refCt, refId);
|
|
82
|
+
resolved[fieldId] = refEntry
|
|
83
|
+
? await resolveEntry(db, refEntry, refCt, depth - 1)
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else if (meta.fieldType === 'references' && meta.refContentType) {
|
|
88
|
+
const ids = entry[fieldId] as string[] | undefined;
|
|
89
|
+
if (ids?.length) {
|
|
90
|
+
const refCt = ctMap.get(meta.refContentType);
|
|
91
|
+
if (refCt) {
|
|
92
|
+
const items = await Promise.all(
|
|
93
|
+
ids.map(async id => {
|
|
94
|
+
const refEntry = await getEntry(db, refCt, id);
|
|
95
|
+
return refEntry
|
|
96
|
+
? resolveEntry(db, refEntry, refCt, depth - 1)
|
|
97
|
+
: null;
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
resolved[fieldId] = items.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return resolved;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Auth routes ──────────────────────────────────────────────────
|
|
110
|
+
app.post('/api/auth/login', handleLogin);
|
|
111
|
+
app.post('/api/auth/logout', handleLogout);
|
|
112
|
+
app.get('/api/auth/me', requireAuth, c => c.json({ authenticated: true }));
|
|
113
|
+
|
|
114
|
+
// ── Public content routes ────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
app.get('/api/content/:type', async c => {
|
|
117
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
118
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
119
|
+
|
|
120
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
121
|
+
typeof getEntries
|
|
122
|
+
>[0];
|
|
123
|
+
const includeDrafts = c.req.query('drafts') === 'true';
|
|
124
|
+
const entries = await getEntries(db, ct, { publishedOnly: !includeDrafts });
|
|
125
|
+
// Always resolve references for public API
|
|
126
|
+
const resolved = await Promise.all(
|
|
127
|
+
entries.map(e => resolveEntry(db, e, ct, 2))
|
|
128
|
+
);
|
|
129
|
+
return c.json({ entries: resolved });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
app.get('/api/content/:type/:id', async c => {
|
|
133
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
134
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
135
|
+
|
|
136
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
137
|
+
typeof getEntry
|
|
138
|
+
>[0];
|
|
139
|
+
const entry = await getEntry(db, ct, c.req.param('id'));
|
|
140
|
+
if (!entry) return c.notFound();
|
|
141
|
+
const resolved = await resolveEntry(db, entry, ct, 2);
|
|
142
|
+
return c.json(resolved);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── Media serving (public) ───────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
app.get('/api/media/:key', serveMedia);
|
|
148
|
+
|
|
149
|
+
// ── Admin routes (auth required) ─────────────────────────────────
|
|
150
|
+
|
|
151
|
+
app.use('/api/admin/*', requireAuth);
|
|
152
|
+
|
|
153
|
+
// Schema endpoint — admin UI uses this to render forms
|
|
154
|
+
app.get('/api/admin/schema', c => {
|
|
155
|
+
const schema = config.contentTypes.map(ct => ({
|
|
156
|
+
id: ct.id,
|
|
157
|
+
name: ct.name,
|
|
158
|
+
displayField: ct.displayField,
|
|
159
|
+
singleton: ct.singleton ?? false,
|
|
160
|
+
fieldMeta: ct.fieldMeta,
|
|
161
|
+
groups: ct.groups.map(g => ({
|
|
162
|
+
groupId: g.groupId,
|
|
163
|
+
name: g.name,
|
|
164
|
+
helpText: g.helpText,
|
|
165
|
+
collapsed: g.collapsed,
|
|
166
|
+
fieldIds: Object.keys(g.fields)
|
|
167
|
+
}))
|
|
168
|
+
}));
|
|
169
|
+
return c.json({ contentTypes: schema });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Media management (admin) — must be before :type catch-all ────
|
|
173
|
+
|
|
174
|
+
app.get('/api/admin/media', listMedia);
|
|
175
|
+
app.post('/api/admin/media', uploadMedia);
|
|
176
|
+
app.delete('/api/admin/media/:id', deleteMedia);
|
|
177
|
+
|
|
178
|
+
// CRUD
|
|
179
|
+
app.get('/api/admin/:type', async c => {
|
|
180
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
181
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
182
|
+
|
|
183
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
184
|
+
typeof getEntries
|
|
185
|
+
>[0];
|
|
186
|
+
const entries = await getEntries(db, ct);
|
|
187
|
+
return c.json({ entries });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
app.get('/api/admin/:type/:id', async c => {
|
|
191
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
192
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
193
|
+
|
|
194
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
195
|
+
typeof getEntry
|
|
196
|
+
>[0];
|
|
197
|
+
const entry = await getEntry(db, ct, c.req.param('id'));
|
|
198
|
+
if (!entry) return c.notFound();
|
|
199
|
+
return c.json(entry);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
app.post('/api/admin/:type', async c => {
|
|
203
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
204
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
205
|
+
|
|
206
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
207
|
+
typeof createEntry
|
|
208
|
+
>[0];
|
|
209
|
+
const data = await c.req.json();
|
|
210
|
+
|
|
211
|
+
// D1 stores booleans as 0/1 — coerce back before validation
|
|
212
|
+
for (const [fid, meta] of Object.entries(ct.fieldMeta)) {
|
|
213
|
+
if (
|
|
214
|
+
meta.fieldType === 'boolean' &&
|
|
215
|
+
fid in data &&
|
|
216
|
+
typeof data[fid] === 'number'
|
|
217
|
+
) {
|
|
218
|
+
data[fid] = Boolean(data[fid]);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Validate with Zod schema
|
|
223
|
+
const result = ct.schema.safeParse(data);
|
|
224
|
+
if (!result.success) {
|
|
225
|
+
const issues = (result.error as z.ZodError).issues.map(issue => ({
|
|
226
|
+
field: issue.path.join('.'),
|
|
227
|
+
message: issue.message
|
|
228
|
+
}));
|
|
229
|
+
return c.json({ error: 'Validation failed', issues }, 422);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const entry = await createEntry(db, ct, data);
|
|
233
|
+
return c.json(entry, 201);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
app.put('/api/admin/:type/:id', async c => {
|
|
237
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
238
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
239
|
+
|
|
240
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
241
|
+
typeof updateEntry
|
|
242
|
+
>[0];
|
|
243
|
+
const data = await c.req.json();
|
|
244
|
+
|
|
245
|
+
// D1 stores booleans as 0/1 — coerce back before validation
|
|
246
|
+
for (const [fid, meta] of Object.entries(ct.fieldMeta)) {
|
|
247
|
+
if (
|
|
248
|
+
meta.fieldType === 'boolean' &&
|
|
249
|
+
fid in data &&
|
|
250
|
+
typeof data[fid] === 'number'
|
|
251
|
+
) {
|
|
252
|
+
data[fid] = Boolean(data[fid]);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Partial validation — only validate fields present in request
|
|
257
|
+
const result = (ct.schema as z.ZodObject<Record<string, z.ZodType>>)
|
|
258
|
+
.partial()
|
|
259
|
+
.safeParse(data);
|
|
260
|
+
if (!result.success) {
|
|
261
|
+
const issues = (result.error as z.ZodError).issues.map(issue => ({
|
|
262
|
+
field: issue.path.join('.'),
|
|
263
|
+
message: issue.message
|
|
264
|
+
}));
|
|
265
|
+
return c.json({ error: 'Validation failed', issues }, 422);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const entry = await updateEntry(db, ct, c.req.param('id'), data);
|
|
269
|
+
if (!entry) return c.notFound();
|
|
270
|
+
return c.json(entry);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
app.put('/api/admin/:type', async c => {
|
|
274
|
+
// For singleton types — update the first (only) entry
|
|
275
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
276
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
277
|
+
|
|
278
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
279
|
+
typeof getEntries
|
|
280
|
+
>[0];
|
|
281
|
+
const entries = await getEntries(db, ct);
|
|
282
|
+
if (entries.length === 0) return c.json({ error: 'No entry found' }, 404);
|
|
283
|
+
|
|
284
|
+
const data = await c.req.json();
|
|
285
|
+
const entry = await updateEntry(
|
|
286
|
+
db as Parameters<typeof updateEntry>[0],
|
|
287
|
+
ct,
|
|
288
|
+
entries[0]!.id as string,
|
|
289
|
+
data
|
|
290
|
+
);
|
|
291
|
+
return c.json(entry);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
app.delete('/api/admin/:type/:id', async c => {
|
|
295
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
296
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
297
|
+
|
|
298
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
299
|
+
typeof deleteEntry
|
|
300
|
+
>[0];
|
|
301
|
+
await deleteEntry(db, ct, c.req.param('id'));
|
|
302
|
+
return c.json({ ok: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Publish / Unpublish convenience endpoints
|
|
306
|
+
app.post('/api/admin/:type/:id/publish', async c => {
|
|
307
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
308
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
309
|
+
|
|
310
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
311
|
+
typeof updateEntry
|
|
312
|
+
>[0];
|
|
313
|
+
const entry = await updateEntry(db, ct, c.req.param('id'), {
|
|
314
|
+
status: 'published',
|
|
315
|
+
publishAt: null // clear scheduled time — publish immediately
|
|
316
|
+
});
|
|
317
|
+
if (!entry) return c.notFound();
|
|
318
|
+
return c.json(entry);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
app.post('/api/admin/:type/:id/unpublish', async c => {
|
|
322
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
323
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
324
|
+
|
|
325
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
326
|
+
typeof updateEntry
|
|
327
|
+
>[0];
|
|
328
|
+
const entry = await updateEntry(db, ct, c.req.param('id'), {
|
|
329
|
+
status: 'draft',
|
|
330
|
+
publishAt: null // clear scheduled time
|
|
331
|
+
});
|
|
332
|
+
if (!entry) return c.notFound();
|
|
333
|
+
return c.json(entry);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Schedule — set a future publish time
|
|
337
|
+
app.post('/api/admin/:type/:id/schedule', async c => {
|
|
338
|
+
const ct = ctMap.get(c.req.param('type'));
|
|
339
|
+
if (!ct) return c.json({ error: 'Unknown content type' }, 404);
|
|
340
|
+
|
|
341
|
+
const db = (c.env as Record<string, unknown>).DB as Parameters<
|
|
342
|
+
typeof updateEntry
|
|
343
|
+
>[0];
|
|
344
|
+
const body = await c.req.json<{ publishAt: string }>();
|
|
345
|
+
if (!body.publishAt) {
|
|
346
|
+
return c.json({ error: 'publishAt is required' }, 400);
|
|
347
|
+
}
|
|
348
|
+
const entry = await updateEntry(db, ct, c.req.param('id'), {
|
|
349
|
+
status: 'published',
|
|
350
|
+
publishAt: body.publishAt
|
|
351
|
+
});
|
|
352
|
+
if (!entry) return c.notFound();
|
|
353
|
+
return c.json(entry);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return app;
|
|
357
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth middleware — simple password-based login with cookie sessions.
|
|
3
|
+
*
|
|
4
|
+
* v1: Single shared password stored as KOGUMA_SECRET Wrangler secret.
|
|
5
|
+
* Sessions use HMAC-signed cookies (no external dependencies).
|
|
6
|
+
*/
|
|
7
|
+
import type { Context, Next } from "hono";
|
|
8
|
+
|
|
9
|
+
const SESSION_COOKIE = "koguma_session";
|
|
10
|
+
const SESSION_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
11
|
+
|
|
12
|
+
// ── HMAC signing ─────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
async function hmacSign(payload: string, secret: string): Promise<string> {
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const key = await crypto.subtle.importKey(
|
|
17
|
+
"raw",
|
|
18
|
+
encoder.encode(secret),
|
|
19
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
20
|
+
false,
|
|
21
|
+
["sign"],
|
|
22
|
+
);
|
|
23
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
24
|
+
const sig = btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
25
|
+
return `${payload}.${sig}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function hmacVerify(token: string, secret: string): Promise<string | null> {
|
|
29
|
+
const lastDot = token.lastIndexOf(".");
|
|
30
|
+
if (lastDot === -1) return null;
|
|
31
|
+
const payload = token.slice(0, lastDot);
|
|
32
|
+
const expected = await hmacSign(payload, secret);
|
|
33
|
+
// Constant-time compare
|
|
34
|
+
if (expected.length !== token.length) return null;
|
|
35
|
+
let diff = 0;
|
|
36
|
+
for (let i = 0; i < expected.length; i++) {
|
|
37
|
+
diff |= expected.charCodeAt(i) ^ token.charCodeAt(i);
|
|
38
|
+
}
|
|
39
|
+
return diff === 0 ? payload : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Cookie helpers ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function getCookie(c: Context, name: string): string | undefined {
|
|
45
|
+
const header = c.req.header("cookie") ?? "";
|
|
46
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
|
47
|
+
return match?.[1];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setCookie(c: Context, name: string, value: string, maxAge: number) {
|
|
51
|
+
c.header(
|
|
52
|
+
"Set-Cookie",
|
|
53
|
+
`${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clearCookie(c: Context, name: string) {
|
|
58
|
+
c.header(
|
|
59
|
+
"Set-Cookie",
|
|
60
|
+
`${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Login handler ────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export async function handleLogin(c: Context): Promise<Response> {
|
|
67
|
+
const secret = (c.env as Record<string, string>).KOGUMA_SECRET;
|
|
68
|
+
if (!secret) {
|
|
69
|
+
return c.json({ error: "KOGUMA_SECRET not configured" }, 500);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const body = await c.req.json<{ password?: string }>();
|
|
73
|
+
if (!body.password) {
|
|
74
|
+
return c.json({ error: "Password required" }, 400);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Constant-time compare
|
|
78
|
+
const encoder = new TextEncoder();
|
|
79
|
+
const a = encoder.encode(body.password);
|
|
80
|
+
const b = encoder.encode(secret);
|
|
81
|
+
if (a.length !== b.length) {
|
|
82
|
+
return c.json({ error: "Invalid password" }, 401);
|
|
83
|
+
}
|
|
84
|
+
let diff = 0;
|
|
85
|
+
for (let i = 0; i < a.length; i++) {
|
|
86
|
+
diff |= a[i]! ^ b[i]!;
|
|
87
|
+
}
|
|
88
|
+
if (diff !== 0) {
|
|
89
|
+
return c.json({ error: "Invalid password" }, 401);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Create signed session token
|
|
93
|
+
const payload = JSON.stringify({
|
|
94
|
+
exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE,
|
|
95
|
+
});
|
|
96
|
+
const token = await hmacSign(payload, secret);
|
|
97
|
+
setCookie(c, SESSION_COOKIE, token, SESSION_MAX_AGE);
|
|
98
|
+
|
|
99
|
+
return c.json({ ok: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Logout handler ───────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export function handleLogout(c: Context): Response {
|
|
105
|
+
clearCookie(c, SESSION_COOKIE);
|
|
106
|
+
return c.json({ ok: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Auth middleware ──────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export async function requireAuth(c: Context, next: Next): Promise<Response | void> {
|
|
112
|
+
const secret = (c.env as Record<string, string>).KOGUMA_SECRET;
|
|
113
|
+
if (!secret) {
|
|
114
|
+
return c.json({ error: "KOGUMA_SECRET not configured" }, 500);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const token = getCookie(c, SESSION_COOKIE);
|
|
118
|
+
if (!token) {
|
|
119
|
+
return c.json({ error: "Not authenticated" }, 401);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const payload = await hmacVerify(token, secret);
|
|
123
|
+
if (!payload) {
|
|
124
|
+
return c.json({ error: "Invalid session" }, 401);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const data = JSON.parse(payload) as { exp: number };
|
|
129
|
+
if (data.exp < Math.floor(Date.now() / 1000)) {
|
|
130
|
+
clearCookie(c, SESSION_COOKIE);
|
|
131
|
+
return c.json({ error: "Session expired" }, 401);
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
return c.json({ error: "Invalid session" }, 401);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await next();
|
|
138
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Koguma browser client — typed fetch wrapper for the CMS API.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { createClient } from "koguma/client";
|
|
6
|
+
* const koguma = createClient();
|
|
7
|
+
* const page = await koguma.get<FourDirectionsPage>("fourDirectionsPage", "single");
|
|
8
|
+
* const cards = await koguma.list<FeatureCard>("featureCard");
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface KogumaClientOptions {
|
|
12
|
+
/** Base URL for the API. Defaults to "" (same origin — works in Cloudflare Workers). */
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface KogumaClient {
|
|
17
|
+
/** Fetch a single entry by ID (or "single" for singleton types) */
|
|
18
|
+
get<T = Record<string, unknown>>(contentType: string, id: string): Promise<T>;
|
|
19
|
+
/** Fetch all entries of a given type */
|
|
20
|
+
list<T = Record<string, unknown>>(contentType: string): Promise<T[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createClient(opts: KogumaClientOptions = {}): KogumaClient {
|
|
24
|
+
const base = opts.baseUrl ?? "";
|
|
25
|
+
|
|
26
|
+
async function apiFetch<T>(url: string): Promise<T> {
|
|
27
|
+
const res = await fetch(`${base}${url}`);
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error(`Koguma API error: ${res.status} ${res.statusText} (${url})`);
|
|
30
|
+
}
|
|
31
|
+
return res.json() as Promise<T>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
async get<T>(contentType: string, id: string): Promise<T> {
|
|
36
|
+
// "single" is a convenience alias for singleton types
|
|
37
|
+
if (id === "single") {
|
|
38
|
+
const data = await apiFetch<{ entries: T[] }>(`/api/content/${contentType}`);
|
|
39
|
+
const entry = data.entries[0];
|
|
40
|
+
if (!entry) throw new Error(`No entry found for ${contentType}`);
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
return apiFetch<T>(`/api/content/${contentType}/${id}`);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async list<T>(contentType: string): Promise<T[]> {
|
|
47
|
+
const data = await apiFetch<{ entries: T[] }>(`/api/content/${contentType}`);
|
|
48
|
+
return data.entries;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Singleton client — shared across the app */
|
|
54
|
+
let _client: KogumaClient | null = null;
|
|
55
|
+
|
|
56
|
+
export function getClient(): KogumaClient {
|
|
57
|
+
if (!_client) {
|
|
58
|
+
_client = createClient();
|
|
59
|
+
}
|
|
60
|
+
return _client;
|
|
61
|
+
}
|