@a83/orbiter-admin 0.2.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.
@@ -0,0 +1,133 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod } from '@a83/orbiter-core';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ export const githubRoutes = new Hono();
6
+
7
+ const API = 'https://api.github.com';
8
+
9
+ function ghHeaders(token) {
10
+ return {
11
+ Authorization: `Bearer ${token}`,
12
+ Accept: 'application/vnd.github+json',
13
+ 'X-GitHub-Api-Version': '2022-11-28',
14
+ 'Content-Type': 'application/json',
15
+ 'User-Agent': 'orbiter-cms',
16
+ };
17
+ }
18
+
19
+ async function ghGet(token, path) {
20
+ const res = await fetch(`${API}${path}`, { headers: ghHeaders(token) });
21
+ if (!res.ok) throw new Error(`GitHub GET ${path}: ${res.status} ${await res.text()}`);
22
+ return res.json();
23
+ }
24
+
25
+ async function ghPost(token, path, body) {
26
+ const res = await fetch(`${API}${path}`, { method: 'POST', headers: ghHeaders(token), body: JSON.stringify(body) });
27
+ if (!res.ok) throw new Error(`GitHub POST ${path}: ${res.status} ${await res.text()}`);
28
+ return res.json();
29
+ }
30
+
31
+ async function ghPatch(token, path, body) {
32
+ const res = await fetch(`${API}${path}`, { method: 'PATCH', headers: ghHeaders(token), body: JSON.stringify(body) });
33
+ if (!res.ok) throw new Error(`GitHub PATCH ${path}: ${res.status} ${await res.text()}`);
34
+ return res.json();
35
+ }
36
+
37
+ const MIME_EXT = {
38
+ 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif',
39
+ 'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/avif': '.avif',
40
+ 'application/pdf': '.pdf', 'video/mp4': '.mp4', 'video/webm': '.webm',
41
+ };
42
+
43
+ function extFor(mime, filename) {
44
+ if (MIME_EXT[mime]) return MIME_EXT[mime];
45
+ const m = filename.match(/\.[^.]+$/);
46
+ return m ? m[0] : '.bin';
47
+ }
48
+
49
+ // POST /api/github/push — commit pod + media to GitHub
50
+ githubRoutes.post('/push', async (c) => {
51
+ const podPath = c.get('podPath');
52
+ const db = openPod(podPath);
53
+ const token = db.getMeta('github.token') ?? '';
54
+ const repo = db.getMeta('github.repo') ?? '';
55
+ const branch = db.getMeta('github.branch') ?? 'main';
56
+
57
+ if (!token || !repo) {
58
+ db.close();
59
+ return c.json({ ok: false, message: 'GitHub token and repo not configured in Settings.' }, 400);
60
+ }
61
+
62
+ try {
63
+ // Read media rows (with BLOBs)
64
+ const media = db.db.prepare('SELECT id, filename, mime_type, data FROM _media').all();
65
+
66
+ // Build media index (no BLOBs)
67
+ const mediaRows = db.db.prepare('SELECT id, filename, mime_type, size, alt, folder, created_at FROM _media').all();
68
+ const indexEntries = mediaRows.map(r => ({
69
+ id: r.id,
70
+ filename: r.filename,
71
+ mime_type: r.mime_type,
72
+ size: r.size,
73
+ alt: r.alt ?? '',
74
+ folder: r.folder ?? '',
75
+ created_at: r.created_at,
76
+ file: `media/${r.id}${extFor(r.mime_type, r.filename)}`,
77
+ }));
78
+ const indexJson = JSON.stringify({ version: 1, media: indexEntries }, null, 2);
79
+
80
+ // Mark storage mode so GH Action knows to unpack
81
+ db.setMeta('storage.mode', 'git');
82
+ db.close();
83
+
84
+ // Read pod as base64 (after writing storage.mode)
85
+ const podBase64 = readFileSync(podPath).toString('base64');
86
+
87
+ const files = [
88
+ { path: 'content.pod', content: podBase64, encoding: 'base64' },
89
+ { path: 'media-index.json', content: Buffer.from(indexJson).toString('base64'), encoding: 'base64' },
90
+ ];
91
+
92
+ for (const row of media) {
93
+ if (!row.data || row.data.length === 0) continue;
94
+ const ext = extFor(row.mime_type, row.filename);
95
+ files.push({ path: `media/${row.id}${ext}`, content: row.data.toString('base64'), encoding: 'base64' });
96
+ }
97
+
98
+ // Commit via Git Trees API
99
+ const refData = await ghGet(token, `/repos/${repo}/git/refs/heads/${branch}`);
100
+ const latestSha = refData.object.sha;
101
+ const commitData = await ghGet(token, `/repos/${repo}/git/commits/${latestSha}`);
102
+ const treeSha = commitData.tree.sha;
103
+
104
+ const treeItems = await Promise.all(
105
+ files.map(async f => {
106
+ const blob = await ghPost(token, `/repos/${repo}/git/blobs`, { content: f.content, encoding: f.encoding });
107
+ return { path: f.path, mode: '100644', type: 'blob', sha: blob.sha };
108
+ })
109
+ );
110
+
111
+ const newTree = await ghPost(token, `/repos/${repo}/git/trees`, { base_tree: treeSha, tree: treeItems });
112
+ const newCommit = await ghPost(token, `/repos/${repo}/git/commits`, {
113
+ message: `orbiter: publish content [${new Date().toISOString().slice(0, 10)}]`,
114
+ tree: newTree.sha,
115
+ parents: [latestSha],
116
+ });
117
+ await ghPatch(token, `/repos/${repo}/git/refs/heads/${branch}`, { sha: newCommit.sha, force: false });
118
+
119
+ return c.json({ ok: true, message: `Pushed ${files.length} files to ${repo}@${branch}`, commit: newCommit.sha });
120
+
121
+ } catch (err) {
122
+ return c.json({ ok: false, message: err.message ?? 'Unknown error' }, 500);
123
+ }
124
+ });
125
+
126
+ // GET /api/github/status
127
+ githubRoutes.get('/status', (c) => {
128
+ const db = openPod(c.get('podPath'));
129
+ const token = db.getMeta('github.token') ?? '';
130
+ const repo = db.getMeta('github.repo') ?? '';
131
+ db.close();
132
+ return c.json({ configured: !!(token && repo) });
133
+ });
@@ -0,0 +1,120 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod } from '@a83/orbiter-core';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { parseWXR, buildImportPlan, executeImport } from '../wp-importer.js';
5
+
6
+ export const importRoutes = new Hono();
7
+
8
+ // In-memory token store — holds parsed WXR between analyze and execute (30 min TTL)
9
+ const pendingImports = new Map();
10
+ setInterval(() => {
11
+ const cutoff = Date.now() - 30 * 60 * 1000;
12
+ for (const [k, v] of pendingImports) {
13
+ if (v.ts < cutoff) pendingImports.delete(k);
14
+ }
15
+ }, 5 * 60 * 1000);
16
+
17
+ // POST /api/import/analyze — upload WXR XML, return plan + token
18
+ importRoutes.post('/analyze', async (c) => {
19
+ const form = await c.req.formData();
20
+ const file = form.get('wxr_file');
21
+ if (!file || typeof file === 'string' || file.size === 0)
22
+ return c.json({ error: 'No file provided' }, 400);
23
+
24
+ try {
25
+ const xmlText = await file.text();
26
+ const parsed = parseWXR(xmlText);
27
+ const plan = buildImportPlan(parsed);
28
+ const token = randomUUID();
29
+ pendingImports.set(token, { parsed, ts: Date.now() });
30
+ return c.json({ token, plan });
31
+ } catch (err) {
32
+ return c.json({ error: `Parse error: ${err.message}` }, 422);
33
+ }
34
+ });
35
+
36
+ // POST /api/import/execute — run WXR import using stored token
37
+ importRoutes.post('/execute', async (c) => {
38
+ const body = await c.req.json().catch(() => ({}));
39
+ const { token, selectedTypes, downloadMedia, onDuplicate } = body;
40
+
41
+ if (!token || !pendingImports.has(token))
42
+ return c.json({ error: 'Import token expired or invalid. Please re-upload your file.' }, 400);
43
+
44
+ const { parsed } = pendingImports.get(token);
45
+ pendingImports.delete(token);
46
+
47
+ const db = openPod(c.get('podPath'));
48
+ try {
49
+ const results = await executeImport(db, parsed, {
50
+ selectedTypes: selectedTypes ?? [],
51
+ downloadMedia: !!downloadMedia,
52
+ onDuplicate: onDuplicate ?? 'skip',
53
+ });
54
+ db.close();
55
+ return c.json(results);
56
+ } catch (err) {
57
+ db.close();
58
+ return c.json({ error: err.message }, 500);
59
+ }
60
+ });
61
+
62
+ // POST /api/import/markdown — import .md files with frontmatter
63
+ importRoutes.post('/markdown', async (c) => {
64
+ const form = await c.req.formData();
65
+ const targetCol = form.get('md_collection')?.toString() ?? '';
66
+ const onDup = form.get('on_duplicate')?.toString() ?? 'skip';
67
+ const files = form.getAll('md_files').filter(f => f instanceof File && f.size > 0);
68
+
69
+ if (files.length === 0) return c.json({ error: 'No files provided' }, 400);
70
+ if (!targetCol) return c.json({ error: 'No target collection selected' }, 400);
71
+
72
+ const db = openPod(c.get('podPath'));
73
+ const col = db.getCollection(targetCol);
74
+ if (!col) { db.close(); return c.json({ error: `Collection "${targetCol}" not found` }, 404); }
75
+
76
+ let imported = 0, skipped = 0;
77
+ const now = () => new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
78
+
79
+ function parseFrontmatter(text) {
80
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
81
+ if (!m) return { data: {}, body: text.trim() };
82
+ const data = {};
83
+ for (const line of m[1].split('\n')) {
84
+ const kv = line.match(/^(\w[\w-]*):\s*(.+)$/);
85
+ if (!kv) continue;
86
+ let val = kv[2].trim();
87
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
88
+ else if (val === 'true') val = true;
89
+ else if (val === 'false') val = false;
90
+ else if (val !== '' && !isNaN(Number(val))) val = Number(val);
91
+ data[kv[1]] = val;
92
+ }
93
+ return { data, body: m[2].trim() };
94
+ }
95
+
96
+ for (const f of files) {
97
+ const text = await f.text();
98
+ const parsed = parseFrontmatter(text);
99
+ const slug = String(parsed.data.slug ?? f.name.replace(/\.md$/, '').toLowerCase().replace(/\s+/g, '-'));
100
+ delete parsed.data.slug;
101
+ const entryData = { ...parsed.data, body: parsed.data.body ?? parsed.body };
102
+ const status = String(parsed.data.status ?? 'draft');
103
+ delete entryData.status;
104
+
105
+ const existing = db.db.prepare('SELECT id FROM _entries WHERE collection_id = ? AND slug = ?').get(targetCol, slug);
106
+ if (existing && onDup === 'skip') { skipped++; continue; }
107
+
108
+ if (existing) {
109
+ db.db.prepare('UPDATE _entries SET data=?,status=?,updated_at=? WHERE id=?')
110
+ .run(JSON.stringify(entryData), status, now(), existing.id);
111
+ } else {
112
+ db.db.prepare('INSERT INTO _entries (id,collection_id,slug,data,status) VALUES (?,?,?,?,?)')
113
+ .run(randomUUID(), targetCol, slug, JSON.stringify(entryData), status);
114
+ }
115
+ imported++;
116
+ }
117
+
118
+ db.close();
119
+ return c.json({ type: 'markdown', imported, skipped });
120
+ });
@@ -0,0 +1,19 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod } from '@a83/orbiter-core';
3
+
4
+ export const infoRoutes = new Hono();
5
+
6
+ // GET /api/info — pod path, format version, collection stats
7
+ infoRoutes.get('/', (c) => {
8
+ const podPath = c.get('podPath');
9
+ const db = openPod(podPath);
10
+ const cols = db.getCollections().map(col => ({
11
+ id: col.id,
12
+ label: col.label,
13
+ total: db.getEntries(col.id).length,
14
+ parent: db.getMeta(`collection.${col.id}.parent`) ?? null,
15
+ }));
16
+ const version = db.getMeta('format_version') ?? '1';
17
+ db.close();
18
+ return c.json({ podPath, formatVersion: version, collections: cols });
19
+ });
@@ -0,0 +1,95 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod } from '@a83/orbiter-core';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export const mediaRoutes = new Hono();
6
+
7
+ // GET /api/media?folder=
8
+ mediaRoutes.get('/', (c) => {
9
+ const folder = c.req.query('folder') ?? null;
10
+ const db = openPod(c.get('podPath'));
11
+ const items = db.listMedia(folder);
12
+ db.close();
13
+ return c.json(items);
14
+ });
15
+
16
+ // GET /api/media/:id/raw — serve the binary
17
+ mediaRoutes.get('/:id/raw', (c) => {
18
+ const db = openPod(c.get('podPath'));
19
+ const item = db.getMediaItem(c.req.param('id'));
20
+ db.close();
21
+ if (!item) return c.json({ error: 'Not found' }, 404);
22
+ return new Response(item.data, {
23
+ headers: {
24
+ 'Content-Type': item.mime_type,
25
+ 'Cache-Control': 'public, max-age=31536000, immutable',
26
+ },
27
+ });
28
+ });
29
+
30
+ // POST /api/media — multipart upload
31
+ mediaRoutes.post('/', async (c) => {
32
+ const form = await c.req.formData();
33
+ const file = form.get('file');
34
+ const alt = form.get('alt')?.toString() ?? null;
35
+ const folder = form.get('folder')?.toString() ?? '';
36
+
37
+ if (!file || typeof file === 'string') return c.json({ error: 'No file provided' }, 400);
38
+
39
+ const buffer = Buffer.from(await file.arrayBuffer());
40
+ const id = randomUUID();
41
+ const db = openPod(c.get('podPath'));
42
+ db.insertMedia(id, file.name, file.type, buffer.byteLength, buffer, alt, folder);
43
+ const item = db.getMediaItem(id);
44
+ db.close();
45
+
46
+ const { data: _, ...meta } = item;
47
+ return c.json(meta, 201);
48
+ });
49
+
50
+ // POST /api/media/import-url — server-side fetch from a public URL (Dropbox, GDrive, etc.)
51
+ mediaRoutes.post('/import-url', async (c) => {
52
+ let body;
53
+ try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); }
54
+ let { url, alt, folder } = body;
55
+ if (!url) return c.json({ error: 'No URL provided' }, 400);
56
+
57
+ // Dropbox: convert share link to direct download
58
+ if (/dropbox\.com/.test(url)) {
59
+ url = url.replace(/[?&]dl=0/, '').replace(/(\?.*)$/, '$1&dl=1');
60
+ if (!url.includes('?')) url += '?dl=1';
61
+ }
62
+ // Google Drive: convert /file/d/ID/view to direct download
63
+ const gdrive = url.match(/drive\.google\.com\/file\/d\/([^/]+)/);
64
+ if (gdrive) url = `https://drive.google.com/uc?export=download&id=${gdrive[1]}`;
65
+
66
+ let resp;
67
+ try {
68
+ resp = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'Orbiter-Admin/1.0' } });
69
+ } catch (err) {
70
+ return c.json({ error: `Fetch failed: ${err.message}` }, 400);
71
+ }
72
+ if (!resp.ok) return c.json({ error: `Remote returned ${resp.status}` }, 400);
73
+
74
+ const mime = (resp.headers.get('content-type') || 'application/octet-stream').split(';')[0].trim();
75
+ const buffer = Buffer.from(await resp.arrayBuffer());
76
+ const filename = url.split('/').pop()?.split('?')[0] || 'imported';
77
+ const id = randomUUID();
78
+ const db = openPod(c.get('podPath'));
79
+ db.insertMedia(id, filename, mime, buffer.byteLength, buffer, alt ?? null, folder ?? '');
80
+ const item = db.getMediaItem(id);
81
+ db.close();
82
+
83
+ const { data: _, ...meta } = item;
84
+ return c.json(meta, 201);
85
+ });
86
+
87
+ // DELETE /api/media/:id
88
+ mediaRoutes.delete('/:id', (c) => {
89
+ const db = openPod(c.get('podPath'));
90
+ const item = db.getMediaItem(c.req.param('id'));
91
+ if (!item) { db.close(); return c.json({ error: 'Not found' }, 404); }
92
+ db.deleteMedia(item.id);
93
+ db.close();
94
+ return c.json({ ok: true });
95
+ });
@@ -0,0 +1,54 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod } from '@a83/orbiter-core';
3
+
4
+ export const metaRoutes = new Hono();
5
+
6
+ const ALLOWED_KEYS = [
7
+ 'site.name', 'site.url', 'site.description', 'site.locale', 'site.locales',
8
+ 'build.webhook_url',
9
+ 'github.token', 'github.repo', 'github.branch',
10
+ 'api.enabled', 'api.token',
11
+ 'dashboard.notes', 'dashboard.todos',
12
+ 'ui.theme',
13
+ 'format_version',
14
+ ];
15
+
16
+ // GET /api/meta — returns all allowed keys as a flat object
17
+ metaRoutes.get('/', (c) => {
18
+ const db = openPod(c.get('podPath'));
19
+ const out = {};
20
+ for (const key of ALLOWED_KEYS) out[key] = db.getMeta(key) ?? null;
21
+ db.close();
22
+ return c.json(out);
23
+ });
24
+
25
+ // GET /api/meta/:key
26
+ metaRoutes.get('/:key', (c) => {
27
+ const key = c.req.param('key').replace(/~/g, '.');
28
+ const db = openPod(c.get('podPath'));
29
+ const val = db.getMeta(key);
30
+ db.close();
31
+ return c.json({ key, value: val });
32
+ });
33
+
34
+ // PUT /api/meta — batch update
35
+ metaRoutes.put('/', async (c) => {
36
+ const body = await c.req.json();
37
+ const db = openPod(c.get('podPath'));
38
+ for (const [key, value] of Object.entries(body)) {
39
+ if (ALLOWED_KEYS.includes(key)) db.setMeta(key, value == null ? '' : String(value));
40
+ }
41
+ db.close();
42
+ return c.json({ ok: true });
43
+ });
44
+
45
+ // PUT /api/meta/:key — single key update
46
+ metaRoutes.put('/:key', async (c) => {
47
+ const key = c.req.param('key').replace(/~/g, '.');
48
+ if (!ALLOWED_KEYS.includes(key)) return c.json({ error: 'Key not allowed' }, 403);
49
+ const { value } = await c.req.json();
50
+ const db = openPod(c.get('podPath'));
51
+ db.setMeta(key, value == null ? '' : String(value));
52
+ db.close();
53
+ return c.json({ ok: true });
54
+ });
@@ -0,0 +1,62 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod } from '@a83/orbiter-core';
3
+
4
+ export const searchRoutes = new Hono();
5
+
6
+ // GET /api/search/recent — last N entries across all collections
7
+ searchRoutes.get('/recent', (c) => {
8
+ const limit = Math.min(parseInt(c.req.query('limit') ?? '10', 10), 50);
9
+ const db = openPod(c.get('podPath'));
10
+ const cols = db.getCollections();
11
+ const colMap = Object.fromEntries(cols.map(c => [c.id, c.label]));
12
+
13
+ const rows = db.db
14
+ .prepare(`SELECT e.*, e.collection_id as collection FROM _entries e ORDER BY e.updated_at DESC LIMIT ?`)
15
+ .all(limit);
16
+
17
+ const results = rows.map(r => {
18
+ const data = JSON.parse(r.data);
19
+ return {
20
+ collection: r.collection_id,
21
+ label: colMap[r.collection_id] ?? r.collection_id,
22
+ slug: r.slug,
23
+ title: data.title ?? r.slug,
24
+ status: r.status,
25
+ updated_at: r.updated_at,
26
+ };
27
+ });
28
+ db.close();
29
+ return c.json(results);
30
+ });
31
+
32
+ // GET /api/search?q=
33
+ searchRoutes.get('/', (c) => {
34
+ const q = (c.req.query('q') ?? '').trim().toLowerCase();
35
+ if (!q) return c.json([]);
36
+
37
+ const db = openPod(c.get('podPath'));
38
+ const collections = db.getCollections();
39
+ const results = [];
40
+
41
+ for (const col of collections) {
42
+ const entries = db.getEntries(col.id);
43
+ for (const entry of entries) {
44
+ const title = (entry.data?.title ?? entry.slug ?? '').toLowerCase();
45
+ const body = (entry.data?.body ?? '').toLowerCase();
46
+ if (title.includes(q) || body.includes(q) || entry.slug.includes(q)) {
47
+ results.push({
48
+ type: 'entry',
49
+ collection: col.id,
50
+ label: col.label,
51
+ slug: entry.slug,
52
+ title: entry.data?.title ?? entry.slug,
53
+ status: entry.status,
54
+ });
55
+ if (results.length >= 20) break;
56
+ }
57
+ }
58
+ if (results.length >= 20) break;
59
+ }
60
+ db.close();
61
+ return c.json(results);
62
+ });
@@ -0,0 +1,46 @@
1
+ import { Hono } from 'hono';
2
+ import { openPod, hashPassword } from '@a83/orbiter-core';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { requireAdmin } from '../middleware/auth.js';
5
+
6
+ export const userRoutes = new Hono();
7
+
8
+ // All user management requires admin role
9
+ userRoutes.use('*', requireAdmin);
10
+
11
+ // GET /api/users
12
+ userRoutes.get('/', (c) => {
13
+ const db = openPod(c.get('podPath'));
14
+ const users = db.getUsers();
15
+ db.close();
16
+ return c.json(users);
17
+ });
18
+
19
+ // POST /api/users
20
+ userRoutes.post('/', async (c) => {
21
+ const { username, password, role = 'editor' } = await c.req.json();
22
+ if (!username || !password) return c.json({ error: 'username and password are required' }, 400);
23
+ if (!['admin', 'editor'].includes(role)) return c.json({ error: 'Invalid role' }, 400);
24
+
25
+ const db = openPod(c.get('podPath'));
26
+ const existing = db.getUserByUsername(username);
27
+ if (existing) { db.close(); return c.json({ error: `User "${username}" already exists` }, 409); }
28
+
29
+ const hashed = await hashPassword(password);
30
+ const id = randomUUID();
31
+ db.insertUser(id, username, hashed, role);
32
+ db.close();
33
+ return c.json({ ok: true, id, username, role }, 201);
34
+ });
35
+
36
+ // DELETE /api/users/:id
37
+ userRoutes.delete('/:id', (c) => {
38
+ const currentUser = c.get('user');
39
+ if (currentUser.id === c.req.param('id')) {
40
+ return c.json({ error: 'Cannot delete your own account' }, 400);
41
+ }
42
+ const db = openPod(c.get('podPath'));
43
+ db.deleteUser(c.req.param('id'));
44
+ db.close();
45
+ return c.json({ ok: true });
46
+ });
package/src/server.js ADDED
@@ -0,0 +1,85 @@
1
+ import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
3
+ import { serveStatic } from '@hono/node-server/serve-static';
4
+ import { serve } from '@hono/node-server';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, join } from 'node:path';
7
+
8
+ // Ensure CWD is the package root so serveStatic finds ./public
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ process.chdir(join(__dirname, '..'));
11
+ import { authRoutes } from './routes/auth.js';
12
+ import { collectionRoutes } from './routes/collections.js';
13
+ import { entryRoutes } from './routes/entries.js';
14
+ import { mediaRoutes } from './routes/media.js';
15
+ import { userRoutes } from './routes/users.js';
16
+ import { metaRoutes } from './routes/meta.js';
17
+ import { accountRoutes } from './routes/account.js';
18
+ import { buildRoutes } from './routes/build.js';
19
+ import { searchRoutes } from './routes/search.js';
20
+ import { githubRoutes } from './routes/github.js';
21
+ import { infoRoutes } from './routes/info.js';
22
+ import { importRoutes } from './routes/import.js';
23
+ import { requireAuth } from './middleware/auth.js';
24
+
25
+ const POD_PATH = process.env.ORBITER_POD;
26
+ if (!POD_PATH) {
27
+ console.error('Error: ORBITER_POD environment variable is required.');
28
+ console.error('Example: ORBITER_POD=/path/to/content.pod npm start');
29
+ process.exit(1);
30
+ }
31
+
32
+ const PORT = parseInt(process.env.PORT ?? '4322', 10);
33
+ const ALLOWED_ORIGINS = process.env.ADMIN_ORIGIN
34
+ ? process.env.ADMIN_ORIGIN.split(',').map(s => s.trim())
35
+ : ['http://localhost:4321', 'http://localhost:4322'];
36
+
37
+ export function createApp(podPath) {
38
+ const app = new Hono();
39
+
40
+ // Inject pod path into every request context
41
+ app.use('*', async (c, next) => {
42
+ c.set('podPath', podPath);
43
+ await next();
44
+ });
45
+
46
+ app.use('*', cors({
47
+ origin: ALLOWED_ORIGINS,
48
+ credentials: true,
49
+ }));
50
+
51
+ // Public routes
52
+ app.route('/api/auth', authRoutes);
53
+
54
+ // Protected routes
55
+ const api = new Hono();
56
+ api.use('*', requireAuth);
57
+ api.route('/collections', collectionRoutes);
58
+ api.route('/collections', entryRoutes);
59
+ api.route('/media', mediaRoutes);
60
+ api.route('/users', userRoutes);
61
+ api.route('/meta', metaRoutes);
62
+ api.route('/account', accountRoutes);
63
+ api.route('/build', buildRoutes);
64
+ api.route('/search', searchRoutes);
65
+ api.route('/github', githubRoutes);
66
+ api.route('/info', infoRoutes);
67
+ api.route('/import', importRoutes);
68
+
69
+ app.route('/api', api);
70
+
71
+ app.get('/health', (c) => c.json({ ok: true, pod: podPath }));
72
+
73
+ // Redirect root to login
74
+ app.get('/', (c) => c.redirect('/login.html'));
75
+
76
+ // Serve static frontend files from public/
77
+ app.use('/*', serveStatic({ root: './public' }));
78
+
79
+ return app;
80
+ }
81
+
82
+ serve({ fetch: createApp(POD_PATH).fetch, port: PORT }, () => {
83
+ console.log(`Orbiter Admin API → http://localhost:${PORT}`);
84
+ console.log(`Pod: ${POD_PATH}`);
85
+ });