const express = require('express'); const fs = require('fs'); const path = require('path'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const router = express.Router(); let pkg; try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; } // Explicit allowlist of allowed files with resolved paths const ALLOWED_FILES = { 'FUTURE.md': path.resolve(__dirname, '..', 'FUTURE.md'), 'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'), }; // Priority emoji to label mapping const PRIORITY_MAP = { '🔴': 'CRITICAL', '🟠': 'HIGH', '🟡': 'MEDIUM', '🔵': 'LOW', '💭': 'NICE_TO_HAVE', }; /** * Generate a slug from a title: lowercase, hyphens, strip emojis */ function slugify(title) { return title .replace(/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FAFF}]/gu, '') // strip emojis .replace(/[^a-zA-Z0-9]+/g, '-') // non-alphanumeric → hyphens .replace(/^-+|-+$/g, '') // trim leading/trailing hyphens .toLowerCase(); } /** * Extract effort estimate from implementation notes text. * Matches patterns like "Estimated effort: 3-4 hours", "Estimated effort: 8 hours" */ function extractEffort(text) { if (!text) return null; const match = text.match(/Estimated effort:\s*(\d+(?:\s*-\s*\d+)?\s*hours?)/i); if (!match) return null; // Normalize: "3-4 hours" → "3-4h", "8 hours" → "8h" return match[1].replace(/\s*hours?/i, 'h').replace(/\s*/g, ''); } /** * Parse FUTURE.md into structured roadmap items. * Filters out completed/strikethrough items and template/meta sections. */ function parseFutureMd(content) { if (!content) return { items: [], counts: {} }; const items = []; const counts = { critical: 0, high: 0, medium: 0, low: 0, niceToHave: 0 }; const lines = content.split('\n'); let skipSection = false; let currentSectionLines = []; let currentPriorityEmoji = null; let currentPriorityLabel = null; let currentTitle = null; let inItem = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Skip template/meta sections if (/^##\s+How to Use This Document/i.test(line) || /^###\s+Priority Format/i.test(line)) { skipSection = true; continue; } // Completed items section if (/^##\s+Completed/i.test(line)) { skipSection = true; continue; } // Stop skipping at ## or ### headings that aren't skipped sections if (skipSection) { if (/^(?:##|###)\s/.test(line) && !/^(?:##|###)\s+(How to Use|Priority Format|Completed)/i.test(line)) { skipSection = false; // Don't continue — process this heading line below } else { continue; } } // Skip table rows (Priority Format table) if (/^\|/.test(line)) continue; // Strikethrough items: ### ~~Title~~ — PRIORITY if (/^###\s+~~/.test(line)) { // Save previous item and skip completed/strikethrough items if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } inItem = false; currentTitle = null; currentSectionLines = []; continue; } // Priority section headings: ### 🔴 CRITICAL, ### 🟠 HIGH, etc. const sectionMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE)\s*$/); if (sectionMatch) { // Save previous item if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } currentPriorityEmoji = sectionMatch[1]; currentPriorityLabel = sectionMatch[2]; inItem = false; currentTitle = null; currentSectionLines = []; continue; } // Item headings: ### 🔴 Title — CRITICAL or ### Title — HIGH etc. const headingMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/); const headingMatchNoEmoji = line.match(/^###\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/); if (headingMatch) { if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } currentPriorityEmoji = headingMatch[1]; currentPriorityLabel = headingMatch[3]; currentTitle = headingMatch[2].trim(); currentSectionLines = []; inItem = true; continue; } if (!headingMatch && headingMatchNoEmoji) { if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } currentPriorityEmoji = currentPriorityEmoji || null; // inherit from section currentPriorityLabel = headingMatchNoEmoji[2]; currentTitle = headingMatchNoEmoji[1].trim(); currentSectionLines = []; inItem = true; continue; } // Also handle items with emoji but no trailing priority: ### 🔴 Title const headingEmojiOnly = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*$/); if (headingEmojiOnly && !headingMatch) { if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } currentPriorityEmoji = headingEmojiOnly[1]; // Use section-level priority if available currentPriorityLabel = currentPriorityLabel || PRIORITY_MAP[headingEmojiOnly[1]] || 'MEDIUM'; currentTitle = headingEmojiOnly[2].trim(); currentSectionLines = []; inItem = true; continue; } // Generic ### headings without emoji or priority label (items in a section context) if (/^###\s+/.test(line) && !headingMatch && !headingMatchNoEmoji && !headingEmojiOnly) { // Plain ### heading within a known section if (currentPriorityLabel) { // Save previous item if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } currentTitle = line.replace(/^###\s+/, '').trim(); currentSectionLines = []; inItem = true; continue; } } // ## Pending Recommendations heading — skip if (/^##\s+Pending Recommendations/.test(line)) { if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } inItem = false; currentTitle = null; currentSectionLines = []; continue; } // Collect body lines for current item if (inItem) { currentSectionLines.push(line); } } // Save last item if (inItem && currentTitle) { _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); } return { items, counts, version: pkg.version }; } /** * Add a parsed item to the items array and update counts. */ function _addItem(items, counts, emoji, label, title, bodyLines) { const body = bodyLines.join('\n'); const description = _extractField(body, 'Description'); const rationale = _extractField(body, 'Rationale'); const implementationNotes = _extractField(body, 'Implementation Notes'); const effort = extractEffort(implementationNotes); // Extract Added and AddedBy metadata const addedMatch = body.match(/\*\*Added:\*\*\s*(\d{4}-\d{2}-\d{2})(?:\s+by\s+(.+))?/); const added = addedMatch ? addedMatch[1] : null; const addedBy = addedMatch ? (addedMatch[2] || null) : null; // Determine status — if not specified, default to PENDING const statusMatch = body.match(/\*\*Status:\*\*\s*(.+)/); const status = statusMatch ? statusMatch[1].trim().toUpperCase() : 'PENDING'; // Map priority label to count key const countKey = { 'CRITICAL': 'critical', 'HIGH': 'high', 'MEDIUM': 'medium', 'LOW': 'low', 'NICE TO HAVE': 'niceToHave', 'NICE_TO_HAVE': 'niceToHave', 'MEH': 'niceToHave', }[label] || 'medium'; counts[countKey]++; items.push({ id: slugify(title), priority: emoji || '', priorityLabel: label, title, description, rationale, implementationNotes, effort, added, addedBy, status, }); } /** * Extract a named field from markdown body text. * Looks for **Field Name:** and captures everything until the next ** field or ### heading or end. */ function _extractField(body, fieldName) { // Match **FieldName:** followed by content until next ** or ### heading const regex = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*\n([\\s\\S]*?)(?=\\n\\*\\*[^*]|\\n###|$)`, 'i'); const match = body.match(regex); if (!match) return null; return match[1].trim(); } /** * Parse DEVELOPMENT_LOG.md into structured log entries. * Returns entries sorted by date descending. */ function parseDevLogMd(content) { if (!content) return []; const entries = []; // Split on version headings: ### v0.24.4 - Title const versionRegex = /^###\s+(v[\d.]+(?:-[\w]+)?)\s+-\s+(.+)$/gm; const splits = []; let match; while ((match = versionRegex.exec(content)) !== null) { splits.push({ version: match[1], title: match[2].trim(), index: match.index, }); } for (let i = 0; i < splits.length; i++) { const start = splits[i].index; const end = i + 1 < splits.length ? splits[i + 1].index : content.length; const block = content.substring(start, end); const entry = _parseDevLogEntry(block, splits[i].version, splits[i].title); if (entry) entries.push(entry); } // Sort by date descending entries.sort((a, b) => { const dateA = a.date ? new Date(a.date) : new Date(0); const dateB = b.date ? new Date(b.date) : new Date(0); return dateB - dateA; }); return entries; } /** * Parse a single dev log entry block. */ function _parseDevLogEntry(block, version, title) { // Status const statusMatch = block.match(/\*\*Status:\*\*\s*(.+)/); const status = statusMatch ? statusMatch[1].trim() : null; // Date const dateMatch = block.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/); const date = dateMatch ? dateMatch[1] : null; // Priority const priorityMatch = block.match(/\*\*Priority:\*\*\s*(.+)/); const priority = priorityMatch ? priorityMatch[1].trim() : null; // Agents table const agents = []; const agentTableRegex = /\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/g; let inAgentTable = false; const blockLines = block.split('\n'); for (const line of blockLines) { if (/^\|\s*Agent\s*\|/i.test(line)) { inAgentTable = true; continue; } if (/^\|\s*[-:]+\s*\|/.test(line)) continue; // separator row if (inAgentTable && /^\|/.test(line)) { const row = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/); if (row) { agents.push({ name: row[1].trim(), status: row[2].trim(), time: row[3].trim(), notes: row[4].trim(), }); } } else if (inAgentTable && !/^\|/.test(line) && line.trim() !== '') { inAgentTable = false; } } // Files modified const filesMatch = block.match(/\*\*Files modified:\*\*\s*(.+)/); const filesModified = filesMatch ? filesMatch[1].split(',').map(f => f.trim().replace(/^`|`$/g, '')).filter(Boolean) : []; // Work completed (checklist items) const workCompleted = []; const workMatch = block.match(/\*\*Work Completed:\*\*\n([\s\S]*?)(?=\n---|\n###|$)/); if (workMatch) { const items = workMatch[1].match(/- \[[ x]\] .+/g); if (items) { workCompleted.push(...items.map(item => item.replace(/^- \[[ x]\]\s*/, '').trim())); } } return { version, title, date, status, priority, agents, filesModified, workCompleted, }; } /** * Redact sensitive information from file content * @param {string} content - The content to redact * @returns {string} - The redacted content */ function redactSensitiveContent(content) { if (!content) return content; return content // Redact internal IPs .replace(/\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}\b/g, '[REDACTED]') .replace(/\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\b/g, '[REDACTED]') .replace(/\b172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}\b/g, '[REDACTED]') // Redact passwords, api_keys, secrets .replace(/(password|api_key|secret)\s*=\s*[^\\\s]+/gi, '$1=[REDACTED]') // Redact file paths (Unix-style: /home/, /etc/, /var/, /tmp/, /usr/, /opt/) .replace(/\/(?:home|etc|var|tmp|usr|opt)\/[^\s"',;)]+/gi, '[REDACTED]') // Redact Windows-style paths .replace(/[A-Z]:\\(?:Users|Windows|Program Files)[\\\/][^\s"',;)]+/gi, '[REDACTED]') // Redact connection strings .replace(/(?:mongodb|postgres|mysql|redis|amqp):\/\/[^\s"']+/gi, '[REDACTED]') // Redact env var values (KEY=value patterns where key contains secret/pass/key/token) .replace(/([A-Z_]*(?:SECRET|KEY|TOKEN|PASS|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*)\s*=\s*[^\s"']+/gi, '$1=[REDACTED]') // Redact internal URLs .replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?[^\s"']*/gi, '[REDACTED_URL]') // Redact lines with security-sensitive patterns (CVE IDs, exploit details, attack vectors) .replace(/\bCVE-\d{4}-\d+\b/gi, '[REDACTED]') .replace(/\b(?:sql\s*injection|xss|csrf|csrf\s*token|race\s*condition|buffer\s*overflow|privilege\s*escalation)\b[^.]*\./gi, '[REDACTED_SECURITY_CONTENT].') .replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]') } // Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat) router.get('/', requireAuth, requireAdmin, (req, res) => { try { // Read both files directly from the allowlist const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8'); const devLogContent = fs.readFileSync(ALLOWED_FILES['DEVELOPMENT_LOG.md'], 'utf-8'); // Redact sensitive information const sanitizedFutureContent = redactSensitiveContent(futureContent); const sanitizedDevLogContent = redactSensitiveContent(devLogContent); res.json({ version: pkg.version, future: sanitizedFutureContent, developmentLog: sanitizedDevLogContent }); } catch (err) { // Generic error message to prevent path disclosure console.error('[aboutAdmin] Error reading files'); res.status(500).json({ error: 'Failed to read project documentation files', code: 'FILE_READ_ERROR' }); } }); // Admin-only endpoint: parsed roadmap items from FUTURE.md router.get('/roadmap', requireAuth, requireAdmin, (req, res) => { try { const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8'); const sanitized = redactSensitiveContent(futureContent); const result = parseFutureMd(sanitized); res.json(result); } catch (err) { console.error('[aboutAdmin] Error reading FUTURE.md for roadmap'); res.status(500).json({ error: 'Failed to read roadmap data', code: 'FILE_READ_ERROR' }); } }); // Admin-only endpoint: parsed dev log entries from DEVELOPMENT_LOG.md router.get('/dev-log', requireAuth, requireAdmin, (req, res) => { try { const devLogContent = fs.readFileSync(ALLOWED_FILES['DEVELOPMENT_LOG.md'], 'utf-8'); const sanitized = redactSensitiveContent(devLogContent); const entries = parseDevLogMd(sanitized); res.json({ entries, version: pkg.version }); } catch (err) { console.error('[aboutAdmin] Error reading DEVELOPMENT_LOG.md for dev-log'); res.status(500).json({ error: 'Failed to read dev log data', code: 'FILE_READ_ERROR' }); } }); module.exports = router;