From 988da11f804a8c4664b6b7d571fdfd2811536ab4 Mon Sep 17 00:00:00 2001 From: janis steiner Date: Tue, 12 May 2026 09:52:15 +0200 Subject: [PATCH] adding file upload & tags --- backend/api/index.php | 130 ++++++++++++++++++++++++++++- backend/config/database.php | 16 ++++ docker-compose.yml | 3 + docker/Dockerfile.php | 5 +- docker/init.sql | 20 +++++ docker/nginx.conf | 5 ++ docker/php.ini | 4 +- frontend/assets/css/style.css | 24 ++++++ frontend/assets/js/app.js | 148 +++++++++++++++++++++++++++++++++- frontend/index.html | 20 ++++- 10 files changed, 366 insertions(+), 9 deletions(-) diff --git a/backend/api/index.php b/backend/api/index.php index 9fec74f..cf67622 100644 --- a/backend/api/index.php +++ b/backend/api/index.php @@ -73,6 +73,16 @@ try { case 'registration': handleRegistration($method, $db); break; + case 'attachments': + handleAttachments($method, $id, $db); + break; + case 'tags': + if ($method === 'GET') { + echo json_encode($db->query("SELECT DISTINCT tag FROM event_tags ORDER BY tag")->fetchAll(PDO::FETCH_COLUMN)); + } else { + http_response_code(405); + } + break; default: http_response_code(404); echo json_encode(['error' => 'Not found']); @@ -315,6 +325,23 @@ function handleTeams($method, $id, $db) { } } +function loadEventTags($eventId, $db) { + $tstmt = $db->prepare("SELECT tag FROM event_tags WHERE event_id = ? ORDER BY tag"); + $tstmt->execute([$eventId]); + return array_column($tstmt->fetchAll(PDO::FETCH_ASSOC), 'tag'); +} + +function saveEventTags($eventId, $tags, $db) { + $db->prepare("DELETE FROM event_tags WHERE event_id = ?")->execute([$eventId]); + if (!empty($tags)) { + $istmt = $db->prepare("INSERT IGNORE INTO event_tags (event_id, tag) VALUES (?, ?)"); + foreach ($tags as $tag) { + $tag = trim($tag); + if ($tag !== '') $istmt->execute([$eventId, $tag]); + } + } +} + function handleEvents($method, $id, $db) { switch ($method) { case 'GET': @@ -330,19 +357,28 @@ function handleEvents($method, $id, $db) { $cstmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC"); $cstmt->execute([$id]); $event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC); + $event['tags'] = loadEventTags($id, $db); } echo json_encode($event); } else { $teamFilter = $_GET['team_id'] ?? null; + $tagFilter = $_GET['tag'] ?? null; $sql = " SELECT e.*, t.name AS team_name, t.color AS team_color FROM events e JOIN teams t ON e.team_id = t.id "; $params = []; + $conditions = []; if ($teamFilter) { - $sql .= " WHERE e.team_id = ?"; + $conditions[] = "e.team_id = ?"; $params[] = $teamFilter; } + if ($tagFilter) { + $sql .= " JOIN event_tags et ON e.id = et.event_id"; + $conditions[] = "et.tag = ?"; + $params[] = $tagFilter; + } + if ($conditions) $sql .= " WHERE " . implode(' AND ', $conditions); $sql .= " ORDER BY e.occurred_at DESC"; $stmt = $db->prepare($sql); $stmt->execute($params); @@ -351,6 +387,7 @@ function handleEvents($method, $id, $db) { $cstmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC"); $cstmt->execute([$event['id']]); $event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC); + $event['tags'] = loadEventTags($event['id'], $db); } echo json_encode($events); } @@ -369,7 +406,11 @@ function handleEvents($method, $id, $db) { $data['event_type'] ?? 'general', $data['occurred_at'] ?? date('Y-m-d H:i:s') ]); - echo json_encode(['id' => $db->lastInsertId()]); + $eventId = $db->lastInsertId(); + if (isset($data['tags']) && is_array($data['tags'])) { + saveEventTags($eventId, $data['tags'], $db); + } + echo json_encode(['id' => $eventId]); break; case 'PUT': if ($id) { @@ -387,11 +428,21 @@ function handleEvents($method, $id, $db) { $stmt = $db->prepare("UPDATE events SET " . implode(', ', $fields) . " WHERE id = ?"); $stmt->execute($params); } + if (isset($data['tags']) && is_array($data['tags'])) { + saveEventTags($id, $data['tags'], $db); + } echo json_encode(['updated' => true]); } break; case 'DELETE': if ($id) { + // Delete attachments from disk + $stmt = $db->prepare("SELECT stored_name FROM file_attachments WHERE event_id = ?"); + $stmt->execute([$id]); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $att) { + $path = '/var/www/uploads/' . $att['stored_name']; + if (file_exists($path)) unlink($path); + } $stmt = $db->prepare("DELETE FROM events WHERE id = ?"); $stmt->execute([$id]); echo json_encode(['deleted' => true]); @@ -612,6 +663,81 @@ function handleDocuments($method, $id, $db) { } } +function handleAttachments($method, $id, $db) { + $username = $_SESSION['neptune_username'] ?? 'Unknown'; + $uploadDir = '/var/www/uploads/'; + + switch ($method) { + case 'GET': + $eventId = $_GET['event_id'] ?? null; + if ($eventId) { + $stmt = $db->prepare("SELECT id, event_id, original_name, stored_name, mime_type, file_size, uploaded_by, created_at FROM file_attachments WHERE event_id = ? ORDER BY created_at ASC"); + $stmt->execute([$eventId]); + echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)); + } else { + http_response_code(400); + echo json_encode(['error' => 'event_id required']); + } + break; + + case 'POST': + if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { + http_response_code(400); + echo json_encode(['error' => 'Upload failed']); + return; + } + $eventId = $_POST['event_id'] ?? null; + if (!$eventId) { + http_response_code(400); + echo json_encode(['error' => 'event_id required']); + return; + } + $file = $_FILES['file']; + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $storedName = uniqid('att_', true) . '.' . $ext; + $dest = $uploadDir . $storedName; + + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + if (!move_uploaded_file($file['tmp_name'], $dest)) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save file']); + return; + } + + $stmt = $db->prepare(" + INSERT INTO file_attachments (event_id, original_name, stored_name, mime_type, file_size, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $eventId, + $file['name'], + $storedName, + $file['type'] ?: 'application/octet-stream', + $file['size'], + $username + ]); + echo json_encode(['id' => $db->lastInsertId()]); + break; + + case 'DELETE': + if ($id) { + $stmt = $db->prepare("SELECT stored_name FROM file_attachments WHERE id = ?"); + $stmt->execute([$id]); + $att = $stmt->fetch(PDO::FETCH_ASSOC); + if ($att) { + $path = $uploadDir . $att['stored_name']; + if (file_exists($path)) unlink($path); + $db->prepare("DELETE FROM file_attachments 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 4c60305..5a9d847 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -58,6 +58,22 @@ function migrate($db) { setting_key VARCHAR(100) PRIMARY KEY, setting_value TEXT NOT NULL )"); + try { $db->exec("CREATE TABLE IF NOT EXISTS file_attachments ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(100), + file_size INT, + uploaded_by VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )"); } catch (Exception $e) {} + try { $db->exec("CREATE TABLE IF NOT EXISTS event_tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + tag VARCHAR(50) NOT NULL, + UNIQUE KEY unique_event_tag (event_id, tag) + )"); } catch (Exception $e) {} try { $stmt = $db->prepare("SELECT COUNT(*) as c FROM neptune_settings WHERE setting_key = 'registration_enabled'"); $stmt->execute(); diff --git a/docker-compose.yml b/docker-compose.yml index 760924d..9584654 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: volumes: - ./frontend:/var/www/html - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf + - uploads_data:/var/www/uploads depends_on: - php networks: @@ -19,6 +20,7 @@ services: MYSQL_ROOT_PASSWORD: neptune_root_pass volumes: - ./backend:/var/www/backend + - uploads_data:/var/www/uploads depends_on: mysql: condition: service_healthy @@ -44,6 +46,7 @@ services: volumes: mysql_data: + uploads_data: networks: neptune: \ No newline at end of file diff --git a/docker/Dockerfile.php b/docker/Dockerfile.php index fed7843..99c7df9 100644 --- a/docker/Dockerfile.php +++ b/docker/Dockerfile.php @@ -3,7 +3,10 @@ FROM php:8.2-fpm RUN apt-get update && apt-get install -y libcurl4-openssl-dev && \ docker-php-ext-install pdo pdo_mysql curl && \ mkdir -p /tmp/sessions && \ - chmod 777 /tmp/sessions + chmod 777 /tmp/sessions && \ + mkdir -p /var/www/uploads && \ + chmod 755 /var/www/uploads && \ + chown www-data:www-data /var/www/uploads COPY docker/php.ini /usr/local/etc/php/conf.d/neptune.ini diff --git a/docker/init.sql b/docker/init.sql index 9758ae1..62b04d1 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -76,6 +76,26 @@ CREATE TABLE IF NOT EXISTS documents ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS file_attachments ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(100), + file_size INT, + uploaded_by VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS event_tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + tag VARCHAR(50) NOT NULL, + UNIQUE KEY unique_event_tag (event_id, tag), + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE +); + INSERT IGNORE INTO teams (name, color) VALUES ('Blue Team', '#0d6efd'), ('Red Team', '#dc3545'), diff --git a/docker/nginx.conf b/docker/nginx.conf index d93fec1..31899cd 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -14,6 +14,11 @@ server { include fastcgi_params; } + location /uploads/ { + alias /var/www/uploads/; + add_header Content-Disposition 'inline'; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/docker/php.ini b/docker/php.ini index 580d783..a2fccd3 100644 --- a/docker/php.ini +++ b/docker/php.ini @@ -3,4 +3,6 @@ session.gc_maxlifetime = 86400 session.cookie_lifetime = 0 session.use_strict_mode = 1 session.cookie_httponly = 1 -session.cookie_samesite = Lax \ No newline at end of file +session.cookie_samesite = Lax +upload_max_filesize = 50M +post_max_size = 50M \ No newline at end of file diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css index f05d029..c3c6d5b 100644 --- a/frontend/assets/css/style.css +++ b/frontend/assets/css/style.css @@ -256,4 +256,28 @@ kbd { font-size: .65rem; text-transform: uppercase; letter-spacing: .03em; +} + +/* Attachments */ +.attachments-list { + border-top: 1px solid var(--neptune-border); + padding-top: .35rem; + margin-top: .35rem; +} + +.attachment-item { + border-bottom: 1px solid rgba(30, 42, 69, 0.5); +} + +.attachment-item:last-child { + border-bottom: none; +} + +.attachment-item a { + color: var(--neptune-accent); +} + +.attachment-item a:hover { + color: #60a5fa; + text-decoration: underline !important; } \ No newline at end of file diff --git a/frontend/assets/js/app.js b/frontend/assets/js/app.js index b3279e1..0aa216e 100644 --- a/frontend/assets/js/app.js +++ b/frontend/assets/js/app.js @@ -152,6 +152,7 @@ function initApp() { document.getElementById('addUserBtn').addEventListener('click', addUser); document.getElementById('logoutBtn').addEventListener('click', logout); document.getElementById('teamFilter').addEventListener('change', renderTimeline); + document.getElementById('tagFilter').addEventListener('change', renderTimeline); document.getElementById('searchEvents').addEventListener('input', renderTimeline); document.getElementById('saveDocument').addEventListener('click', saveDocument); document.getElementById('documentModal').addEventListener('hidden.bs.modal', () => { @@ -166,6 +167,22 @@ function initApp() { document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2); }); + document.getElementById('eventFileInput').addEventListener('change', (e) => { + const preview = document.getElementById('eventFilePreview'); + const files = e.target.files; + if (!files.length) { preview.innerHTML = ''; return; } + preview.innerHTML = Array.from(files).map(f => + `
${esc(f.name)} (${formatFileSize(f.size)})
` + ).join(''); + }); + + document.getElementById('eventTagsInput').addEventListener('input', renderEventTagsPreview); + + document.getElementById('eventModal').addEventListener('hidden.bs.modal', () => { + document.getElementById('eventTagsDisplay').innerHTML = ''; + document.getElementById('eventTagsInput').value = ''; + }); + canvas.addEventListener('mousedown', onMouseDown); canvas.addEventListener('mousemove', onMouseMove); canvas.addEventListener('mouseup', onMouseUp); @@ -250,19 +267,36 @@ async function loadTeams() { async function loadEvents() { events = await apiFetch('events'); + loadAllTags(); renderTimeline(); } +let allTags = []; + +async function loadAllTags() { + try { + allTags = await apiFetch('tags'); + const sel = document.getElementById('tagFilter'); + if (!sel) return; + const current = sel.value; + sel.innerHTML = '' + allTags.map(t => ``).join(''); + sel.value = current; + } catch (e) {} +} + function renderTimeline() { const container = document.getElementById('timelineContainer'); const teamFilter = document.getElementById('teamFilter').value; + const tagFilter = document.getElementById('tagFilter').value; const search = document.getElementById('searchEvents').value.toLowerCase(); let filtered = events; if (teamFilter) filtered = filtered.filter(e => e.team_id == teamFilter); + if (tagFilter) filtered = filtered.filter(e => e.tags && e.tags.includes(tagFilter)); if (search) filtered = filtered.filter(e => e.title.toLowerCase().includes(search) || - (e.description && e.description.toLowerCase().includes(search)) + (e.description && e.description.toLowerCase().includes(search)) || + (e.tags && e.tags.some(t => t.includes(search))) ); if (!filtered.length) { @@ -287,6 +321,7 @@ function renderTimeline() {
${esc(e.title)}
${e.description ? '

' + renderDocLinks(e.description) + '

' : ''} + ${renderTimelineTags(e.tags)}
@@ -294,6 +329,7 @@ function renderTimeline() {
+
Comments ${e.comments && e.comments.length ? '(' + e.comments.length + ')' : ''}
${renderComments(e)}
@@ -306,6 +342,11 @@ function renderTimeline() {
`; }).join(''); + + filtered.forEach(e => { + const container = document.getElementById('attachments-' + e.id); + if (container) renderAttachments(e.id, container); + }); } function renderDocLinks(text) { @@ -450,22 +491,123 @@ async function deleteEvent(id, btn) { loadEvents(); } +async function uploadAttachment(eventId, file) { + const formData = new FormData(); + formData.append('file', file); + formData.append('event_id', eventId); + await fetch('/api/attachments', { method: 'POST', body: formData }); +} + +async function loadAttachments(eventId) { + try { + const res = await fetch('/api/attachments?event_id=' + eventId); + return await res.json(); + } catch (e) { + return []; + } +} + +async function deleteAttachment(id, el) { + if (!confirm('Delete this file?')) return; + await apiFetch('attachments/' + id, { method: 'DELETE' }); + const container = el.closest('.attachments-list'); + if (container) { + const eventId = container.dataset.eventId; + renderAttachments(eventId, container); + } +} + +async function renderAttachments(eventId, container) { + container.innerHTML = 'Loading...'; + const attachments = await loadAttachments(eventId); + if (!attachments.length) { + container.innerHTML = ''; + return; + } + container.innerHTML = attachments.map(a => { + const icon = getFileIcon(a.mime_type, a.original_name); + const size = formatFileSize(a.file_size); + return `
+
+ + ${esc(a.original_name)} + (${size}) +
+ +
`; + }).join(''); +} + +function formatFileSize(bytes) { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } + return size.toFixed(1) + ' ' + units[i]; +} + +function getFileIcon(mime, name) { + if (mime.startsWith('image/')) return 'fa-file-image'; + if (mime === 'application/pdf') return 'fa-file-pdf'; + if (mime.startsWith('text/')) return 'fa-file-alt'; + if (mime.includes('spreadsheet') || mime.includes('excel') || name.endsWith('.csv') || name.endsWith('.xls') || name.endsWith('.xlsx')) return 'fa-file-excel'; + if (mime.includes('document') || mime.includes('word') || name.endsWith('.doc') || name.endsWith('.docx')) return 'fa-file-word'; + if (name.endsWith('.md')) return 'fa-file-alt'; + return 'fa-file'; +} + async function saveEvent() { + const tags = parseEventTags(); const data = { team_id: document.getElementById('eventTeam').value, title: document.getElementById('eventTitle').value, description: document.getElementById('eventDescription').value, severity: document.getElementById('eventSeverity').value, event_type: document.getElementById('eventType').value, - occurred_at: new Date().toISOString().slice(0, 16) + occurred_at: new Date().toISOString().slice(0, 16), + tags: tags }; if (!data.title) return alert('Title required'); - await apiFetch('events', { method: 'POST', body: JSON.stringify(data) }); + + const result = await apiFetch('events', { method: 'POST', body: JSON.stringify(data) }); + const eventId = result.id; + + const fileInput = document.getElementById('eventFileInput'); + if (fileInput.files.length > 0) { + for (const file of fileInput.files) { + await uploadAttachment(eventId, file); + } + } bootstrap.Modal.getInstance(document.getElementById('eventModal')).hide(); document.getElementById('eventForm').reset(); + document.getElementById('eventFilePreview').innerHTML = ''; + document.getElementById('eventTagsDisplay').innerHTML = ''; + document.getElementById('eventTagsInput').value = ''; + fileInput.value = ''; loadEvents(); } +function parseEventTags() { + const input = document.getElementById('eventTagsInput'); + if (!input) return []; + return input.value.split(',').map(t => t.trim().toLowerCase()).filter(t => t.length > 0); +} + +function renderEventTagsPreview() { + const tags = parseEventTags(); + const container = document.getElementById('eventTagsDisplay'); + if (!container) return; + container.innerHTML = tags.map(t => `${esc(t)}`).join(''); +} + +function renderTimelineTags(tags) { + if (!tags || !tags.length) return ''; + return '
' + tags.map(t => + `${esc(t)}` + ).join('') + '
'; +} + // ==================== NETWORK MAP DATA ==================== async function loadNetworkData() { const [n, l, s] = await Promise.all([ diff --git a/frontend/index.html b/frontend/index.html index 4c8c731..0516fcd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -63,12 +63,17 @@
-
+
-
+
+ +
+
@@ -240,6 +245,17 @@
+
+ + + Comma-separated tags +
+
+
+ + +
+