From 2db5fa502f6e6abfb41a3f7c8320a3f3eb31e399 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 16:34:06 +0300 Subject: [PATCH 1/3] dashboard: add authentication middleware Signed-off-by: NotAShelf Change-Id: I9910548c65a11f2c83dfeb4fef3c93f06a6a6964 --- src/dashboard.ts | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index 64cd5fc..db6f3e6 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -9,6 +9,43 @@ export function createDashboardRouter(config: Config): express.Router { router.use(express.json()); + // Authentication middleware + if (config.dashboard?.auth) { + router.use((req, res, next) => { + const auth = config.dashboard!.auth!; + + if (auth.type === 'basic') { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Basic ')) { + res.setHeader('WWW-Authenticate', 'Basic realm="Troutbot Dashboard"'); + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const credentials = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const [username, password] = credentials.split(':'); + + if (username !== auth.username || password !== auth.password) { + res.setHeader('WWW-Authenticate', 'Basic realm="Troutbot Dashboard"'); + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + } else if (auth.type === 'token') { + const authHeader = req.headers.authorization; + const token = req.query.token as string | undefined; + + const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : token; + + if (!providedToken || providedToken !== auth.token) { + res.status(401).json({ error: 'Invalid or missing token' }); + return; + } + } + + next(); + }); + } + // API routes router.get('/api/status', (_req, res) => { const enabledBackends = Object.entries(config.engine.backends) @@ -41,9 +78,9 @@ export function createDashboardRouter(config: Config): express.Router { try { const partial = req.body as Partial; const merged = deepMerge( - config as Record, - partial as Record - ) as Config; + config as unknown as Record, + partial as unknown as Record + ) as unknown as Config; validate(merged); // Apply in-place @@ -55,8 +92,7 @@ export function createDashboardRouter(config: Config): express.Router { } }); - // --- Dashboard HTML --- - + // Dashboard HTML router.get('/dashboard', (_req, res) => { res.type('html').send(dashboardHTML()); }); From 7d8bc6943daf7297d6b7c4bbd63221719c79495d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 17:16:58 +0300 Subject: [PATCH 2/3] config: bind to localhost by default Signed-off-by: NotAShelf Change-Id: I00aca92a09291ce12f09da68917f56c06a6a6964 --- src/config.ts | 30 +++++++++++++++++++++++++++++- src/index.ts | 11 +++++++---- src/types.ts | 14 ++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 5c6354f..e5896c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -97,7 +97,10 @@ export function loadConfig(): Config { ); } - const config = deepMerge(defaults, fileConfig); + const config = deepMerge( + defaults as unknown as Record, + fileConfig as unknown as Record + ) as unknown as Config; // Environment variable overrides if (process.env.PORT) { @@ -108,6 +111,31 @@ export function loadConfig(): Config { config.server.port = parsed; } + if (process.env.HOST) { + config.server.host = process.env.HOST; + } + + if (process.env.DASHBOARD_TOKEN) { + config.dashboard = { + ...(config.dashboard || { enabled: true }), + auth: { + type: 'token', + token: process.env.DASHBOARD_TOKEN, + }, + }; + } + + if (process.env.DASHBOARD_USERNAME && process.env.DASHBOARD_PASSWORD) { + config.dashboard = { + ...(config.dashboard || { enabled: true }), + auth: { + type: 'basic', + username: process.env.DASHBOARD_USERNAME, + password: process.env.DASHBOARD_PASSWORD, + }, + }; + } + const validLogLevels = ['debug', 'info', 'warn', 'error']; if (process.env.LOG_LEVEL) { if (!validLogLevels.includes(process.env.LOG_LEVEL)) { diff --git a/src/index.ts b/src/index.ts index 2e24cbb..1545763 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,12 +27,13 @@ async function analyzeOne(target: string) { initLogger(config.logging); const logger = getLogger(); - initGitHub(process.env.GITHUB_TOKEN); if (!process.env.GITHUB_TOKEN) { logger.error('GITHUB_TOKEN is required for analyze mode'); process.exit(1); } + initGitHub(process.env.GITHUB_TOKEN); + const prData = await fetchPR(owner, repo, prNumber); if (!prData) { logger.error(`Could not fetch PR ${owner}/${repo}#${prNumber}`); @@ -108,8 +109,9 @@ function serve() { .filter(([, v]) => v.enabled) .map(([k]) => k); - const server = app.listen(port, async () => { - logger.info(`Troutbot listening on port ${port}`); + const host = config.server.host || '127.0.0.1'; + const server = app.listen(port, host, async () => { + logger.info(`Troutbot listening on ${host}:${port}`); logger.info(`Enabled backends: ${enabledBackends.join(', ')}`); // Watched repos @@ -140,7 +142,8 @@ function serve() { // Comment update mode logger.info(`Comment updates: ${config.response.allowUpdates ? 'enabled' : 'disabled'}`); - logger.info(`Dashboard available at http://localhost:${port}/dashboard`); + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + logger.info(`Dashboard available at http://${displayHost}:${port}/dashboard`); // Start polling if enabled await startPolling(config); diff --git a/src/types.ts b/src/types.ts index ab07dff..939a62e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export interface Config { server: ServerConfig; + dashboard?: DashboardConfig; repositories: RepoConfig[]; filters: FiltersConfig; engine: EngineConfig; @@ -16,9 +17,22 @@ export interface PollingConfig { export interface ServerConfig { port: number; + host?: string; rateLimit?: number; } +export interface DashboardConfig { + enabled: boolean; + auth?: DashboardAuthConfig; +} + +export interface DashboardAuthConfig { + type: 'basic' | 'token'; + username?: string; + password?: string; + token?: string; +} + export interface RepoConfig { owner: string; repo: string; From ad491d69e8c2309b20c41d4e10dc204a19905a3d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 17:17:16 +0300 Subject: [PATCH 3/3] meta: move config files to typescript Signed-off-by: NotAShelf Change-Id: I60de4abb5b81c4b14d95d7b6f1da8aa66a6a6964 --- config.example.ts | 16 ++++++++++++++++ eslint.config.mjs => eslint.config.ts | 7 ++++--- prettier.config.mjs => prettier.config.ts | 6 +++++- 3 files changed, 25 insertions(+), 4 deletions(-) rename eslint.config.mjs => eslint.config.ts (76%) rename prettier.config.mjs => prettier.config.ts (75%) diff --git a/config.example.ts b/config.example.ts index 513c8bc..d922e4d 100644 --- a/config.example.ts +++ b/config.example.ts @@ -3,8 +3,24 @@ import type { Config } from './src/types'; const config: Config = { server: { port: 3000, + // host: '0.0.0.0', // Uncomment to bind to all interfaces (default is localhost only) }, + // Dashboard configuration (optional) + // dashboard: { + // enabled: true, + // // Authentication options (choose one): + // auth: { + // // Option 1: Basic HTTP authentication + // type: 'basic', + // username: 'admin', + // password: 'changeme', + // // Option 2: Bearer token authentication + // // type: 'token', + // // token: 'your-secret-token-here', + // }, + // }, + repositories: [ // Leave empty to accept webhooks from any repo. // { owner: "myorg", repo: "myrepo" }, diff --git a/eslint.config.mjs b/eslint.config.ts similarity index 76% rename from eslint.config.mjs rename to eslint.config.ts index 57ccfab..4a51839 100644 --- a/eslint.config.mjs +++ b/eslint.config.ts @@ -2,9 +2,9 @@ import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; export default [ + { ignores: ['dist/**', 'node_modules/**', '**/*.d.ts'] }, { - files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], - ignores: ['dist/**', 'node_modules/**'], + files: ['src/**/*.ts', '**/*.js', 'config.example.ts', 'tsup.config.ts'], languageOptions: { ecmaVersion: 2022, sourceType: 'module', @@ -19,8 +19,9 @@ export default [ }, }, plugins: { - '@typescript-eslint': tseslint, + '@typescript-eslint': tseslint as unknown as Record, }, + rules: { ...tseslint.configs.recommended.rules, '@typescript-eslint/no-explicit-any': 'warn', diff --git a/prettier.config.mjs b/prettier.config.ts similarity index 75% rename from prettier.config.mjs rename to prettier.config.ts index e218dce..218a5fa 100644 --- a/prettier.config.mjs +++ b/prettier.config.ts @@ -1,4 +1,6 @@ -export default { +import type { Config } from 'prettier'; + +const config: Config = { printWidth: 100, tabWidth: 2, useTabs: false, @@ -13,3 +15,5 @@ export default { endOfLine: 'lf', plugins: [], }; + +export default config;