BillTracker/routes/aboutAdmin.js

459 lines
16 KiB
JavaScript
Raw Permalink Normal View History

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;