- Notifications
You must be signed in to change notification settings - Fork 234
feat(admin): add authenticated settings API endpoints#690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
8608b2689f09b91e3e8b74f261abcc5c7b33878d55e7c0e780447dcdd2b424c733b79fFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "nostream": minor | ||
| --- | ||
| feat: add authenticated admin settings API endpoints |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { Request, Response } from 'express' | ||
| import { IController } from '../../@types/controllers' | ||
| import { Settings } from '../../@types/settings' | ||
| import { loadDefaults, loadMergedSettings, filterSettingsAgainstDefaults } from '../../utils/settings-config' | ||
| import { redactSettingsSecrets } from '../../utils/settings-redaction' | ||
| export class GetAdminSettingsController implements IController { | ||
| public async handleRequest(_request: Request, response: Response): Promise<void> { | ||
| const merged = loadMergedSettings() | ||
| const defaults = loadDefaults() | ||
| const filtered = filterSettingsAgainstDefaults(merged, defaults) as Settings | ||
| const settings = redactSettingsSecrets(filtered) | ||
| response.status(200).setHeader('content-type', 'application/json').send({ settings }) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { Request, Response } from 'express' | ||
| import { IController } from '../../@types/controllers' | ||
| import { guidedSettingCategories } from '../../utils/settings-guided-schema' | ||
| export class GetAdminSettingsSchemaController implements IController { | ||
| public async handleRequest(_request: Request, response: Response): Promise<void> { | ||
| response.status(200).setHeader('content-type', 'application/json').send({ categories: guidedSettingCategories }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { Request, Response } from 'express' | ||
| import { Settings } from '../../@types/settings' | ||
| import { IController } from '../../@types/controllers' | ||
| import { adminSettingsPatchBodySchema } from '../../schemas/admin-settings-schema' | ||
| import { | ||
| getByPath, | ||
| loadMergedSettings, | ||
| loadUserSettings, | ||
| saveSettings, | ||
| setByPath, | ||
| validatePathAgainstDefaults, | ||
| validateSettings, | ||
| } from '../../utils/settings-config' | ||
| import { | ||
| isWriteProtectedSettingsPath, | ||
| redactSettingsValue, | ||
| } from '../../utils/settings-redaction' | ||
| import { validateSchema } from '../../utils/validation' | ||
| export class PatchAdminSettingsController implements IController { | ||
| public async handleRequest(request: Request, response: Response): Promise<void> { | ||
| const validation = validateSchema(adminSettingsPatchBodySchema)(request.body) | ||
| if (validation.error) { | ||
| response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) | ||
| return | ||
| } | ||
| const { path, value } = validation.value | ||
| if (isWriteProtectedSettingsPath(path)) { | ||
| response | ||
| .status(400) | ||
| .setHeader('content-type', 'application/json') | ||
| .send({ | ||
| error: 'Validation failed', | ||
| issues: [{ path, message: 'Path is write-protected' }], | ||
| }) | ||
| return | ||
| } | ||
| const pathIssues = validatePathAgainstDefaults(path) | ||
| if (pathIssues.length > 0) { | ||
| response | ||
| .status(400) | ||
| .setHeader('content-type', 'application/json') | ||
| .send({ error: 'Validation failed', issues: pathIssues }) | ||
| return | ||
Ferryx349 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| const userSettings = loadUserSettings() as unknown as Record<string, unknown> | ||
| const nextUserSettings = setByPath(userSettings, path, value) | ||
| const merged = loadMergedSettings() as unknown as Record<string, unknown> | ||
| const mergedNext = setByPath(merged, path, getByPath(nextUserSettings, path)) | ||
| const validationIssues = validateSettings(mergedNext as unknown as Settings) | ||
| if (validationIssues.length > 0) { | ||
| response | ||
| .status(400) | ||
| .setHeader('content-type', 'application/json') | ||
| .send({ error: 'Validation failed', issues: validationIssues }) | ||
| return | ||
| } | ||
| saveSettings(nextUserSettings as unknown as Settings) | ||
Ferryx349 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const updatedValue = redactSettingsValue(path, getByPath(nextUserSettings, path)) | ||
| response.status(200).setHeader('content-type', 'application/json').send({ | ||
| ok: true, | ||
| path, | ||
| value: updatedValue, | ||
| }) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { Request, Response } from 'express' | ||
| import { IController } from '../../@types/controllers' | ||
| import { loadMergedSettings, validateSettings } from '../../utils/settings-config' | ||
| export class PostAdminSettingsValidateController implements IController { | ||
| public async handleRequest(_request: Request, response: Response): Promise<void> { | ||
| const issues = validateSettings(loadMergedSettings()) | ||
| if (issues.length === 0) { | ||
| response.status(200).setHeader('content-type', 'application/json').send({ valid: true, issues: [] }) | ||
| return | ||
| } | ||
| response.status(200).setHeader('content-type', 'application/json').send({ valid: false, issues }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { GetAdminSettingsController } from '../../controllers/admin/get-settings-controller' | ||
| import { IController } from '../../@types/controllers' | ||
| export const createGetAdminSettingsController = (): IController => { | ||
| return new GetAdminSettingsController() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { GetAdminSettingsSchemaController } from '../../controllers/admin/get-settings-schema-controller' | ||
| import { IController } from '../../@types/controllers' | ||
| export const createGetAdminSettingsSchemaController = (): IController => { | ||
| return new GetAdminSettingsSchemaController() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { PatchAdminSettingsController } from '../../controllers/admin/patch-settings-controller' | ||
| import { IController } from '../../@types/controllers' | ||
| export const createPatchAdminSettingsController = (): IController => { | ||
| return new PatchAdminSettingsController() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { PostAdminSettingsValidateController } from '../../controllers/admin/post-settings-validate-controller' | ||
| import { IController } from '../../@types/controllers' | ||
| export const createPostAdminSettingsValidateController = (): IController => { | ||
| return new PostAdminSettingsValidateController() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -3,8 +3,12 @@ | ||
| import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' | ||
| import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' | ||
| import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' | ||
| import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory' | ||
| import { createGetAdminSettingsSchemaController } from '../../factories/controllers/get-admin-settings-schema-controller-factory' | ||
| import { createPatchAdminSettingsController } from '../../factories/controllers/patch-admin-settings-controller-factory' | ||
| import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory' | ||
| import { createPostAdminLogoutController } from '../../factories/controllers/post-admin-logout-controller-factory' | ||
| import { createPostAdminSettingsValidateController } from '../../factories/controllers/post-admin-settings-validate-controller-factory' | ||
| import { adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware' | ||
| import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware' | ||
| import { | ||
| @@ -23,11 +27,25 @@ | ||
| router.use(adminEnabledMiddleware) | ||
| router.use('/assets', express.static('./resources/admin/assets')) | ||
| router.get('/', getAdminDashboardRequestHandler) | ||
| router.get('/dashboard', getAdminDashboardRequestHandler) | ||
| router.post('/login', adminLoginRateLimitMiddleware, json(), withAdminController(createPostAdminLoginController)) | ||
| router.post('/logout', adminRateLimitMiddleware, withAdminController(createPostAdminLogoutController)) | ||
| router.get('/session', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSessionController)) | ||
| router.get('/health', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminHealthController)) | ||
| router.get('/metrics', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminMetricsController)) | ||
| router.get('/settings', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSettingsController)) | ||
cameri marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| router.get( | ||
| '/settings/schema', | ||
| adminRateLimitMiddleware, | ||
| adminAuthMiddleware, | ||
cameri marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| withAdminController(createGetAdminSettingsSchemaController), | ||
| ) | ||
| router.patch('/settings', adminRateLimitMiddleware, adminAuthMiddleware, json(), withAdminController(createPatchAdminSettingsController)) | ||
cameri marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| router.post( | ||
| '/settings/validate', | ||
| adminRateLimitMiddleware, | ||
| adminAuthMiddleware, | ||
cameri marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| withAdminController(createPostAdminSettingsValidateController), | ||
| ) | ||
| export default router | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { z } from 'zod' | ||
| export const adminSettingsPatchBodySchema = z | ||
| .object({ | ||
| path: z.string().min(1), | ||
| value: z.custom<unknown>((input) => input !== undefined, { message: 'value is required' }), | ||
| }) | ||
| .strict() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| const SENSITIVE_SETTING_KEYS = new Set(['passwordHash', 'secret']) | ||
| const isPlainObject = (value: unknown): value is Record<string, unknown> => { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value) | ||
| } | ||
| export const isSensitiveSettingsPath = (path: string): boolean => { | ||
| const segments = path.split('.') | ||
| const lastSegment = segments[segments.length - 1] ?? '' | ||
| const key = lastSegment.replace(/\[\d+\]$/, '') | ||
| return SENSITIVE_SETTING_KEYS.has(key) | ||
| } | ||
| export const isWriteProtectedSettingsPath = (path: string): boolean => { | ||
| return path === 'admin.passwordHash' || path.endsWith('.passwordHash') | ||
| } | ||
| export const redactSettingsValue = (path: string, value: unknown): unknown => { | ||
| if (isSensitiveSettingsPath(path) && typeof value === 'string' && value.length > 0) { | ||
| return '***' | ||
| } | ||
| return value | ||
| } | ||
| export const redactSettingsSecrets = <T>(settings: T): T => { | ||
| const redactWalk = (value: unknown): unknown => { | ||
| if (Array.isArray(value)) { | ||
| return value.map(redactWalk) | ||
| } | ||
| if (!isPlainObject(value)) { | ||
| return value | ||
| } | ||
| const result: Record<string, unknown> = {} | ||
| for (const [key, entry] of Object.entries(value)) { | ||
| if (SENSITIVE_SETTING_KEYS.has(key) && typeof entry === 'string' && entry.length > 0) { | ||
| result[key] = '***' | ||
| continue | ||
| } | ||
| result[key] = redactWalk(entry) | ||
| } | ||
| return result | ||
| } | ||
| return redactWalk(settings) as T | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.