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!
' + esc(contentPreview) + '
' : ''} +Loading documents...
+