@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.
- package/README.md +115 -0
- package/package.json +33 -0
- package/public/admin-utils.js +302 -0
- package/public/build.html +129 -0
- package/public/collections.html +100 -0
- package/public/dashboard.html +478 -0
- package/public/editor.html +1569 -0
- package/public/entries.html +367 -0
- package/public/favicon.svg +6 -0
- package/public/import.html +514 -0
- package/public/login.html +76 -0
- package/public/media.html +233 -0
- package/public/router.js +142 -0
- package/public/schema.html +366 -0
- package/public/search.js +209 -0
- package/public/settings.html +688 -0
- package/public/sidebar.js +90 -0
- package/public/style.css +1020 -0
- package/public/theme.js +63 -0
- package/public/users.html +192 -0
- package/src/index.js +4 -0
- package/src/middleware/auth.js +20 -0
- package/src/routes/account.js +41 -0
- package/src/routes/auth.js +55 -0
- package/src/routes/build.js +25 -0
- package/src/routes/collections.js +65 -0
- package/src/routes/entries.js +103 -0
- package/src/routes/github.js +133 -0
- package/src/routes/import.js +120 -0
- package/src/routes/info.js +19 -0
- package/src/routes/media.js +95 -0
- package/src/routes/meta.js +54 -0
- package/src/routes/search.js +62 -0
- package/src/routes/users.js +46 -0
- package/src/server.js +85 -0
- package/src/wp-importer.js +299 -0
package/public/theme.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orbiter theme engine — runs inline in <head> before first paint.
|
|
3
|
+
* Manages: palette (space/zen/catppuccin) × scheme (dark/light/auto).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Console easter egg ──────────────────────────────────────────────
|
|
7
|
+
(function () {
|
|
8
|
+
var a = 'color:#1898f8;font-size:18px;font-weight:600;letter-spacing:6px;font-family:monospace';
|
|
9
|
+
var b = 'color:#00c8a0;font-size:11px;font-family:monospace;line-height:1.8';
|
|
10
|
+
var c = 'color:#4a7098;font-size:10px;font-family:monospace';
|
|
11
|
+
var d = 'color:#e85870;font-size:10px;font-family:monospace';
|
|
12
|
+
console.log('%c⊙ ORBITER', a);
|
|
13
|
+
console.log('%cStandalone Admin — Content Management System\ngithub.com/aeon022/orbiter · MIT License', b);
|
|
14
|
+
console.log('%c─────────────────────────────────────────────', c);
|
|
15
|
+
console.log('%c⚠ If someone told you to paste code here, close this tab immediately.', d);
|
|
16
|
+
})();
|
|
17
|
+
(function () {
|
|
18
|
+
var theme = localStorage.getItem('orb_theme') || 'space';
|
|
19
|
+
var scheme = localStorage.getItem('orb_scheme') || 'auto';
|
|
20
|
+
var style = localStorage.getItem('orb_style') || 'glass';
|
|
21
|
+
|
|
22
|
+
var root = document.documentElement;
|
|
23
|
+
if (theme !== 'space') root.setAttribute('data-theme', theme);
|
|
24
|
+
if (scheme === 'dark') root.setAttribute('data-scheme', 'dark');
|
|
25
|
+
if (scheme === 'light') root.setAttribute('data-scheme', 'light');
|
|
26
|
+
if (style === 'glass') root.setAttribute('data-style', 'glass');
|
|
27
|
+
|
|
28
|
+
// Wire up toggle button once DOM is ready
|
|
29
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
30
|
+
var btn = document.getElementById('scheme-toggle');
|
|
31
|
+
if (!btn) return;
|
|
32
|
+
|
|
33
|
+
function updateBtn() {
|
|
34
|
+
var s = localStorage.getItem('orb_scheme') || 'auto';
|
|
35
|
+
if (s === 'auto') {
|
|
36
|
+
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
37
|
+
btn.textContent = prefersDark ? '◐' : '◑';
|
|
38
|
+
btn.title = 'Auto (' + (prefersDark ? 'dark' : 'light') + ') — click to override';
|
|
39
|
+
} else if (s === 'dark') {
|
|
40
|
+
btn.textContent = '●';
|
|
41
|
+
btn.title = 'Dark — click for light';
|
|
42
|
+
} else {
|
|
43
|
+
btn.textContent = '○';
|
|
44
|
+
btn.title = 'Light — click for auto';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
btn.addEventListener('click', function () {
|
|
49
|
+
var s = localStorage.getItem('orb_scheme') || 'auto';
|
|
50
|
+
var next = s === 'auto' ? 'dark' : s === 'dark' ? 'light' : 'auto';
|
|
51
|
+
localStorage.setItem('orb_scheme', next);
|
|
52
|
+
var root = document.documentElement;
|
|
53
|
+
root.removeAttribute('data-scheme');
|
|
54
|
+
if (next === 'dark') root.setAttribute('data-scheme', 'dark');
|
|
55
|
+
if (next === 'light') root.setAttribute('data-scheme', 'light');
|
|
56
|
+
updateBtn();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// React to system changes when in auto mode
|
|
60
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateBtn);
|
|
61
|
+
updateBtn();
|
|
62
|
+
});
|
|
63
|
+
})();
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Orbiter Admin — Users</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Space+Grotesk:wght@300;400;500;600&family=Noto+Serif+JP:wght@200;300&family=DM+Mono:wght@300;400&display=swap" rel="stylesheet">
|
|
10
|
+
<link rel="stylesheet" href="/style.css" />
|
|
11
|
+
<script src="/theme.js"></script>
|
|
12
|
+
<style>
|
|
13
|
+
.user-table { border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; margin-bottom:28px; }
|
|
14
|
+
.user-head { display:grid; grid-template-columns:1fr 90px 160px 80px; padding:7px 16px; background:var(--bg2); border-bottom:1px solid var(--line); }
|
|
15
|
+
.user-head-cell { font-size:9px; letter-spacing:0.2em; text-transform:uppercase; color:var(--muted); }
|
|
16
|
+
.user-row { display:grid; grid-template-columns:1fr 90px 160px 80px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--line2); background:var(--bg1); }
|
|
17
|
+
.user-row:last-child { border-bottom:none; }
|
|
18
|
+
.user-row.is-self { background:var(--accent-bg); }
|
|
19
|
+
.user-name { font-size:12px; color:var(--text); display:flex; align-items:center; gap:8px; }
|
|
20
|
+
.self-tag { font-size:9px; color:var(--accent); background:var(--accent-bg); border:1px solid rgba(90,122,240,.2); padding:1px 6px; border-radius:2px; }
|
|
21
|
+
.role-badge { font-size:9px; padding:2px 8px; letter-spacing:0.06em; border-radius:2px; }
|
|
22
|
+
.role-badge.admin { color:var(--gold); background:var(--gold-bg); border:1px solid rgba(200,168,107,.2); }
|
|
23
|
+
.role-badge.editor { color:var(--muted); background:var(--bg3); border:1px solid var(--line); }
|
|
24
|
+
.user-date { font-size:11px; color:var(--mid); }
|
|
25
|
+
.btn-del { padding:3px 10px; background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:9px; cursor:pointer; transition:all .12s; border-radius:2px; }
|
|
26
|
+
.btn-del:hover:not(:disabled) { border-color:var(--red); color:var(--red); background:rgba(139,38,53,.04); }
|
|
27
|
+
.btn-del:disabled { opacity:.3; cursor:default; }
|
|
28
|
+
|
|
29
|
+
.add-group { background:var(--bg2); border:1px solid var(--line); border-radius:var(--radius); }
|
|
30
|
+
.group-header { padding:10px 20px; border-bottom:1px solid var(--line); font-size:9px; letter-spacing:0.28em; text-transform:uppercase; color:var(--muted); display:flex; align-items:center; gap:8px; }
|
|
31
|
+
.group-header::before { content:"—"; color:var(--gold); }
|
|
32
|
+
.add-row { display:grid; grid-template-columns:1fr 1fr 120px; gap:12px; padding:16px 20px; align-items:end; }
|
|
33
|
+
.add-field { display:flex; flex-direction:column; gap:5px; }
|
|
34
|
+
.add-label { font-size:9px; letter-spacing:0.14em; text-transform:uppercase; color:var(--muted); }
|
|
35
|
+
.banner { padding:8px 16px; font-size:10px; display:flex; align-items:center; gap:6px; border-radius:var(--radius); margin-bottom:14px; }
|
|
36
|
+
.banner-ok { background:var(--jade-bg); color:var(--jade); border:1px solid rgba(45,139,106,.2); }
|
|
37
|
+
.banner-ok::before { content:"✓"; }
|
|
38
|
+
.banner-err { background:rgba(139,38,53,.07); color:var(--red); border:1px solid rgba(139,38,53,.15); }
|
|
39
|
+
.banner-err::before { content:"✕"; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div class="app">
|
|
44
|
+
<header class="topbar">
|
|
45
|
+
<a class="logo" href="/dashboard.html"><div class="logo-mark">OR</div>Orbiter</a>
|
|
46
|
+
<div class="topbar-right">
|
|
47
|
+
<button class="search-trigger" id="search-btn" title="Search (⌘K)">
|
|
48
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
49
|
+
Search <kbd>⌘K</kbd>
|
|
50
|
+
</button>
|
|
51
|
+
<button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
|
|
52
|
+
<span class="user" id="topbar-user"></span>
|
|
53
|
+
<span class="logout" id="logout-btn">Sign out</span>
|
|
54
|
+
</div>
|
|
55
|
+
</header>
|
|
56
|
+
<nav class="sidebar">
|
|
57
|
+
<div class="nav-section">Content</div>
|
|
58
|
+
<a class="nav-item" href="/dashboard.html"><span class="nav-icon">◈</span>Dashboard</a>
|
|
59
|
+
<a class="nav-item" href="/collections.html"><span class="nav-icon">⊞</span>Collections</a>
|
|
60
|
+
<div class="nav-section">Assets</div>
|
|
61
|
+
<a class="nav-item" href="/media.html"><span class="nav-icon">⊟</span>Media</a>
|
|
62
|
+
<div class="nav-section">System</div>
|
|
63
|
+
<a class="nav-item" href="/schema.html"><span class="nav-icon">◈</span>Schema</a>
|
|
64
|
+
<a class="nav-item" href="/build.html"><span class="nav-icon">▲</span>Build</a>
|
|
65
|
+
<a class="nav-item" href="/settings.html"><span class="nav-icon">◎</span>Settings</a>
|
|
66
|
+
<a class="nav-item active admin-only" href="/users.html" style="display:none"><span class="nav-icon">◉</span>Users</a>
|
|
67
|
+
<div class="sidebar-footer">
|
|
68
|
+
<div class="pod-name" id="pod-name">content.pod</div>
|
|
69
|
+
<div class="pod-info" id="pod-info"></div>
|
|
70
|
+
<div class="pod-status"><span class="pod-dot"></span>pod synced</div>
|
|
71
|
+
</div>
|
|
72
|
+
</nav>
|
|
73
|
+
<main class="main">
|
|
74
|
+
<div class="page-header">
|
|
75
|
+
<h1 class="page-title">Users</h1>
|
|
76
|
+
<p class="page-sub" id="user-count"></p>
|
|
77
|
+
</div>
|
|
78
|
+
<div id="banner" class="banner" style="display:none"></div>
|
|
79
|
+
<div id="content"><div class="empty"><div class="spinner"></div></div></div>
|
|
80
|
+
|
|
81
|
+
<script type="module">
|
|
82
|
+
const me = await fetch('/api/auth/me',{credentials:'include'}).then(r=>r.json()).catch(()=>null);
|
|
83
|
+
if (!me?.user) { location.replace('/login.html'); }
|
|
84
|
+
if (me.user.role !== 'admin') { location.replace('/dashboard.html'); }
|
|
85
|
+
document.getElementById('topbar-user').textContent = me.user.username;
|
|
86
|
+
document.querySelectorAll('.admin-only').forEach(el=>el.style.display='');
|
|
87
|
+
document.getElementById('logout-btn').addEventListener('click',async()=>{
|
|
88
|
+
await fetch('/api/auth/logout',{method:'POST',credentials:'include'});
|
|
89
|
+
location.replace('/login.html');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function showBanner(cls, text) {
|
|
93
|
+
const el = document.getElementById('banner');
|
|
94
|
+
el.className = 'banner ' + cls;
|
|
95
|
+
el.textContent = text;
|
|
96
|
+
el.style.display = '';
|
|
97
|
+
setTimeout(()=>el.style.display='none', 3000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function loadUsers() {
|
|
101
|
+
const users = await fetch('/api/users',{credentials:'include'}).then(r=>r.json()).catch(()=>[]);
|
|
102
|
+
document.getElementById('user-count').textContent = `${users.length} user${users.length!==1?'s':''}`;
|
|
103
|
+
|
|
104
|
+
const wrap = document.getElementById('content');
|
|
105
|
+
if (!users.length) {
|
|
106
|
+
wrap.innerHTML = '<div class="empty"><div class="empty-icon">◉</div>No users yet</div>';
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
wrap.innerHTML = `
|
|
110
|
+
<div class="user-table">
|
|
111
|
+
<div class="user-head">
|
|
112
|
+
<div class="user-head-cell">Username</div>
|
|
113
|
+
<div class="user-head-cell">Role</div>
|
|
114
|
+
<div class="user-head-cell">Last login</div>
|
|
115
|
+
<div class="user-head-cell"></div>
|
|
116
|
+
</div>
|
|
117
|
+
${users.map(u=>`
|
|
118
|
+
<div class="user-row ${u.id===me.user.id?'is-self':''}">
|
|
119
|
+
<div class="user-name">${u.username}${u.id===me.user.id?'<span class="self-tag">you</span>':''}</div>
|
|
120
|
+
<div><span class="role-badge ${u.role}">${u.role}</span></div>
|
|
121
|
+
<div class="user-date">${u.last_login ? new Date(u.last_login).toLocaleDateString() : '—'}</div>
|
|
122
|
+
<div>
|
|
123
|
+
<button class="btn-del" data-id="${u.id}" ${u.id===me.user.id?'disabled':''}>Delete</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
`).join('')}
|
|
127
|
+
</div>
|
|
128
|
+
${addForm()}
|
|
129
|
+
`;
|
|
130
|
+
attachAddForm();
|
|
131
|
+
wrap.querySelectorAll('.btn-del').forEach(btn=>btn.addEventListener('click', async ()=>{
|
|
132
|
+
if (!confirm('Delete this user?')) return;
|
|
133
|
+
const res = await fetch(`/api/users/${btn.dataset.id}`,{method:'DELETE',credentials:'include'});
|
|
134
|
+
if (res.ok) { showBanner('banner-ok ✓ User deleted'); loadUsers(); }
|
|
135
|
+
else showBanner('banner-err', '✕ Could not delete user');
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function addForm() {
|
|
140
|
+
return `
|
|
141
|
+
<div class="add-group">
|
|
142
|
+
<div class="group-header">Add user</div>
|
|
143
|
+
<form id="add-user-form">
|
|
144
|
+
<div class="add-row">
|
|
145
|
+
<div class="add-field">
|
|
146
|
+
<label class="add-label">Username</label>
|
|
147
|
+
<input class="input" type="text" name="username" autocomplete="off" required placeholder="username" />
|
|
148
|
+
</div>
|
|
149
|
+
<div class="add-field">
|
|
150
|
+
<label class="add-label">Password — min 8 chars</label>
|
|
151
|
+
<input class="input" type="password" name="password" autocomplete="new-password" required minlength="8" />
|
|
152
|
+
</div>
|
|
153
|
+
<div class="add-field">
|
|
154
|
+
<label class="add-label">Role</label>
|
|
155
|
+
<select class="input" name="role">
|
|
156
|
+
<option value="editor">editor</option>
|
|
157
|
+
<option value="admin">admin</option>
|
|
158
|
+
</select>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div style="padding:0 20px 16px;display:flex;justify-content:flex-end;">
|
|
162
|
+
<button type="submit" class="btn btn-primary">+ Add user</button>
|
|
163
|
+
</div>
|
|
164
|
+
</form>
|
|
165
|
+
</div>
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function attachAddForm() {
|
|
170
|
+
document.getElementById('add-user-form').addEventListener('submit', async e=>{
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
const fd = new FormData(e.target);
|
|
173
|
+
const res = await fetch('/api/users',{
|
|
174
|
+
method:'POST', credentials:'include',
|
|
175
|
+
headers:{'Content-Type':'application/json'},
|
|
176
|
+
body: JSON.stringify({ username: fd.get('username'), password: fd.get('password'), role: fd.get('role') }),
|
|
177
|
+
});
|
|
178
|
+
const json = await res.json();
|
|
179
|
+
if (res.ok) { showBanner('banner-ok','✓ User created'); e.target.reset(); loadUsers(); }
|
|
180
|
+
else showBanner('banner-err','✕ ' + (json.error ?? 'Failed'));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
loadUsers();
|
|
185
|
+
</script>
|
|
186
|
+
</main>
|
|
187
|
+
</div>
|
|
188
|
+
<script src="/search.js"></script>
|
|
189
|
+
<script src="/sidebar.js"></script>
|
|
190
|
+
<script src="/router.js"></script>
|
|
191
|
+
</body>
|
|
192
|
+
</html>
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getCookie } from 'hono/cookie';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
export const requireAuth = async (c, next) => {
|
|
5
|
+
const token = getCookie(c, 'orb_sess') ?? '';
|
|
6
|
+
const db = openPod(c.get('podPath'));
|
|
7
|
+
const user = db.checkSession(token);
|
|
8
|
+
db.close();
|
|
9
|
+
|
|
10
|
+
if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
|
11
|
+
|
|
12
|
+
c.set('user', user);
|
|
13
|
+
await next();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const requireAdmin = async (c, next) => {
|
|
17
|
+
const user = c.get('user');
|
|
18
|
+
if (user?.role !== 'admin') return c.json({ error: 'Forbidden' }, 403);
|
|
19
|
+
await next();
|
|
20
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { openPod, verifyPassword, hashPassword } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
export const accountRoutes = new Hono();
|
|
5
|
+
|
|
6
|
+
// PUT /api/account/password
|
|
7
|
+
accountRoutes.put('/password', async (c) => {
|
|
8
|
+
const user = c.get('user');
|
|
9
|
+
const { currentPassword, newPassword } = await c.req.json();
|
|
10
|
+
if (!currentPassword || !newPassword) return c.json({ error: 'Missing fields' }, 400);
|
|
11
|
+
if (newPassword.length < 8) return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
|
12
|
+
|
|
13
|
+
const db = openPod(c.get('podPath'));
|
|
14
|
+
const full = db.getUserByUsername(user.username);
|
|
15
|
+
const ok = await verifyPassword(currentPassword, full.password);
|
|
16
|
+
if (!ok) { db.close(); return c.json({ error: 'Current password is incorrect' }, 401); }
|
|
17
|
+
|
|
18
|
+
const hash = await hashPassword(newPassword);
|
|
19
|
+
db.db.prepare('UPDATE _users SET password = ? WHERE id = ?').run(hash, user.id);
|
|
20
|
+
db.close();
|
|
21
|
+
return c.json({ ok: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// PUT /api/account/username
|
|
25
|
+
accountRoutes.put('/username', async (c) => {
|
|
26
|
+
const user = c.get('user');
|
|
27
|
+
const { newUsername, currentPassword } = await c.req.json();
|
|
28
|
+
if (!newUsername || !currentPassword) return c.json({ error: 'Missing fields' }, 400);
|
|
29
|
+
|
|
30
|
+
const db = openPod(c.get('podPath'));
|
|
31
|
+
const full = db.getUserByUsername(user.username);
|
|
32
|
+
const ok = await verifyPassword(currentPassword, full.password);
|
|
33
|
+
if (!ok) { db.close(); return c.json({ error: 'Current password is incorrect' }, 401); }
|
|
34
|
+
|
|
35
|
+
const taken = db.db.prepare('SELECT id FROM _users WHERE username = ? AND id != ?').get(newUsername, user.id);
|
|
36
|
+
if (taken) { db.close(); return c.json({ error: 'Username already taken' }, 409); }
|
|
37
|
+
|
|
38
|
+
db.db.prepare('UPDATE _users SET username = ? WHERE id = ?').run(newUsername, user.id);
|
|
39
|
+
db.close();
|
|
40
|
+
return c.json({ ok: true });
|
|
41
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
|
3
|
+
import { openPod, verifyPassword, generateToken } from '@a83/orbiter-core';
|
|
4
|
+
|
|
5
|
+
export const authRoutes = new Hono();
|
|
6
|
+
|
|
7
|
+
// POST /api/auth/login
|
|
8
|
+
authRoutes.post('/login', async (c) => {
|
|
9
|
+
const { username, password } = await c.req.json();
|
|
10
|
+
if (!username || !password) return c.json({ error: 'Missing credentials' }, 400);
|
|
11
|
+
|
|
12
|
+
const db = openPod(c.get('podPath'));
|
|
13
|
+
const user = db.getUserByUsername(username);
|
|
14
|
+
if (!user || !(await verifyPassword(password, user.password))) {
|
|
15
|
+
db.close();
|
|
16
|
+
return c.json({ error: 'Invalid username or password' }, 401);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const token = generateToken();
|
|
20
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
|
21
|
+
.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
22
|
+
db.createSession(user.id, token, expiresAt);
|
|
23
|
+
db.close();
|
|
24
|
+
|
|
25
|
+
setCookie(c, 'orb_sess', token, {
|
|
26
|
+
httpOnly: true,
|
|
27
|
+
sameSite: 'Lax',
|
|
28
|
+
path: '/',
|
|
29
|
+
maxAge: 30 * 24 * 60 * 60,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return c.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// POST /api/auth/logout
|
|
36
|
+
authRoutes.post('/logout', (c) => {
|
|
37
|
+
const token = getCookie(c, 'orb_sess') ?? '';
|
|
38
|
+
if (token) {
|
|
39
|
+
const db = openPod(c.get('podPath'));
|
|
40
|
+
db.deleteSession(token);
|
|
41
|
+
db.close();
|
|
42
|
+
}
|
|
43
|
+
deleteCookie(c, 'orb_sess');
|
|
44
|
+
return c.json({ ok: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// GET /api/auth/me
|
|
48
|
+
authRoutes.get('/me', (c) => {
|
|
49
|
+
const token = getCookie(c, 'orb_sess') ?? '';
|
|
50
|
+
const db = openPod(c.get('podPath'));
|
|
51
|
+
const user = db.checkSession(token);
|
|
52
|
+
db.close();
|
|
53
|
+
if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
|
54
|
+
return c.json({ user });
|
|
55
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
export const buildRoutes = new Hono();
|
|
5
|
+
|
|
6
|
+
// POST /api/build/trigger
|
|
7
|
+
buildRoutes.post('/trigger', async (c) => {
|
|
8
|
+
const db = openPod(c.get('podPath'));
|
|
9
|
+
const url = db.getMeta('build.webhook_url') ?? '';
|
|
10
|
+
db.close();
|
|
11
|
+
if (!url) return c.json({ error: 'No webhook URL configured' }, 400);
|
|
12
|
+
|
|
13
|
+
const res = await fetch(url, { method: 'POST' }).catch(e => ({ ok: false, status: 0, err: e.message }));
|
|
14
|
+
if (!res.ok) return c.json({ error: `Webhook returned ${res.status}` }, 502);
|
|
15
|
+
return c.json({ ok: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// GET /api/build/status
|
|
19
|
+
buildRoutes.get('/status', (c) => {
|
|
20
|
+
const db = openPod(c.get('podPath'));
|
|
21
|
+
const webhook = db.getMeta('build.webhook_url') ?? '';
|
|
22
|
+
const last = db.getMeta('build.last_triggered') ?? null;
|
|
23
|
+
db.close();
|
|
24
|
+
return c.json({ configured: !!webhook, lastTriggered: last });
|
|
25
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
import { requireAdmin } from '../middleware/auth.js';
|
|
4
|
+
|
|
5
|
+
export const collectionRoutes = new Hono();
|
|
6
|
+
|
|
7
|
+
// GET /api/collections
|
|
8
|
+
collectionRoutes.get('/', (c) => {
|
|
9
|
+
const db = openPod(c.get('podPath'));
|
|
10
|
+
const cols = db.getCollections().map(col => ({
|
|
11
|
+
...col,
|
|
12
|
+
schema: col.schema ? JSON.parse(col.schema) : {},
|
|
13
|
+
total: db.getEntries(col.id).length,
|
|
14
|
+
}));
|
|
15
|
+
db.close();
|
|
16
|
+
return c.json(cols);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// GET /api/collections/:id
|
|
20
|
+
collectionRoutes.get('/:id', (c) => {
|
|
21
|
+
const db = openPod(c.get('podPath'));
|
|
22
|
+
const col = db.getCollection(c.req.param('id'));
|
|
23
|
+
db.close();
|
|
24
|
+
if (!col) return c.json({ error: 'Not found' }, 404);
|
|
25
|
+
return c.json({ ...col, schema: col.schema ? JSON.parse(col.schema) : {} });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// POST /api/collections (admin only)
|
|
29
|
+
collectionRoutes.post('/', requireAdmin, async (c) => {
|
|
30
|
+
const { id, label, schema = {} } = await c.req.json();
|
|
31
|
+
if (!id || !label) return c.json({ error: 'id and label are required' }, 400);
|
|
32
|
+
|
|
33
|
+
const safeId = id.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
|
|
34
|
+
const db = openPod(c.get('podPath'));
|
|
35
|
+
if (db.getCollection(safeId)) {
|
|
36
|
+
db.close();
|
|
37
|
+
return c.json({ error: `Collection "${safeId}" already exists` }, 409);
|
|
38
|
+
}
|
|
39
|
+
db.createCollection(safeId, label, schema);
|
|
40
|
+
const created = db.getCollection(safeId);
|
|
41
|
+
db.close();
|
|
42
|
+
return c.json({ ...created, schema }, 201);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// PUT /api/collections/:id (admin only)
|
|
46
|
+
collectionRoutes.put('/:id', requireAdmin, async (c) => {
|
|
47
|
+
const { label, schema } = await c.req.json();
|
|
48
|
+
const db = openPod(c.get('podPath'));
|
|
49
|
+
const col = db.getCollection(c.req.param('id'));
|
|
50
|
+
if (!col) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
51
|
+
db.updateCollection(col.id, label ?? col.label, schema ?? JSON.parse(col.schema ?? '{}'));
|
|
52
|
+
const updated = db.getCollection(col.id);
|
|
53
|
+
db.close();
|
|
54
|
+
return c.json({ ...updated, schema: updated.schema ? JSON.parse(updated.schema) : {} });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// DELETE /api/collections/:id (admin only)
|
|
58
|
+
collectionRoutes.delete('/:id', requireAdmin, (c) => {
|
|
59
|
+
const db = openPod(c.get('podPath'));
|
|
60
|
+
const col = db.getCollection(c.req.param('id'));
|
|
61
|
+
if (!col) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
62
|
+
db.deleteCollection(col.id);
|
|
63
|
+
db.close();
|
|
64
|
+
return c.json({ ok: true });
|
|
65
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
export const entryRoutes = new Hono();
|
|
5
|
+
|
|
6
|
+
// GET /api/collections/:id/entries?status=draft|published
|
|
7
|
+
entryRoutes.get('/:collectionId/entries', (c) => {
|
|
8
|
+
const { collectionId } = c.req.param();
|
|
9
|
+
const status = c.req.query('status') || undefined;
|
|
10
|
+
const db = openPod(c.get('podPath'));
|
|
11
|
+
if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
12
|
+
const entries = db.getEntries(collectionId, { status });
|
|
13
|
+
db.close();
|
|
14
|
+
return c.json(entries);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// GET /api/collections/:id/entries/:slug
|
|
18
|
+
entryRoutes.get('/:collectionId/entries/:slug', (c) => {
|
|
19
|
+
const { collectionId, slug } = c.req.param();
|
|
20
|
+
const db = openPod(c.get('podPath'));
|
|
21
|
+
const entry = db.getEntry(collectionId, slug);
|
|
22
|
+
db.close();
|
|
23
|
+
if (!entry) return c.json({ error: 'Not found' }, 404);
|
|
24
|
+
return c.json(entry);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// POST /api/collections/:id/entries
|
|
28
|
+
entryRoutes.post('/:collectionId/entries', async (c) => {
|
|
29
|
+
const { collectionId } = c.req.param();
|
|
30
|
+
const { slug, data = {}, status = 'draft' } = await c.req.json();
|
|
31
|
+
if (!slug) return c.json({ error: 'slug is required' }, 400);
|
|
32
|
+
|
|
33
|
+
const db = openPod(c.get('podPath'));
|
|
34
|
+
if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
35
|
+
if (db.getEntry(collectionId, slug)) { db.close(); return c.json({ error: `Entry "${slug}" already exists` }, 409); }
|
|
36
|
+
|
|
37
|
+
const id = db.createEntry(collectionId, slug, data, status);
|
|
38
|
+
const entry = db.getEntry(collectionId, slug);
|
|
39
|
+
db.close();
|
|
40
|
+
return c.json({ ...entry, id }, 201);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// PUT /api/collections/:id/entries/:slug
|
|
44
|
+
entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
|
|
45
|
+
const { collectionId, slug } = c.req.param();
|
|
46
|
+
const body = await c.req.json();
|
|
47
|
+
|
|
48
|
+
const db = openPod(c.get('podPath'));
|
|
49
|
+
const ok = db.updateEntry(collectionId, slug, body);
|
|
50
|
+
if (!ok) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
51
|
+
const updated = db.getEntry(collectionId, body.slug ?? slug);
|
|
52
|
+
db.close();
|
|
53
|
+
return c.json(updated);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// DELETE /api/collections/:id/entries/:slug
|
|
57
|
+
entryRoutes.delete('/:collectionId/entries/:slug', (c) => {
|
|
58
|
+
const { collectionId, slug } = c.req.param();
|
|
59
|
+
const db = openPod(c.get('podPath'));
|
|
60
|
+
const ok = db.deleteEntry(collectionId, slug);
|
|
61
|
+
db.close();
|
|
62
|
+
if (!ok) return c.json({ error: 'Not found' }, 404);
|
|
63
|
+
return c.json({ ok: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// GET /api/collections/:id/entries/:slug/versions
|
|
67
|
+
entryRoutes.get('/:collectionId/entries/:slug/versions', (c) => {
|
|
68
|
+
const { collectionId, slug } = c.req.param();
|
|
69
|
+
const db = openPod(c.get('podPath'));
|
|
70
|
+
const entry = db.getEntry(collectionId, slug);
|
|
71
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
72
|
+
const versions = db.db
|
|
73
|
+
.prepare('SELECT id, created_at FROM _versions WHERE entry_id = ? ORDER BY created_at DESC LIMIT 20')
|
|
74
|
+
.all(entry.id);
|
|
75
|
+
db.close();
|
|
76
|
+
return c.json(versions);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// POST /api/collections/:id/entries/:slug/duplicate
|
|
80
|
+
entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
|
|
81
|
+
const { collectionId, slug } = c.req.param();
|
|
82
|
+
const db = openPod(c.get('podPath'));
|
|
83
|
+
const entry = db.getEntry(collectionId, slug);
|
|
84
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
85
|
+
const newSlug = slug + '-copy';
|
|
86
|
+
db.createEntry(collectionId, newSlug, entry.data, 'draft');
|
|
87
|
+
const created = db.getEntry(collectionId, newSlug);
|
|
88
|
+
db.close();
|
|
89
|
+
return c.json(created, 201);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// PATCH /api/collections/:id/entries/:slug/status
|
|
93
|
+
entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
|
|
94
|
+
const { collectionId, slug } = c.req.param();
|
|
95
|
+
const { status } = await c.req.json();
|
|
96
|
+
if (!['draft', 'published'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
|
|
97
|
+
const db = openPod(c.get('podPath'));
|
|
98
|
+
const entry = db.getEntry(collectionId, slug);
|
|
99
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
100
|
+
db.updateEntry(collectionId, slug, { slug, data: entry.data, status });
|
|
101
|
+
db.close();
|
|
102
|
+
return c.json({ ok: true });
|
|
103
|
+
});
|