Skip to content

Commit 3aadc95

Browse files
committed
refactor(admin): split admin handler into modules
Extract admin orchestration, credential logic, and public auth flow into dedicated handler and service modules. Keep admin.js as a thin entrypoint that re-exports handleAdminRequest and internals for tests. This improves separation of concerns and maintainability without changing user-facing behavior.
1 parent f84be67 commit 3aadc95

12 files changed

Lines changed: 1284 additions & 1138 deletions

src/handlers/admin.js

Lines changed: 18 additions & 1138 deletions
Large diffs are not rendered by default.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { ConfigService } from '../../services/config.js';
2+
import { response } from '../../utils.js';
3+
import { verifyJwt, refreshJwt, getAuthCookie, createAuthCookie } from '../../services/auth.js';
4+
import { getOrCreateJwtSecretForInitializedAdmin } from '../../services/admin/session-service.js';
5+
import {
6+
handlePublicAdminApiRequest,
7+
isAdminInitialized,
8+
isInitSecretConfigured
9+
} from './public-controller.js';
10+
import { handleProtectedAdminApiRequest } from './protected-api-controller.js';
11+
import { fetchAdminAsset, isAdminEntryPage, isAdminInitPage } from './page-controller.js';
12+
13+
export async function handleAdminRequest(request, logger) {
14+
const url = new URL(request.url);
15+
const { ASSETS } = ConfigService.getEnv();
16+
if (!ASSETS) {
17+
logger.fatal('ASSETS binding is not configured.');
18+
return response.json({ error: 'ASSETS binding is not configured.' }, 500);
19+
}
20+
21+
const initialized = isAdminInitialized();
22+
const initSecretConfigured = isInitSecretConfigured();
23+
24+
const publicApiResponse = await handlePublicAdminApiRequest(request, logger, {
25+
initialized,
26+
initSecretConfigured
27+
});
28+
if (publicApiResponse) {
29+
return publicApiResponse;
30+
}
31+
32+
if (!initialized) {
33+
if (!initSecretConfigured) {
34+
logger.fatal('INIT_SECRET is required before admin initialization.');
35+
return response.normal('INIT_SECRET is not configured.', 500, { 'Set-Cookie': createAuthCookie('invalid', 0) }, 'text/plain; charset=utf-8');
36+
}
37+
38+
if (url.pathname.startsWith('/admin/api/')) {
39+
return response.json({ error: 'Admin is not initialized. Please complete initial setup first.' }, 403);
40+
}
41+
42+
if (isAdminEntryPage(url.pathname) || isAdminInitPage(url.pathname)) {
43+
return fetchAdminAsset(request, '/admin/init.html', logger, 200, { 'Set-Cookie': createAuthCookie('invalid', 0) });
44+
}
45+
46+
return response.normal('Admin is not initialized yet.', 403, { 'Set-Cookie': createAuthCookie('invalid', 0) }, 'text/plain; charset=utf-8');
47+
}
48+
49+
const jwtSecret = await getOrCreateJwtSecretForInitializedAdmin(logger);
50+
if (!jwtSecret) {
51+
logger.fatal('JWT secret is not configured for initialized admin.');
52+
return response.json(
53+
{ error: 'JWT secret is not configured for initialized admin.' },
54+
500,
55+
{ 'Set-Cookie': createAuthCookie('invalid', 0) }
56+
);
57+
}
58+
59+
const token = getAuthCookie(request, logger);
60+
const isValid = await verifyJwt(jwtSecret, token, logger);
61+
62+
if (isValid) {
63+
const newToken = await refreshJwt(jwtSecret, token, logger);
64+
const cookie = createAuthCookie(newToken, 8 * 60 * 60);
65+
if (url.pathname.startsWith('/admin/api/')) {
66+
const apiResponse = await handleProtectedAdminApiRequest(request, logger);
67+
if (apiResponse.headers.has('Set-Cookie')) {
68+
return apiResponse;
69+
}
70+
71+
const headers = new Headers(apiResponse.headers);
72+
headers.set('Set-Cookie', cookie);
73+
return new Response(apiResponse.body, {
74+
status: apiResponse.status,
75+
statusText: apiResponse.statusText,
76+
headers
77+
});
78+
}
79+
80+
if (isAdminInitPage(url.pathname)) {
81+
return fetchAdminAsset(request, '/admin/index.html', logger, 200, { 'Set-Cookie': cookie });
82+
}
83+
84+
if (isAdminEntryPage(url.pathname)) {
85+
return fetchAdminAsset(request, '/admin/index.html', logger, 200, { 'Set-Cookie': cookie });
86+
}
87+
88+
return fetchAdminAsset(request, url.pathname, logger, null, { 'Set-Cookie': cookie });
89+
}
90+
91+
const expiredCookieHeaders = { 'Set-Cookie': createAuthCookie('invalid', 0) };
92+
93+
if (url.pathname.startsWith('/admin/api/')) {
94+
return response.json({ error: 'Unauthorized' }, 401, expiredCookieHeaders);
95+
}
96+
97+
if (isAdminInitPage(url.pathname)) {
98+
return fetchAdminAsset(request, '/admin/login.html', logger, 401, expiredCookieHeaders);
99+
}
100+
101+
if (isAdminEntryPage(url.pathname)) {
102+
return fetchAdminAsset(request, '/admin/login.html', logger, 401, expiredCookieHeaders);
103+
}
104+
105+
return response.normal('Unauthorized.', 401, expiredCookieHeaders, 'text/plain; charset=utf-8');
106+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ConfigService } from '../../services/config.js';
2+
import { serveAssetResponse } from '../../utils.js';
3+
4+
export async function fetchAdminAsset(request, assetPath, logger, status = null, headers = {}) {
5+
return serveAssetResponse(request, ConfigService.getEnv().ASSETS, assetPath, logger, {
6+
status,
7+
headers,
8+
notConfiguredMessage: 'Admin asset is unavailable because ASSETS binding is not configured.',
9+
notFoundMessage: 'Admin asset not found.',
10+
fetchFailureMessage: 'Failed to fetch admin asset',
11+
logLabel: 'admin asset fetch'
12+
});
13+
}
14+
15+
export function isAdminEntryPage(pathname) {
16+
return pathname === '/admin' || pathname === '/admin/' || pathname === '/admin/index.html';
17+
}
18+
19+
export function isAdminInitPage(pathname) {
20+
return pathname === '/admin/init' || pathname === '/admin/init/' || pathname === '/admin/init.html';
21+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { ConfigService, deepMerge } from '../../services/config.js';
2+
import { response } from '../../utils.js';
3+
import { createJwt, createAuthCookie } from '../../services/auth.js';
4+
import { getGlobalConfig, saveGlobalConfig } from '../../repositories/admin/config-repository.js';
5+
import { getGroup, getAllGroups, saveGroup, deleteGroup } from '../../repositories/admin/group-repository.js';
6+
import {
7+
requiresAdminPasswordStorageUpgrade,
8+
hasConfiguredAdminPassword,
9+
getRuntimeAdminCredentials,
10+
buildAdminPasswordCredentials,
11+
isValidAdminPassword,
12+
normalizePersistedAdminCredentialFields
13+
} from '../../services/admin/credential-service.js';
14+
import { generateJwtSecret } from '../../services/admin/session-service.js';
15+
import {
16+
ADMIN_DATA_SCHEMA_VERSION,
17+
sanitizeConfigForResponse,
18+
normalizeImportPayload,
19+
buildMergedConfigForImport,
20+
syncImportedGroups
21+
} from '../../services/admin/import-export-service.js';
22+
import { Router } from 'itty-router';
23+
24+
async function readJsonBody(request) {
25+
try {
26+
return await request.json();
27+
} catch {
28+
return null;
29+
}
30+
}
31+
32+
function handleLogout() {
33+
const cookie = createAuthCookie('logged_out', 0);
34+
return response.json({ success: true }, 200, { 'Set-Cookie': cookie });
35+
}
36+
37+
export async function handleProtectedAdminApiRequest(request, logger) {
38+
const router = Router();
39+
40+
router.post('/admin/api/logout', () => handleLogout());
41+
42+
router.get('/admin/api/config', async () => {
43+
const config = await getGlobalConfig() || ConfigService.get();
44+
return response.json(sanitizeConfigForResponse(config));
45+
});
46+
47+
router.get('/admin/api/export', async () => {
48+
const config = await getGlobalConfig() || ConfigService.get();
49+
const groups = await getAllGroups();
50+
51+
return response.json({
52+
schemaVersion: ADMIN_DATA_SCHEMA_VERSION,
53+
exportedAt: new Date().toISOString(),
54+
config: sanitizeConfigForResponse(config),
55+
groups
56+
});
57+
});
58+
59+
router.post('/admin/api/import', async () => {
60+
const payload = await readJsonBody(request);
61+
if (!payload) {
62+
return response.json({ error: 'Invalid JSON payload.' }, 400);
63+
}
64+
65+
let normalizedPayload;
66+
try {
67+
normalizedPayload = normalizeImportPayload(payload);
68+
} catch (err) {
69+
return response.json({
70+
error: err instanceof Error ? err.message : 'Invalid import payload.'
71+
}, 400);
72+
}
73+
74+
let previousConfig = null;
75+
let configPersisted = false;
76+
77+
try {
78+
previousConfig = await getGlobalConfig() || {};
79+
const mergedConfig = await buildMergedConfigForImport(normalizedPayload.importedConfig);
80+
await saveGlobalConfig(mergedConfig);
81+
configPersisted = true;
82+
83+
await syncImportedGroups(normalizedPayload.importedGroups, logger);
84+
await ConfigService.init(ConfigService.getEnv(), ConfigService.getCtx());
85+
86+
logger.warn('Admin config/groups imported from JSON backup.', {
87+
importedGroups: normalizedPayload.importedGroups.length
88+
}, { notify: true });
89+
90+
return response.json({
91+
success: true,
92+
importedGroups: normalizedPayload.importedGroups.length
93+
});
94+
} catch (err) {
95+
if (configPersisted) {
96+
try {
97+
await saveGlobalConfig(previousConfig);
98+
await ConfigService.init(ConfigService.getEnv(), ConfigService.getCtx());
99+
} catch (rollbackErr) {
100+
logger.error(rollbackErr, { customMessage: 'Failed to rollback global config after import error.' });
101+
}
102+
}
103+
104+
logger.error(err, { customMessage: 'Failed to import admin config/groups from JSON.' });
105+
return response.json({ error: 'Failed to import data.' }, 500);
106+
}
107+
});
108+
109+
router.put('/admin/api/config', async () => {
110+
const newConfig = await readJsonBody(request);
111+
if (!newConfig || typeof newConfig !== 'object' || Array.isArray(newConfig)) {
112+
return response.json({ error: 'Invalid config payload.' }, 400);
113+
}
114+
115+
if ('jwtSecret' in newConfig) {
116+
delete newConfig.jwtSecret;
117+
}
118+
119+
if ('adminPasswordHash' in newConfig) {
120+
delete newConfig.adminPasswordHash;
121+
}
122+
123+
if ('adminPasswordSalt' in newConfig) {
124+
delete newConfig.adminPasswordSalt;
125+
}
126+
127+
if ('adminPasswordHashIterations' in newConfig) {
128+
delete newConfig.adminPasswordHashIterations;
129+
}
130+
131+
let passwordChanged = false;
132+
const currentAdminCredentials = getRuntimeAdminCredentials();
133+
const passwordStorageUpgradeRequired = requiresAdminPasswordStorageUpgrade(currentAdminCredentials);
134+
135+
if ('adminPassword' in newConfig) {
136+
const nextPassword = typeof newConfig.adminPassword === 'string'
137+
? newConfig.adminPassword.trim()
138+
: '';
139+
140+
if (!nextPassword) {
141+
delete newConfig.adminPassword;
142+
} else {
143+
if (nextPassword.length < 6) {
144+
return response.json({ error: 'Password must be at least 6 characters.' }, 400);
145+
}
146+
147+
let passwordMatched;
148+
try {
149+
passwordMatched = await isValidAdminPassword(nextPassword, currentAdminCredentials);
150+
} catch (err) {
151+
logger.error(err, { customMessage: 'Failed to validate admin password hash during update.' });
152+
return response.json({ error: 'Failed to validate password.' }, 500);
153+
}
154+
155+
passwordChanged = !hasConfiguredAdminPassword(currentAdminCredentials)
156+
|| !passwordMatched;
157+
158+
const shouldPersistPasswordCredentials = passwordChanged || passwordStorageUpgradeRequired;
159+
if (shouldPersistPasswordCredentials) {
160+
let passwordCredentials;
161+
try {
162+
passwordCredentials = await buildAdminPasswordCredentials(nextPassword);
163+
} catch (err) {
164+
logger.error(err, { customMessage: 'Failed to hash admin password during update.' });
165+
return response.json({ error: 'Failed to update admin credentials.' }, 500);
166+
}
167+
168+
newConfig.adminPasswordHash = passwordCredentials.adminPasswordHash;
169+
newConfig.adminPasswordSalt = passwordCredentials.adminPasswordSalt;
170+
newConfig.adminPasswordHashIterations = passwordCredentials.adminPasswordHashIterations;
171+
newConfig.adminPassword = '';
172+
173+
if (passwordChanged) {
174+
newConfig.jwtSecret = generateJwtSecret();
175+
}
176+
} else {
177+
delete newConfig.adminPassword;
178+
}
179+
}
180+
}
181+
182+
const oldConfig = await getGlobalConfig() || {};
183+
const mergedConfig = normalizePersistedAdminCredentialFields(deepMerge({}, oldConfig, newConfig));
184+
await saveGlobalConfig(mergedConfig);
185+
186+
const responseHeaders = {};
187+
if (passwordChanged) {
188+
const jwtSecret = typeof mergedConfig.jwtSecret === 'string'
189+
? mergedConfig.jwtSecret.trim()
190+
: '';
191+
192+
if (!jwtSecret) {
193+
logger.fatal('JWT secret is missing after password update.');
194+
return response.json({ error: 'JWT secret is missing after password update.' }, 500);
195+
}
196+
197+
const token = await createJwt(jwtSecret, {}, logger);
198+
responseHeaders['Set-Cookie'] = createAuthCookie(token, 8 * 60 * 60);
199+
logger.warn('Admin password updated and JWT secret rotated.', {}, { notify: true });
200+
} else {
201+
logger.info('Global config updated', {}, { notify: true });
202+
}
203+
204+
return response.json({ success: true, passwordChanged }, 200, responseHeaders);
205+
});
206+
207+
router.get('/admin/api/groups', async () => {
208+
const groups = await getAllGroups();
209+
return response.json(groups);
210+
});
211+
212+
router.post('/admin/api/groups', async () => {
213+
const newGroup = await request.json();
214+
if (!newGroup || typeof newGroup.name !== 'string' || !newGroup.name.trim()) {
215+
logger.warn('Invalid group data', { GroupData: newGroup });
216+
return response.json({ error: 'Invalid group data' }, 400);
217+
}
218+
219+
if (!newGroup.token) newGroup.token = crypto.randomUUID();
220+
if (!newGroup.token || typeof newGroup.token !== 'string' || !newGroup.token.trim()) {
221+
logger.warn('Invalid group data', { GroupData: newGroup });
222+
return response.json({ error: 'Invalid group data' }, 400);
223+
}
224+
225+
const group = await getGroup(newGroup.token);
226+
if (group) {
227+
logger.warn('Group already exists', { GroupName: newGroup.name });
228+
return response.json({ error: 'Group already exists' }, 400);
229+
}
230+
231+
await saveGroup(newGroup);
232+
logger.info('Group created', { GroupName: newGroup.name, Token: newGroup.token }, { notify: true });
233+
return response.json(newGroup);
234+
});
235+
236+
router.put('/admin/api/groups/:token', async ({ params }) => {
237+
const token = params.token;
238+
const groupData = await request.json();
239+
groupData.token = token;
240+
await saveGroup(groupData);
241+
logger.info('Group updated', { GroupName: groupData.name, Token: groupData.token }, { notify: true });
242+
return response.json(groupData);
243+
});
244+
245+
router.delete('/admin/api/groups/:token', async ({ params }) => {
246+
const token = params.token;
247+
await deleteGroup(token);
248+
logger.warn('Group deleted', { Token: token }, { notify: true });
249+
return response.json({ success: true });
250+
});
251+
252+
router.get('/admin/api/utils/gentoken', () => response.json({ token: crypto.randomUUID() }));
253+
254+
const routerResponse = await router.fetch(request);
255+
if (routerResponse) {
256+
return routerResponse;
257+
}
258+
259+
return response.json({ error: 'API endpoint not found' }, 404);
260+
}

0 commit comments

Comments
 (0)