2026-05-09 16:25:12 -05:00
|
|
|
const express = require('express');
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
|
|
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
2026-05-09 22:01:19 -05:00
|
|
|
let pkg;
|
|
|
|
|
try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
|
|
|
|
|
|
2026-05-09 18:25:25 -05:00
|
|
|
// 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'),
|
|
|
|
|
};
|
2026-05-09 16:25:12 -05:00
|
|
|
|
v0.25.0: roadmap redesign, import CSRF fix, AdminDashboard removed
- RoadmapPage: kanban-style priority lanes, shadcn Collapsible/Tabs,
lazy-loaded activity log, admin-only /api/about/roadmap + /dev-log endpoints
- Import CSRF fix: added x-csrf-token header to importAdminBackup,
previewSpreadsheetImport, previewUserDbImport raw fetch() calls
- Removed AdminDashboard.jsx, replaced by RoadmapPage
- Added @radix-ui/react-collapsible + collapsible shadcn component
- Security audit by Private_Hudson: PASS (CSRF fix verified,
admin endpoints gated, path traversal mitigated, XSS safe)
2026-05-11 21:42:36 -05:00
|
|
|
// 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 16:25:12 -05:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-05-09 18:25:25 -05:00
|
|
|
.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]')
|
2026-05-09 16:25:12 -05:00
|
|
|
}
|
|
|
|
|
|
v0.25.0: roadmap redesign, import CSRF fix, AdminDashboard removed
- RoadmapPage: kanban-style priority lanes, shadcn Collapsible/Tabs,
lazy-loaded activity log, admin-only /api/about/roadmap + /dev-log endpoints
- Import CSRF fix: added x-csrf-token header to importAdminBackup,
previewSpreadsheetImport, previewUserDbImport raw fetch() calls
- Removed AdminDashboard.jsx, replaced by RoadmapPage
- Added @radix-ui/react-collapsible + collapsible shadcn component
- Security audit by Private_Hudson: PASS (CSRF fix verified,
admin endpoints gated, path traversal mitigated, XSS safe)
2026-05-11 21:42:36 -05:00
|
|
|
// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat)
|
2026-05-09 16:25:12 -05:00
|
|
|
router.get('/', requireAuth, requireAdmin, (req, res) => {
|
|
|
|
|
try {
|
2026-05-09 18:25:25 -05:00
|
|
|
// 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');
|
2026-05-09 16:25:12 -05:00
|
|
|
|
|
|
|
|
// Redact sensitive information
|
|
|
|
|
const sanitizedFutureContent = redactSensitiveContent(futureContent);
|
|
|
|
|
const sanitizedDevLogContent = redactSensitiveContent(devLogContent);
|
|
|
|
|
|
|
|
|
|
res.json({
|
2026-05-09 22:01:19 -05:00
|
|
|
version: pkg.version,
|
2026-05-09 16:25:12 -05:00
|
|
|
future: sanitizedFutureContent,
|
|
|
|
|
developmentLog: sanitizedDevLogContent
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
2026-05-09 18:25:25 -05:00
|
|
|
// Generic error message to prevent path disclosure
|
|
|
|
|
console.error('[aboutAdmin] Error reading files');
|
2026-05-09 16:25:12 -05:00
|
|
|
res.status(500).json({
|
|
|
|
|
error: 'Failed to read project documentation files',
|
|
|
|
|
code: 'FILE_READ_ERROR'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
v0.25.0: roadmap redesign, import CSRF fix, AdminDashboard removed
- RoadmapPage: kanban-style priority lanes, shadcn Collapsible/Tabs,
lazy-loaded activity log, admin-only /api/about/roadmap + /dev-log endpoints
- Import CSRF fix: added x-csrf-token header to importAdminBackup,
previewSpreadsheetImport, previewUserDbImport raw fetch() calls
- Removed AdminDashboard.jsx, replaced by RoadmapPage
- Added @radix-ui/react-collapsible + collapsible shadcn component
- Security audit by Private_Hudson: PASS (CSRF fix verified,
admin endpoints gated, path traversal mitigated, XSS safe)
2026-05-11 21:42:36 -05:00
|
|
|
// 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'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 22:01:19 -05:00
|
|
|
module.exports = router;
|