adding file upload & tags

This commit is contained in:
2026-05-12 09:52:15 +02:00
parent 494f30d6c3
commit 988da11f80
10 changed files with 366 additions and 9 deletions
+128 -2
View File
@@ -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':
+16
View File
@@ -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();
+3
View File
@@ -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:
+4 -1
View File
@@ -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
+20
View File
@@ -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'),
+5
View File
@@ -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;
}
+2
View File
@@ -4,3 +4,5 @@ session.cookie_lifetime = 0
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
upload_max_filesize = 50M
post_max_size = 50M
+24
View File
@@ -257,3 +257,27 @@ kbd {
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;
}
+145 -3
View File
@@ -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 =>
`<div class="small text-secondary"><i class="fas ${getFileIcon(f.type, f.name)} me-1"></i>${esc(f.name)} (${formatFileSize(f.size)})</div>`
).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 = '<option value="">All Tags</option>' + allTags.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).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() {
</div>
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6>
${e.description ? '<p class="mb-1 small text-secondary">' + renderDocLinks(e.description) + '</p>' : ''}
${renderTimelineTags(e.tags)}
<div class="d-flex justify-content-between align-items-center mt-1">
<div class="doc-links-inline" id="docLinks-${e.id}">${renderInlineDocLinks(e.description || '')}</div>
<div>
@@ -294,6 +329,7 @@ function renderTimeline() {
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteEvent(${e.id}, this)" title="Delete event" style="font-size:.7rem;"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="attachments-list mt-1" id="attachments-${e.id}" data-event-id="${e.id}"></div>
<div class="mt-2" id="comments-${e.id}">
<div class="d-flex align-items-center mb-1"><small class="text-secondary fw-bold"><i class="fas fa-comment-dots me-1"></i>Comments ${e.comments && e.comments.length ? '(' + e.comments.length + ')' : ''}</small></div>
<div class="comment-log">${renderComments(e)}</div>
@@ -306,6 +342,11 @@ function renderTimeline() {
</div>
</div>`;
}).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 = '<small class="text-secondary"><i class="fas fa-spinner fa-spin me-1"></i>Loading...</small>';
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 `<div class="attachment-item d-flex align-items-center justify-content-between py-1">
<div class="d-flex align-items-center">
<i class="fas ${icon} me-2" style="font-size:.85rem;"></i>
<a href="/uploads/${a.stored_name}" target="_blank" class="small text-decoration-none" style="word-break:break-all;">${esc(a.original_name)}</a>
<small class="text-secondary ms-2">(${size})</small>
</div>
<button class="btn btn-outline-danger btn-sm py-0 px-1 ms-2" onclick="deleteAttachment(${a.id}, this)" title="Delete file" style="font-size:.65rem;"><i class="fas fa-times"></i></button>
</div>`;
}).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 => `<span class="badge bg-secondary me-1" style="font-size:.7rem;">${esc(t)}</span>`).join('');
}
function renderTimelineTags(tags) {
if (!tags || !tags.length) return '';
return '<div class="d-flex flex-wrap gap-1 mb-1">' + tags.map(t =>
`<span class="badge" style="font-size:.6rem;background:#1e2a45;color:#94a3b8;border:1px solid #2a3a60;">${esc(t)}</span>`
).join('') + '</div>';
}
// ==================== NETWORK MAP DATA ====================
async function loadNetworkData() {
const [n, l, s] = await Promise.all([
+18 -2
View File
@@ -63,12 +63,17 @@
</div>
<div class="row mb-3">
<div class="col-md-4">
<div class="col-md-3">
<select class="form-select form-select-sm" id="teamFilter">
<option value="">All Teams</option>
</select>
</div>
<div class="col-md-8">
<div class="col-md-3">
<select class="form-select form-select-sm" id="tagFilter">
<option value="">All Tags</option>
</select>
</div>
<div class="col-md-6">
<input type="text" class="form-control form-control-sm" id="searchEvents" placeholder="Search events...">
</div>
</div>
@@ -240,6 +245,17 @@
</select>
</div>
</div>
<div class="mb-2">
<label class="form-label small">Tags</label>
<input type="text" class="form-control form-control-sm" id="eventTagsInput" placeholder="e.g. phishing, ransomware, ioc">
<small class="text-secondary">Comma-separated tags</small>
<div id="eventTagsDisplay" class="d-flex flex-wrap gap-1 mt-1"></div>
</div>
<div class="mb-2">
<label class="form-label small">File Attachments</label>
<input type="file" class="form-control form-control-sm" id="eventFileInput" multiple>
<div id="eventFilePreview" class="mt-1"></div>
</div>
</form>
</div>
<div class="modal-footer border-secondary">