diff --git a/.gitignore b/.gitignore index 13e46b8..ce93d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ www/secret.php www/_user.php +www/_files/* +!www/_files/.gitkeep diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..221edb8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "todo-tree.tree.scanMode": "workspace only" +} \ No newline at end of file diff --git a/README.md b/README.md index 07296c5..726d148 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,60 @@ -# businesscard +# Digitale Visitenkarten (Businesscard) -## TODO -- admin ident werte typen \ No newline at end of file +Dieses Projekt stellt eine einfache Verwaltungsoberfläche für digitale Visitenkarten bereit. Jede Karte wird über eine UUID adressiert, die eindeutig einem Datensatz in der Datenbank zugeordnet ist. Besucher:innen rufen die Karte über card.php?uuid= auf, während Administrator:innen über das Passwort-geschützte Dashboard alle Identitäten, Feldwerte, Dateien und UUID-Token pflegen. + +## Architektur + +* **PHP/Apache**-Weboberfläche in www/ mit Frontend (card.php, download.php) und Backend (admin.php plus _sql.php, _func.php, _user.php). +* **MariaDB** (Docker) hält Tabellen für Identitäten, Felder, UUIDs, Berechtigungen, Dateien und Loginversuche (db/init/001_schema.sql). +* **Docker Compose** startet Webserver und Datenbank; www/ wird in den Apache-Container gemountet, www/_files/ spiegelt das Upload-Verzeichnis /var/www/html/_files/. +* **Uploadverzeichnis** www/_files/ liegt außerhalb des Document Roots und ist als .gitkeep im Repository abgelegt, damit Hochladungen später von download.php referenziert werden. + +## Installation & erster Start + +1. Repository clonen. +2. www/secret.php vom Beispiel kopieren (secret.php.example) und Datenbankzugang konfigurieren, damit _sql.php sich verbinden kann. +3. mkdir -p www/_files (oder md www/_files unter Windows) ausführen und darauf achten, dass die .gitkeep-Datei vorhanden ist, damit der Ordner beim Containerstart genutzt wird. +4. docker-compose up --build im Projektverzeichnis ausführen, um Apache und MariaDB zu starten. +5. http://localhost/admin.php öffnen; die Standardanmeldedaten stehen in www/_user.php (admin / password) und sollten sofort geändert werden. + +## Admin-Oberfläche & Workflows + +* Login schützt das Dashboard; nach drei gescheiterten Versuchen wird eine IP für eine Stunde gesperrt (admin_login_attempts). +* Das Dashboard zeigt alle Identitäten sowie UUID-Token. Neue Identitäten erstellt man über ?action=identity_create. +* Jedes Identity kann beliebige Felder (identity_fields) enthalten. Der Typ kann single, multi, file oder url sein. +* Dateien werden pro Identität über das neue Upload-Formular hinzugefügt und landen in www/_files/. Der Upload speichert Dateiname, generierten Zwischennamen und MIME-Type in files. Unter dem Formular zeigt das Admin-UI eine Liste der bereits hochgeladenen Dateien inkl. einer Löschoption. +* Wenn ein Feld auf Typ file gesetzt ist, zeigt die Auswahl nur Uploads der aktuellen Identität (per files.identity_id). Die ausgewählte Datei-ID wird im Feldwert gespeichert, damit später nur diese Datei angezeigt oder heruntergeladen wird. +* UUID-Token (access_tokens) verbinden eine Identität mit einer eindeutigen Zeichenfolge, können optional Auslaufdaten und Notizen erhalten und dürfen **nur einmal** existieren. Tokenrechte (token_permissions) bestimmen, welche Feldschlüssel Besucher:innen sehen. + +## Besucheransicht (Visitenkarte) + +* /card.php?uuid=<UUID> lädt nur die Felder, die das Token freigibt, sowie Dateien mit demselben Token oder ohne Tokenbindung. +* Dateifelder erscheinen unterhalb der Felder als Downloadlinks, wenn der Token für die Datei berechtigt ist. +* Das Frontend zeigt Meta-Informationen (Ablaufdatum, Notiz) sowie eine gekürzte UUID. Bestehende Admin-Sessions leiten zurück zum Dashboard, damit Redakteur:innen schnell bearbeiten können. + +## Datenbankstruktur im Überblick + +| Tabelle | Zweck | +| --- | --- | +| identities | Identitätsdatensätze mit Name und Timestamp. | +| identity_fields | Key-Value-Felder (Text, mehrzeilig, Datei, URL) pro Identität. | +| access_tokens | UUID-Token pro Identität mit Ablauf, Notiz und eindeutiger Zeichenfolge. | +| token_permissions | Erlaubte Feldschlüssel pro Token. | +| files | Hochgeladene Dateien mit identity_id, filename, stored_name, mime_type und optional token_id. | +| admin_login_attempts | Brute-Force-Schutz mit Zählern und Sperrzeit pro IP. | + +## Sicherheitshinweise + +* **UUIDs sind einmalige Zugriffstoken** und sollten nicht doppelt vergeben oder offen weitergegeben werden, wenn geschützte Daten hinterlegt sind. +* **Admin-Zugang**: Bitte www/_user.php durch eigene Credentials ersetzen oder ein robusteres Auth-Verfahren implementieren. +* **Loginversuche** werden nach drei Fehlversuchen eine Stunde gesperrt (registerFailedLogin, isIpLocked, clearLoginAttempts). +* **Datei-Uploads** liegen im Verzeichnis www/_files/ (Container: /var/www/html/_files/) und dürfen ausschließlich über download.php mit gültigem Token ausgeliefert werden. Der Ordner sollte nicht direkt vom Webserver referenziert werden. + +## Weiterentwicklung & Pflege + +1. **Stil & Branding**: Die Inline-CSS im card.php-Head kann durch ein eigenes Template ersetzt oder ausgelagert werden. +2. **Dateien & Automatisierung**: UUIDs und zugehörige Dateien lassen sich per API oder QR-Code verteilen. Die Download-Logik kann um Freigabezeitfenster erweitert werden. +3. **Tests & Validierung**: Neue Felder brauchen eventuell Frontend-Validierung; Dateiuploads sollten auf MIME-Type, Größe und erlaubte Endungen geprüft werden. +4. **Deployment**: Docker Compose genügt lokal. In Produktion empfiehlt sich ein Reverse Proxy, HTTPS und regelmäßige Backups des MariaDB-Volumes (db_data). Auch www/_files/ sollte gesichert werden, da dort alle Uploads gespeichert werden. + +> Hinweis: UUIDs repräsentieren digitale Visitenkarten und sind **einmalig**. Jeder Besuch über eine UUID dokumentiert genau eine Karte, deren Sichtbarkeit über Tokenrechte und Datei-Zuordnungen kontrolliert wird. diff --git a/docker-compose.yml b/docker-compose.yml index 1af2243..0b4ed12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - ./www:/var/www/html depends_on: - db + command: ["sh", "-c", "mkdir -p /var/www/html/_files && chown -R www-data:www-data /var/www/html/_files && apache2-foreground"] db: image: mariadb:11 diff --git a/www/admin.php b/www/admin.php index 7078c63..8d48a4a 100644 --- a/www/admin.php +++ b/www/admin.php @@ -6,6 +6,11 @@ require '_user.php'; session_start(); $ip = $_SERVER['REMOTE_ADDR']; +const MAX_UPLOAD_FILE_SIZE_MB = 32; +const MAX_UPLOAD_FILE_SIZE_BYTES = MAX_UPLOAD_FILE_SIZE_MB * 1024 * 1024; +ini_set('upload_max_filesize', MAX_UPLOAD_FILE_SIZE_MB . 'M'); +ini_set('post_max_size', (MAX_UPLOAD_FILE_SIZE_MB + 1) . 'M'); + /* ───────────────────────────── Security ───────────────────────────── */ @@ -81,6 +86,7 @@ button {
+ $attrVal) { + $extraAttrString .= ' ' . htmlspecialchars($attr) . '="' . htmlspecialchars($attrVal) . '"'; + } + + if ($type === 'file') { + $options = ''; + foreach ($filesForIdentity as $file) { + $fileId = (string)(int)$file['id']; + $selected = $fileId === $value ? ' selected' : ''; + $options .= sprintf( + '', + htmlspecialchars($fileId), + $selected, + htmlspecialchars($file['filename']) + ); + } + return ""; + } + + if ($type === 'multi') { + return ""; + } + + $inputType = $type === 'url' ? 'url' : 'text'; + return ""; + } $id = (int)($_GET['id'] ?? 0); $identity = $sql->single( @@ -234,7 +270,146 @@ if ($action === 'identity_edit') { exit('Identität nicht gefunden'); } + $uploadErrorLabel = function (int $error): string { + return match ($error) { + UPLOAD_ERR_INI_SIZE => 'Datei überschreitet upload_max_filesize', + UPLOAD_ERR_FORM_SIZE => 'Datei überschreitet MAX_FILE_SIZE', + UPLOAD_ERR_PARTIAL => 'Datei wurde nur teilweise hochgeladen', + UPLOAD_ERR_NO_FILE => 'keine Datei ausgewählt', + UPLOAD_ERR_NO_TMP_DIR => 'temporäres Verzeichnis fehlt', + UPLOAD_ERR_CANT_WRITE => 'Datei konnte nicht geschrieben werden', + UPLOAD_ERR_EXTENSION => 'Upload durch eine Erweiterung abgebrochen', + default => 'unbekannter Upload-Fehler', + }; + }; + + $fileUploadErrors = $_SESSION['fileUploadErrors'] ?? []; + unset($_SESSION['fileUploadErrors']); + $fileUploadSuccess = (int)($_SESSION['fileUploadSuccess'] ?? 0); + unset($_SESSION['fileUploadSuccess']); + $fileDeleteMessage = $_SESSION['fileDeleteMessage'] ?? ''; + unset($_SESSION['fileDeleteMessage']); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $fileUploadErrors = []; + $fileUploadSuccess = 0; + $fileDeleteMessage = ''; + + if (isset($_POST['upload_files'])) { + $filesInput = $_FILES['files'] ?? null; + $hasSelection = false; + if ($filesInput) { + if (is_array($filesInput['name'])) { + foreach ($filesInput['name'] as $fileName) { + if (trim((string)$fileName) !== '') { + $hasSelection = true; + break; + } + } + } else { + $hasSelection = trim((string)$filesInput['name']) !== ''; + } + } + + if ($filesInput && $hasSelection) { + $uploadDir = __DIR__ . '/_files/'; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) { + $fileUploadErrors[] = 'Upload-Verzeichnis kann nicht erstellt werden.'; + } else { + $total = is_array($filesInput['name']) ? count($filesInput['name']) : 1; + $uploaded = 0; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + for ($i = 0; $i < $total; $i++) { + $originalName = is_array($filesInput['name']) ? $filesInput['name'][$i] : $filesInput['name']; + $error = is_array($filesInput['error']) ? $filesInput['error'][$i] : $filesInput['error']; + $tmpName = is_array($filesInput['tmp_name']) ? $filesInput['tmp_name'][$i] : $filesInput['tmp_name']; + + if ($error === UPLOAD_ERR_NO_FILE) { + continue; + } + + $originalName = trim((string)$originalName); + if ($originalName === '') { + continue; + } + + if ($error !== UPLOAD_ERR_OK) { + $fileUploadErrors[] = sprintf( + 'Fehler beim Hochladen von %s: %s.', + $originalName, + $uploadErrorLabel($error) + ); + continue; + } + + if (!is_uploaded_file($tmpName)) { + $fileUploadErrors[] = sprintf('Datei %s konnte nicht verarbeitet werden.', $originalName); + continue; + } + + $safeName = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($originalName)); + if ($safeName === '') { + $safeName = 'file'; + } + do { + $storedName = bin2hex(random_bytes(12)) . '_' . $safeName; + $destination = $uploadDir . $storedName; + } while (is_file($destination)); + + if (!move_uploaded_file($tmpName, $destination)) { + $fileUploadErrors[] = sprintf('Speichern von %s fehlgeschlagen.', $originalName); + continue; + } + + $mimeType = $finfo ? finfo_file($finfo, $destination) : 'application/octet-stream'; + $sql->set( + "INSERT INTO files (identity_id, filename, stored_name, mime_type) + VALUES (?, ?, ?, ?)", + "isss", + [$id, $originalName, $storedName, $mimeType] + ); + $uploaded++; + } + + if ($finfo) { + finfo_close($finfo); + } + + if ($uploaded > 0) { + $fileUploadSuccess = $uploaded; + } + } + } else { + $fileUploadErrors[] = 'Bitte wählen Sie mindestens eine Datei aus.'; + } + } + + if (isset($_POST['delete_file'])) { + $fileId = (int)($_POST['file_id'] ?? 0); + if ($fileId > 0) { + $file = $sql->single( + "SELECT stored_name, filename FROM files WHERE id = ? AND identity_id = ?", + "ii", + [$fileId, $id] + ); + if ($file) { + $diskPath = __DIR__ . '/_files/' . $file['stored_name']; + if (is_file($diskPath)) { + @unlink($diskPath); + } + $sql->set( + "DELETE FROM files WHERE id = ?", + "i", + [$fileId] + ); + $fileDeleteMessage = sprintf('Datei "%s" gelöscht.', $file['filename']); + } else { + $fileUploadErrors[] = 'Datei nicht gefunden oder gehört nicht zu dieser Identität.'; + } + } else { + $fileUploadErrors[] = 'Ungültige Datei.'; + } + } // Identität umbenennen if (isset($_POST['rename'])) { @@ -287,6 +462,10 @@ if ($action === 'identity_edit') { ); } + $_SESSION['fileUploadErrors'] = $fileUploadErrors; + $_SESSION['fileUploadSuccess'] = $fileUploadSuccess; + $_SESSION['fileDeleteMessage'] = $fileDeleteMessage; + header("Location: admin.php?action=identity_edit&id=$id"); exit; } @@ -296,6 +475,27 @@ if ($action === 'identity_edit') { "i", [$id] ); + + $identityFiles = $sql->get( + "SELECT id, filename FROM files WHERE identity_id = ? ORDER BY uploaded_at DESC", + "i", + [$id] + ); + if ($identityFiles === false) { + $identityFiles = []; + } + $duplicateFilenameCounts = []; + foreach ($identityFiles as $file) { + $filename = $file['filename'] ?? ''; + if ($filename === '') { + continue; + } + $duplicateFilenameCounts[$filename] = ($duplicateFilenameCounts[$filename] ?? 0) + 1; + } + $duplicateFileNames = array_keys(array_filter( + $duplicateFilenameCounts, + static fn (int $count): bool => $count > 1 + )); ?> @@ -334,17 +534,13 @@ if ($action === 'identity_edit') { - - - - - - + +
+ +
- @@ -367,8 +563,10 @@ if ($action === 'identity_edit') {

Neues Feld

- - @@ -377,6 +575,143 @@ if ($action === 'identity_edit') {
+

Dateien hochladen

+
+ + +
+ + 0): ?> +

+ Datei hochgeladen. +

+ + + + + + + +

Vorhandene Dateien

+ +

+ + +

+ Dateien mit identischem Dateinamen bleiben getrennt und können einzeln gelöscht. +

+ + + + + + +

← zurück

@@ -466,6 +801,26 @@ if ($action === 'uuid_edit') { "i", [$token['identity_id']] ); + $files = $sql->get( + "SELECT id, filename FROM files + WHERE identity_id = ? + AND (token_id IS NULL OR token_id = ?) + ORDER BY uploaded_at DESC", + "ii", + [$token['identity_id'], $token['id']] + ); + if ($files === false) { + $files = []; + } + $fileLinks = array_filter(array_map(static function ($file) use ($uuid) { + if (empty($file['id'])) { + return null; + } + return [ + 'url' => '/download.php?id=' . (int)$file['id'] . '&uuid=' . urlencode($uuid), + 'filename' => (string)($file['filename'] ?: 'Datei'), + ]; + }, $files)); ?> UUID bearbeiten @@ -493,6 +848,20 @@ if ($action === 'uuid_edit') { + +

Dateien

+ + +

← zurück

get("SELECT * FROM identities ORDER BY id DESC"); diff --git a/www/card.php b/www/card.php index 48562c2..371936b 100644 --- a/www/card.php +++ b/www/card.php @@ -170,6 +170,30 @@ function label(string $key): string { text-align: center; font-size: .75rem; color: var(--muted); + display: flex; + flex-direction: column; + gap: .25rem; + align-items: center; + } + + .footer-meta { + display: flex; + gap: .65rem; + font-size: .7rem; + flex-wrap: wrap; + justify-content: center; + color: var(--muted); + } + + .footer-meta span { + padding: .15rem .4rem; + border-radius: 999px; + background: rgba(148, 163, 184, .18); + } + + .footer-note { + font-size: .65rem; + color: rgba(229, 231, 235, .7); } @@ -203,10 +227,22 @@ function label(string $key): string { diff --git a/www/download.php b/www/download.php index 988f7b8..b841e47 100644 --- a/www/download.php +++ b/www/download.php @@ -6,13 +6,19 @@ require '_func.php'; * Eingaben prüfen */ $fileId = isset($_GET['id']) ? (int)$_GET['id'] : 0; -$uuid = $_GET['uuid'] ?? null; +$uuid = trim($_GET['uuid'] ?? ''); if ($fileId <= 0 || !$uuid) { http_response_code(400); exit('Ungültige Anfrage'); } +if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid)) { + http_response_code(400); + exit('Ungültige Anfrage'); + //TODO fehlgeschlagener zugriff als fehlerhafte anmeldung ansehen und bei wiederholung ip sperren +} + /** * Token validieren */ @@ -51,10 +57,10 @@ if (!$file) { /** * Dateipfad */ -$basePath = '/var/www/files/'; -$path = realpath($basePath . $file['stored_name']); +$basePath = realpath(__DIR__ . '/_files'); +$path = $basePath ? realpath($basePath . '/' . $file['stored_name']) : null; -if (!$path || !str_starts_with($path, $basePath) || !is_file($path)) { +if (!$path || !$basePath || !str_starts_with($path, $basePath) || !is_file($path)) { http_response_code(404); exit('Datei fehlt'); }