From ae21c3c5d8e7c96101d278b5688c7f40133dece9 Mon Sep 17 00:00:00 2001 From: Troy grunt Date: Fri, 13 Feb 2026 21:59:45 +0100 Subject: [PATCH] admin update, und upload --- .gitignore | 2 + .vscode/settings.json | 3 + README.md | 62 +++++++++- www/admin.php | 268 ++++++++++++++++++++++++++++++++++++++++-- www/card.php | 40 ++++++- www/files/.gitkeep | 1 + 6 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 www/files/.gitkeep diff --git a/.gitignore b/.gitignore index 13e46b8..af96255 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..a86a35d 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/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. +* 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/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/www/admin.php b/www/admin.php index 5a826cc..8d246f7 100644 --- a/www/admin.php +++ b/www/admin.php @@ -81,6 +81,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,8 +265,94 @@ if ($action === 'identity_edit') { exit('Identität nicht gefunden'); } + $fileUploadErrors = []; + $fileUploadSuccess = 0; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + 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.', $originalName); + 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'; + } + $storedName = bin2hex(random_bytes(12)) . '_' . $safeName; + $destination = $uploadDir . $storedName; + + 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.'; + } + } + // Identität umbenennen if (isset($_POST['rename'])) { $sql->set( @@ -296,6 +413,15 @@ 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 = []; + } ?> @@ -334,17 +460,13 @@ if ($action === 'identity_edit') { - - - - - - + +
+ +
- @@ -367,8 +489,10 @@ if ($action === 'identity_edit') {

Neues Feld

- - @@ -377,6 +501,125 @@ if ($action === 'identity_edit') {
+

Dateien hochladen

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

+ Datei hochgeladen. +

+ + + + + + + +

Vorhandene Dateien

+ + + + + +

← zurück

@@ -409,7 +652,6 @@ if ($action === 'uuid_create') { EDIT UUID ───────────────────────────── */ if ($action === 'uuid_edit') { - //TODO es fehlt die auswahl welche felder sichtbar sein sollen. $uuid = $_GET['uuid'] ?? ''; $token = $sql->single( 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/files/.gitkeep b/www/files/.gitkeep new file mode 100644 index 0000000..cd4950f --- /dev/null +++ b/www/files/.gitkeep @@ -0,0 +1 @@ +This directory stores uploaded files outside the document root.