|
| 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