a control panel for my server
at main 5.6 kB view raw
1import { Database } from "bun:sqlite"; 2 3const DB_PATH = process.env.DATABASE_PATH || "./data/control.db"; 4const FLAGS_CONFIG_PATH = process.env.FLAGS_CONFIG || "./flags.json"; 5 6// Load flags config from file (path from env or default) 7const flagsConfig = await Bun.file(FLAGS_CONFIG_PATH).json().catch(() => { 8 // Fallback to local flags.json if FLAGS_CONFIG path doesn't exist 9 return import("../flags.json").then(m => m.default); 10}); 11 12// Initialize database 13const db = new Database(DB_PATH, { create: true }); 14db.exec(` 15 CREATE TABLE IF NOT EXISTS flags ( 16 id TEXT PRIMARY KEY, 17 enabled INTEGER NOT NULL DEFAULT 0, 18 updated_at TEXT NOT NULL DEFAULT (datetime('now')) 19 ) 20`); 21 22// Prepared statements for performance 23const getFlag = db.prepare<{ enabled: number }, [string]>( 24 "SELECT enabled FROM flags WHERE id = ?" 25); 26const setFlagStmt = db.prepare( 27 `INSERT INTO flags (id, enabled, updated_at) VALUES (?, ?, datetime('now')) 28 ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, updated_at = datetime('now')` 29); 30const getAllFlags = db.prepare<{ id: string; enabled: number }, []>( 31 "SELECT id, enabled FROM flags" 32); 33 34export interface FlagDefinition { 35 name: string; 36 description: string; 37 paths: string[]; // The paths this flag blocks 38 redact?: Record<string, string[]>; // path -> fields to strip from JSON 39} 40 41export interface ServiceDefinition { 42 name: string; 43 flags: Record<string, FlagDefinition>; 44} 45 46export interface FlagsConfig { 47 services: Record<string, ServiceDefinition>; 48} 49 50export interface FlagStatus { 51 id: string; 52 name: string; 53 description: string; 54 enabled: boolean; 55 service: string; 56} 57 58export function getConfig(): FlagsConfig { 59 return flagsConfig as FlagsConfig; 60} 61 62export function getAllFlagIds(): string[] { 63 const config = getConfig(); 64 const ids: string[] = []; 65 for (const service of Object.values(config.services)) { 66 for (const flagId of Object.keys(service.flags)) { 67 ids.push(flagId); 68 } 69 } 70 return ids; 71} 72 73export function getFlagDefinition( 74 flagId: string 75): { flag: FlagDefinition; serviceId: string; service: ServiceDefinition } | null { 76 const config = getConfig(); 77 for (const [serviceId, service] of Object.entries(config.services)) { 78 if (flagId in service.flags) { 79 return { flag: service.flags[flagId], serviceId, service }; 80 } 81 } 82 return null; 83} 84 85export function getFlagStatus(flagId: string): boolean { 86 const row = getFlag.get(flagId); 87 return row?.enabled === 1; 88} 89 90export function setFlag(flagId: string, enabled: boolean): void { 91 if (!getFlagDefinition(flagId)) { 92 throw new Error(`Unknown flag: ${flagId}`); 93 } 94 setFlagStmt.run(flagId, enabled ? 1 : 0); 95} 96 97export function getAllFlagsStatus(): Record<string, FlagStatus[]> { 98 const config = getConfig(); 99 const result: Record<string, FlagStatus[]> = {}; 100 101 // Get all current flag states from DB 102 const dbFlags = new Map<string, boolean>(); 103 for (const row of getAllFlags.all()) { 104 dbFlags.set(row.id, row.enabled === 1); 105 } 106 107 for (const [serviceId, service] of Object.entries(config.services)) { 108 const flags: FlagStatus[] = []; 109 for (const [flagId, flag] of Object.entries(service.flags)) { 110 flags.push({ 111 id: flagId, 112 name: flag.name, 113 description: flag.description, 114 enabled: dbFlags.get(flagId) ?? false, 115 service: serviceId, 116 }); 117 } 118 result[serviceId] = flags; 119 } 120 121 return result; 122} 123 124// Match a path pattern against a request path (supports * wildcard for single segment) 125function matchPath(pattern: string, path: string): boolean { 126 if (!pattern.includes("*")) { 127 return path === pattern || path.startsWith(pattern + "/") || path.startsWith(pattern + "?"); 128 } 129 130 // Convert pattern to regex: * matches any single path segment 131 const regexPattern = pattern 132 .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex special chars except * 133 .replace(/\*/g, "[^/]+"); // * matches one path segment 134 135 const regex = new RegExp(`^${regexPattern}($|\\?|/)`); 136 return regex.test(path); 137} 138 139// Check if a request should be blocked based on host and path 140export function shouldBlock(host: string, path: string): boolean { 141 const config = getConfig(); 142 143 for (const [serviceId, service] of Object.entries(config.services)) { 144 // Check if this request matches a service 145 if (!host.includes(serviceId) && !serviceId.includes(host)) { 146 continue; 147 } 148 149 for (const [flagId, flag] of Object.entries(service.flags)) { 150 // Check if flag is enabled (blocking) 151 if (!getFlagStatus(flagId)) { 152 continue; 153 } 154 155 // Check if any of the flag's paths match (supports * wildcard) 156 for (const flagPath of flag.paths) { 157 if (matchPath(flagPath, path)) { 158 return true; 159 } 160 } 161 } 162 } 163 164 return false; 165} 166 167// Get fields to redact from a JSON response based on host and path 168export function getRedactions(host: string, path: string): string[] { 169 const config = getConfig(); 170 const fields: string[] = []; 171 172 for (const [serviceId, service] of Object.entries(config.services)) { 173 if (!host.includes(serviceId) && !serviceId.includes(host)) { 174 continue; 175 } 176 177 for (const [flagId, flag] of Object.entries(service.flags)) { 178 if (!getFlagStatus(flagId)) { 179 continue; 180 } 181 182 if (flag.redact) { 183 for (const [redactPath, redactFields] of Object.entries(flag.redact)) { 184 if (path === redactPath || path.startsWith(redactPath + "?")) { 185 fields.push(...redactFields); 186 } 187 } 188 } 189 } 190 } 191 192 return fields; 193}