diff --git a/backend/api/index.php b/backend/api/index.php index 30db320..c74a4c9 100644 --- a/backend/api/index.php +++ b/backend/api/index.php @@ -64,6 +64,9 @@ try { case 'links': handleLinks($method, $id, $db); break; + case 'documents': + handleDocuments($method, $id, $db); + break; case 'shapes': handleShapes($method, $id, $db); break; @@ -447,6 +450,115 @@ function handleLinks($method, $id, $db) { } } +function handleDocuments($method, $id, $db) { + $username = $_SESSION['neptune_username'] ?? 'Unknown'; + switch ($method) { + case 'GET': + if ($id) { + $stmt = $db->prepare(" + SELECT d.*, t.name AS team_name, t.color AS team_color + FROM documents d JOIN teams t ON d.team_id = t.id + WHERE d.id = ? + "); + $stmt->execute([$id]); + echo json_encode($stmt->fetch(PDO::FETCH_ASSOC)); + } else { + $teamFilter = $_GET['team_id'] ?? null; + $typeFilter = $_GET['doc_type'] ?? null; + $sql = " + SELECT d.*, t.name AS team_name, t.color AS team_color + FROM documents d JOIN teams t ON d.team_id = t.id + "; + $params = []; + $conditions = []; + if ($teamFilter) { + $conditions[] = "d.team_id = ?"; + $params[] = $teamFilter; + } + if ($typeFilter) { + $conditions[] = "d.doc_type = ?"; + $params[] = $typeFilter; + } + if ($conditions) $sql .= " WHERE " . implode(' AND ', $conditions); + $sql .= " ORDER BY d.occurred_at DESC"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + break; + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + $stmt = $db->prepare(" + INSERT INTO documents (doc_type, team_id, title, content, occurred_at) + VALUES (?, ?, ?, ?, ?) + "); + $stmt->execute([ + $data['doc_type'], + $data['team_id'], + $data['title'], + $data['content'] ?? '', + $data['occurred_at'] ?? date('Y-m-d H:i:s') + ]); + $docId = $db->lastInsertId(); + // Also create a timeline event for this document + $teamId = $data['team_id']; + $docType = $data['doc_type']; + $typeLabels = ['deployment' => 'Deployment', 'attack' => 'Attack', 'incident-report' => 'Incident Report', 'remediation' => 'Remediation', 'exercise' => 'Exercise']; + $typeLabel = $typeLabels[$docType] ?? ucfirst($docType); + $eventTitle = $typeLabel . ': ' . $data['title']; + $eventDesc = $username . ' created document "' . $data['title'] . '" (' . $typeLabel . ')'; + $stmt2 = $db->prepare(" + INSERT INTO events (team_id, title, description, severity, event_type, occurred_at) + VALUES (?, ?, ?, 'info', 'document', ?) + "); + $stmt2->execute([$teamId, $eventTitle, $eventDesc, $data['occurred_at'] ?? date('Y-m-d H:i:s')]); + echo json_encode(['id' => $docId]); + break; + case 'PUT': + if ($id) { + $data = json_decode(file_get_contents('php://input'), true); + $fields = []; + $params = []; + foreach (['doc_type','team_id','title','content','occurred_at'] as $f) { + if (isset($data[$f])) { + $fields[] = "$f = ?"; + $params[] = $data[$f]; + } + } + if ($fields) { + $params[] = $id; + $stmt = $db->prepare("UPDATE documents SET " . implode(', ', $fields) . " WHERE id = ?"); + $stmt->execute($params); + // Create a timeline event for the edit + if (isset($data['title']) || isset($data['doc_type'])) { + $docType = $data['doc_type'] ?? ''; + $docTitle = $data['title'] ?? ''; + $teamId = $data['team_id'] ?? null; + if ($teamId) { + $typeLabels = ['deployment' => 'Deployment', 'attack' => 'Attack', 'incident-report' => 'Incident Report', 'remediation' => 'Remediation', 'exercise' => 'Exercise']; + $typeLabel = $typeLabels[$docType] ?? ucfirst($docType); + $eventTitle = 'Updated ' . $typeLabel . ': ' . $docTitle; + $eventDesc = $username . ' updated document "' . $docTitle . '" (' . $typeLabel . ')'; + $stmt2 = $db->prepare(" + INSERT INTO events (team_id, title, description, severity, event_type, occurred_at) + VALUES (?, ?, ?, 'info', 'document', ?) + "); + $stmt2->execute([$teamId, $eventTitle, $eventDesc, date('Y-m-d H:i:s')]); + } + } + } + echo json_encode(['updated' => true]); + } + break; + case 'DELETE': + if ($id) { + $db->prepare("DELETE FROM documents WHERE id = ?")->execute([$id]); + echo json_encode(['deleted' => true]); + } + break; + } +} + function handleShapes($method, $id, $db) { switch ($method) { case 'GET': diff --git a/backend/config/database.php b/backend/config/database.php index 78e3945..db2ce72 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -16,6 +16,17 @@ function getDbConnection() { } function migrate($db) { + $db->exec("CREATE TABLE IF NOT EXISTS documents ( + id INT AUTO_INCREMENT PRIMARY KEY, + doc_type VARCHAR(50) NOT NULL, + team_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT, + occurred_at DATETIME NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE + )"); $db->exec("CREATE TABLE IF NOT EXISTS network_shapes ( id INT AUTO_INCREMENT PRIMARY KEY, label VARCHAR(255) NOT NULL DEFAULT '', diff --git a/docker/init.sql b/docker/init.sql index 12b469d..98f528d 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -65,6 +65,18 @@ CREATE TABLE IF NOT EXISTS network_shapes ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS documents ( + id INT AUTO_INCREMENT PRIMARY KEY, + doc_type VARCHAR(50) NOT NULL, + team_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT, + occurred_at DATETIME NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +); + INSERT IGNORE INTO teams (name, color) VALUES ('Blue Team', '#0d6efd'), ('Red Team', '#dc3545'), diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css index 6edabab..f05d029 100644 --- a/frontend/assets/css/style.css +++ b/frontend/assets/css/style.css @@ -232,4 +232,28 @@ kbd { .comment-input-group .btn:hover { background: var(--neptune-accent); color: #fff; +} + +/* Document Cards */ +.doc-card { + transition: border-color .2s, transform .15s; +} + +.doc-card:hover { + border-color: var(--neptune-accent); + transform: translateY(-2px); +} + +.doc-card .card-header { + background: rgba(59,130,246,.05); +} + +.doc-card .card-footer { + background: transparent; +} + +.doc-card .badge { + font-size: .65rem; + text-transform: uppercase; + letter-spacing: .03em; } \ No newline at end of file diff --git a/frontend/assets/js/app.js b/frontend/assets/js/app.js index 3c7df4d..e05eac2 100644 --- a/frontend/assets/js/app.js +++ b/frontend/assets/js/app.js @@ -1,6 +1,8 @@ const API = '/api/'; let teams = []; let events = []; +let documents = []; +let editingDocId = null; let nodes = []; let links = []; let shapes = []; @@ -119,7 +121,10 @@ function initApp() { ctx = canvas.getContext('2d'); resizeCanvas(); - loadTeams().then(() => loadEvents()); + loadTeams().then(() => { + loadEvents(); + loadDocuments(); + }); loadNetworkData(); document.getElementById('saveEvent').addEventListener('click', saveEvent); @@ -130,6 +135,10 @@ function initApp() { document.getElementById('logoutBtn').addEventListener('click', logout); document.getElementById('teamFilter').addEventListener('change', renderTimeline); document.getElementById('searchEvents').addEventListener('input', renderTimeline); + document.getElementById('saveDocument').addEventListener('click', saveDocument); + document.getElementById('docTeamFilter').addEventListener('change', renderDocuments); + document.getElementById('docTypeFilter').addEventListener('change', renderDocuments); + document.getElementById('searchDocs').addEventListener('input', renderDocuments); document.getElementById('shapeOpacity').addEventListener('input', (e) => { document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2); }); @@ -949,6 +958,265 @@ async function removeUser(id) { document.getElementById('settingsModal').addEventListener('show.bs.modal', loadUsers); +// ==================== DOCUMENTS ==================== +const DOC_TYPE_ICONS = { + deployment: { icon: 'fa-server', color: '#06b6d4' }, + attack: { icon: 'fa-bolt', color: '#ef4444' }, + 'incident-report': { icon: 'fa-exclamation-triangle', color: '#f59e0b' }, + remediation: { icon: 'fa-wrench', color: '#22c55e' }, + exercise: { icon: 'fa-dumbbell', color: '#3b82f6' } +}; + +const DOC_TYPE_LABELS = { + deployment: 'Deployment', + attack: 'Attack', + 'incident-report': 'Incident Report', + remediation: 'Remediation', + exercise: 'Exercise' +}; + +async function loadDocuments() { + documents = await apiFetch('documents'); + populateDocTeamFilter(); + renderDocuments(); +} + +function populateDocTeamFilter() { + const sel = document.getElementById('docTeamFilter'); + const teamSel = document.getElementById('docTeam'); + sel.innerHTML = ''; + teamSel.innerHTML = ''; + teams.forEach(t => { + sel.innerHTML += ``; + teamSel.innerHTML += ``; + }); +} + +function renderDocuments() { + const container = document.getElementById('documentContainer'); + const teamFilter = document.getElementById('docTeamFilter').value; + const typeFilter = document.getElementById('docTypeFilter').value; + const search = document.getElementById('searchDocs').value.toLowerCase(); + + let filtered = documents; + if (teamFilter) filtered = filtered.filter(d => d.team_id == teamFilter); + if (typeFilter) filtered = filtered.filter(d => d.doc_type == typeFilter); + if (search) filtered = filtered.filter(d => + d.title.toLowerCase().includes(search) || + (d.content && d.content.toLowerCase().includes(search)) + ); + + if (!filtered.length) { + container.innerHTML = '

No documents yet. Create a deployment, attack report, or more!

'; + return; + } + + container.innerHTML = '
' + filtered.map(d => { + const meta = DOC_TYPE_ICONS[d.doc_type] || DOC_TYPE_ICONS.deployment; + const label = DOC_TYPE_LABELS[d.doc_type] || d.doc_type; + const date = new Date(d.occurred_at).toLocaleString(); + const contentPreview = d.content ? d.content.substring(0, 150) + (d.content.length > 150 ? '...' : '') : ''; + return ` +
+
+
+
+ + ${label} + + ${esc(d.team_name)} +
+ ${date} +
+
+
${esc(d.title)}
+ ${contentPreview ? '

' + esc(contentPreview) + '

' : ''} +
+ +
+
`; + }).join('') + '
'; +} + +const DOC_FIELD_TEMPLATES = { + deployment: [ + { id: 'docTarget', label: 'Target System / Host', type: 'text', placeholder: 'e.g. web-server-01, firewall-cluster' }, + { id: 'docVersion', label: 'Version / Build', type: 'text', placeholder: 'e.g. v2.1.0, build 42' }, + { id: 'docStatus', label: 'Status', type: 'select', options: ['Planned', 'In Progress', 'Completed', 'Rolled Back', 'Failed'] } + ], + attack: [ + { id: 'docSource', label: 'Attack Source / IP', type: 'text', placeholder: 'e.g. 10.0.0.5, External C2' }, + { id: 'docVector', label: 'Attack Vector', type: 'select', options: ['Phishing', 'Brute Force', 'Malware', 'DDoS', 'SQL Injection', 'XSS', 'Social Engineering', 'Physical', 'Other'] }, + { id: 'docImpact', label: 'Impact', type: 'select', options: ['None', 'Low', 'Medium', 'High', 'Critical'] } + ], + 'incident-report': [ + { id: 'docSeverity', label: 'Severity', type: 'select', options: ['Info', 'Low', 'Medium', 'High', 'Critical'] }, + { id: 'docDetectedBy', label: 'Detected By', type: 'text', placeholder: 'e.g. SOC, Automated Alert, User Report' }, + { id: 'docContainment', label: 'Containment Status', type: 'select', options: ['Not Contained', 'In Progress', 'Contained', 'Resolved'] } + ], + remediation: [ + { id: 'docAffected', label: 'Affected Systems', type: 'text', placeholder: 'e.g. mail-server-02, all endpoints' }, + { id: 'docAction', label: 'Action Taken', type: 'select', options: ['Patch Applied', 'Config Change', 'Rule Updated', 'Access Revoked', 'Network Isolated', 'Other'] }, + { id: 'docVerified', label: 'Verification', type: 'select', options: ['Pending', 'Verified', 'Failed Verification'] } + ], + exercise: [ + { id: 'docScenario', label: 'Scenario', type: 'text', placeholder: 'e.g. Ransomware simulation, phishing drill' }, + { id: 'docParticipants', label: 'Participants', type: 'text', placeholder: 'e.g. Blue Team, SOC, external red team' }, + { id: 'docOutcome', label: 'Outcome', type: 'select', options: ['Pass', 'Partial Pass', 'Fail', 'Inconclusive', 'Not Evaluated'] } + ] +}; + +function updateDocFormFields() { + const docType = document.getElementById('docType').value; + const container = document.getElementById('dynamicDocFields'); + const templates = DOC_FIELD_TEMPLATES[docType] || []; + container.innerHTML = templates.map(f => { + if (f.type === 'select') { + const opts = f.options.map(o => ``).join(''); + return ` +
+ + +
`; + } + return ` +
+ + +
`; + }).join(''); + // Restore values if editing + if (editingDocId && window._editDocData) { + const data = window._editDocData; + templates.forEach(f => { + const el = document.getElementById(f.id); + if (el) el.value = data._fields?.[f.id] || ''; + }); + } +} + +function openNewDocument(type) { + editingDocId = null; + document.getElementById('documentModalLabel').textContent = 'New ' + (DOC_TYPE_LABELS[type] || type) + ' Document'; + document.getElementById('saveDocument').innerHTML = ' Save Document'; + document.getElementById('docType').value = type; + document.getElementById('docTitle').value = ''; + document.getElementById('docContent').value = ''; + window._editDocData = null; + updateDocFormFields(); + new bootstrap.Modal(document.getElementById('documentModal')).show(); +} + +function collectDocFields() { + const docType = document.getElementById('docType').value; + const templates = DOC_FIELD_TEMPLATES[docType] || []; + const fields = {}; + templates.forEach(f => { + const el = document.getElementById(f.id); + if (el) fields[f.id] = el.value; + }); + return fields; +} + +async function saveDocument() { + const docType = document.getElementById('docType').value; + const teamId = document.getElementById('docTeam').value; + const title = document.getElementById('docTitle').value.trim(); + const content = document.getElementById('docContent').value.trim(); + const fields = collectDocFields(); + + if (!title) return alert('Title is required'); + if (!teamId) return alert('Team is required'); + + // Build structured content from fields + const label = DOC_TYPE_LABELS[docType] || docType; + let fullContent = content; + const fieldEntries = Object.entries(fields).filter(([, v]) => v); + if (fieldEntries.length) { + const fieldPrefix = fieldEntries.map(([k, v]) => { + const tmpl = (DOC_FIELD_TEMPLATES[docType] || []).find(t => t.id === k); + return (tmpl ? tmpl.label + ': ' : k + ': ') + v; + }).join('\n'); + fullContent = fieldPrefix + (content ? '\n\n' + content : ''); + } + + const data = { + doc_type: docType, + team_id: teamId, + title: title, + content: fullContent, + occurred_at: new Date().toISOString().slice(0, 16) + }; + + if (editingDocId) { + data._fields = fields; + await apiFetch('documents/' + editingDocId, { method: 'PUT', body: JSON.stringify(data) }); + editingDocId = null; + } else { + await apiFetch('documents', { method: 'POST', body: JSON.stringify(data) }); + } + + bootstrap.Modal.getInstance(document.getElementById('documentModal')).hide(); + document.getElementById('documentForm').reset(); + window._editDocData = null; + loadDocuments(); + loadEvents(); +} + +async function editDocument(id) { + const d = documents.find(x => x.id == id); + if (!d) return; + editingDocId = id; + window._editDocData = d; + + // Parse structured content back into fields + const docType = d.doc_type; + const templates = DOC_FIELD_TEMPLATES[docType] || []; + const parsedFields = {}; + if (d.content) { + const lines = d.content.split('\n'); + let inContent = false; + let contentParts = []; + for (const line of lines) { + if (!inContent && line.includes(': ')) { + const colonIdx = line.indexOf(': '); + const fieldLabel = line.substring(0, colonIdx); + const fieldVal = line.substring(colonIdx + 2); + const tmpl = templates.find(t => t.label === fieldLabel); + if (tmpl) { + parsedFields[tmpl.id] = fieldVal; + continue; + } + } + if (!line.trim() && !inContent) { inContent = true; continue; } + if (inContent) contentParts.push(line); + } + d._parsedContent = contentParts.join('\n').trim(); + } + + document.getElementById('documentModalLabel').textContent = 'Edit ' + (DOC_TYPE_LABELS[d.doc_type] || d.doc_type) + ' Document'; + document.getElementById('saveDocument').innerHTML = ' Update Document'; + document.getElementById('docType').value = d.doc_type; + document.getElementById('docTeam').value = d.team_id; + document.getElementById('docTitle').value = d.title; + document.getElementById('docContent').value = d._parsedContent || ''; + + updateDocFormFields(); + new bootstrap.Modal(document.getElementById('documentModal')).show(); +} + +async function deleteDocument(id) { + if (!confirm('Delete this document?')) return; + await apiFetch('documents/' + id, { method: 'DELETE' }); + loadDocuments(); + loadEvents(); +} + if (!CanvasRenderingContext2D.prototype.roundRect) { CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) { if (r > w / 2) r = w / 2; diff --git a/frontend/index.html b/frontend/index.html index 961d3e1..ce407a0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -32,6 +32,11 @@ Network Map +
@@ -136,6 +141,56 @@
+
+
+
+

Documents

+ Standardized forms for deployments, attacks, incident reports & more +
+
+
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+

Loading documents...

+
+
@@ -338,6 +393,53 @@ + + +