From 25766959f18f353f472ebdab261746cc29cc0913 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 09:57:51 +0000 Subject: [PATCH] Add FamilyAlbums family photo portal with Nextcloud integration A PHP-based family photo album portal featuring: - Public gallery with year/month filtering and search - Mobile-responsive design with Tailwind CSS - Comment system for family members - Admin interface for album management - Flat-file JSON database (no MySQL needed) - CSRF protection and XSS prevention - Rate limiting and honeypot spam protection --- familyalbums/.htaccess | 48 +++ familyalbums/README.md | 131 +++++++ familyalbums/admin.php | 658 ++++++++++++++++++++++++++++++++ familyalbums/api.php | 350 +++++++++++++++++ familyalbums/config.php | 91 +++++ familyalbums/data/.htaccess | 8 + familyalbums/data/albums.json | 14 + familyalbums/data/comments.json | 3 + familyalbums/index.php | 449 ++++++++++++++++++++++ 9 files changed, 1752 insertions(+) create mode 100644 familyalbums/.htaccess create mode 100644 familyalbums/README.md create mode 100644 familyalbums/admin.php create mode 100644 familyalbums/api.php create mode 100644 familyalbums/config.php create mode 100644 familyalbums/data/.htaccess create mode 100644 familyalbums/data/albums.json create mode 100644 familyalbums/data/comments.json create mode 100644 familyalbums/index.php diff --git a/familyalbums/.htaccess b/familyalbums/.htaccess new file mode 100644 index 0000000..4d6ee6d --- /dev/null +++ b/familyalbums/.htaccess @@ -0,0 +1,48 @@ +# FamilyAlbums - Apache Configuration + +# Security Headers + + Header set X-Content-Type-Options "nosniff" + Header set X-Frame-Options "SAMEORIGIN" + Header set X-XSS-Protection "1; mode=block" + Header set Referrer-Policy "strict-origin-when-cross-origin" + + +# Deny access to config file + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Deny access to hidden files + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Enable compression + + AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript + + +# Cache static assets + + ExpiresActive On + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + + +# Default charset +AddDefaultCharset UTF-8 diff --git a/familyalbums/README.md b/familyalbums/README.md new file mode 100644 index 0000000..ac93da8 --- /dev/null +++ b/familyalbums/README.md @@ -0,0 +1,131 @@ +# FamilyAlbums - Familien-Fotoalbum-Portal + +Ein einfaches, PHP-basiertes Portal zur Verwaltung und Anzeige von Familien-Fotoalben mit Links zu Nextcloud. + +## Features + +- Öffentliche Galerie-Ansicht mit Jahr/Monat-Filter +- Stichwortsuche über Titel, Tags und Beschreibung +- Kommentarfunktion für Familienmitglieder +- Admin-Interface zur Albumverwaltung +- Responsive Design (Tailwind CSS) +- Flat-File Datenbank (JSON) - kein MySQL erforderlich +- Spam-Schutz (Honeypot + Rate-Limiting) +- CSRF-Schutz für Admin-Aktionen + +## Installation + +### 1. Dateien kopieren + +```bash +# Auf den Webserver kopieren +sudo cp -r familyalbums /var/www/ + +# Berechtigungen setzen +sudo chown -R www-data:www-data /var/www/familyalbums +sudo chmod -R 755 /var/www/familyalbums +sudo chmod 770 /var/www/familyalbums/data +sudo chmod 770 /var/www/familyalbums/thumbnails +``` + +### 2. Admin-Passwort ändern + +**WICHTIG:** Das Standard-Passwort muss vor dem produktiven Einsatz geändert werden! + +```bash +# Neuen Passwort-Hash generieren +php -r "echo password_hash('DeinSicheresPasswort', PASSWORD_DEFAULT);" +``` + +Den generierten Hash in `config.php` eintragen: + +```php +define('ADMIN_PASSWORD_HASH', '$2y$10$DEIN_GENERIERTER_HASH_HIER'); +``` + +### 3. Apache Virtual Host (optional) + +```apache + + ServerName familyalbums.example.com + DocumentRoot /var/www/familyalbums + + + AllowOverride All + Require all granted + + +``` + +## Verwendung + +### Öffentliche Galerie + +- URL: `https://deine-domain.ch/` +- Filter nach Jahr und Monat +- Stichwortsuche +- Kommentare zu Alben hinterlassen + +### Admin-Bereich + +- URL: `https://deine-domain.ch/admin.php` +- Login mit dem konfigurierten Passwort +- Alben hinzufügen, bearbeiten, löschen +- Optional: Vorschaubilder hochladen +- Kommentare moderieren + +## Datenstruktur + +### albums.json + +```json +{ + "albums": [ + { + "id": "uuid", + "title": "Albumtitel", + "url": "https://nextcloud.../apps/photos/public/...", + "date": "2024-12-25", + "tags": ["tag1", "tag2"], + "description": "Beschreibung", + "thumbnail": "thumbnails/bild.jpg", + "created_at": "2024-12-26T10:00:00+01:00" + } + ] +} +``` + +### comments.json + +```json +{ + "comments": [ + { + "id": "uuid", + "album_id": "album-uuid", + "author": "Name", + "text": "Kommentar", + "created_at": "2024-12-27T14:30:00+01:00" + } + ] +} +``` + +## Sicherheit + +- Admin-Passwort mit bcrypt gehasht +- CSRF-Token für alle Admin-Aktionen +- XSS-Schutz durch `htmlspecialchars()` +- Rate-Limiting für Kommentare (5/Minute pro IP) +- Honeypot-Feld gegen Spam-Bots +- `.htaccess` schützt config.php und data/ + +## Anforderungen + +- PHP 8.0+ +- Apache mit mod_rewrite (optional) +- Schreibrechte für data/ und thumbnails/ + +## Lizenz + +Privates Projekt für Familien-Nutzung. diff --git a/familyalbums/admin.php b/familyalbums/admin.php new file mode 100644 index 0000000..c901590 --- /dev/null +++ b/familyalbums/admin.php @@ -0,0 +1,658 @@ + + + + + + + <?= e($pageTitle) ?> + + + + + + + + + + + + + + + + + diff --git a/familyalbums/api.php b/familyalbums/api.php new file mode 100644 index 0000000..9620d60 --- /dev/null +++ b/familyalbums/api.php @@ -0,0 +1,350 @@ + 'Nicht autorisiert'], 401); + } +} + +// === ALBEN === + +if ($action === 'albums' && $method === 'GET') { + // Alle Alben abrufen (öffentlich) + $data = read_json(ALBUMS_FILE); + $albums = $data['albums'] ?? []; + + // Filter: Jahr + if (!empty($_GET['year'])) { + $year = $_GET['year']; + $albums = array_filter($albums, fn($a) => substr($a['date'], 0, 4) === $year); + } + + // Filter: Monat + if (!empty($_GET['month'])) { + $month = $_GET['month']; + $albums = array_filter($albums, fn($a) => substr($a['date'], 5, 2) === $month); + } + + // Filter: Suche + if (!empty($_GET['search'])) { + $search = mb_strtolower($_GET['search']); + $albums = array_filter($albums, function($a) use ($search) { + $haystack = mb_strtolower($a['title'] . ' ' . $a['description'] . ' ' . implode(' ', $a['tags'])); + return str_contains($haystack, $search); + }); + } + + // Sortierung + $sort = $_GET['sort'] ?? 'newest'; + usort($albums, function($a, $b) use ($sort) { + if ($sort === 'oldest') { + return strcmp($a['date'], $b['date']); + } + return strcmp($b['date'], $a['date']); // newest first + }); + + json_response(['albums' => array_values($albums)]); +} + +if ($action === 'album' && $method === 'POST') { + // Album erstellen (Admin) + require_admin(); + + $input = json_decode(file_get_contents('php://input'), true); + + if (!csrf_validate($input['csrf'] ?? '')) { + json_response(['error' => 'Ungültiges CSRF-Token'], 403); + } + + if (empty($input['title']) || empty($input['url']) || empty($input['date'])) { + json_response(['error' => 'Titel, URL und Datum sind Pflichtfelder'], 400); + } + + $album = [ + 'id' => generate_uuid(), + 'title' => trim($input['title']), + 'url' => trim($input['url']), + 'date' => $input['date'], + 'tags' => array_map('trim', $input['tags'] ?? []), + 'description' => trim($input['description'] ?? ''), + 'thumbnail' => $input['thumbnail'] ?? '', + 'created_at' => date('c') + ]; + + $data = read_json(ALBUMS_FILE); + $data['albums'][] = $album; + write_json(ALBUMS_FILE, $data); + + json_response(['success' => true, 'album' => $album]); +} + +if ($action === 'album' && $method === 'PUT') { + // Album bearbeiten (Admin) + require_admin(); + + $input = json_decode(file_get_contents('php://input'), true); + + if (!csrf_validate($input['csrf'] ?? '')) { + json_response(['error' => 'Ungültiges CSRF-Token'], 403); + } + + $id = $input['id'] ?? ''; + + $data = read_json(ALBUMS_FILE); + $found = false; + + foreach ($data['albums'] as &$album) { + if ($album['id'] === $id) { + $album['title'] = trim($input['title'] ?? $album['title']); + $album['url'] = trim($input['url'] ?? $album['url']); + $album['date'] = $input['date'] ?? $album['date']; + $album['tags'] = array_map('trim', $input['tags'] ?? $album['tags']); + $album['description'] = trim($input['description'] ?? $album['description']); + $album['thumbnail'] = $input['thumbnail'] ?? $album['thumbnail']; + $found = true; + break; + } + } + + if (!$found) { + json_response(['error' => 'Album nicht gefunden'], 404); + } + + write_json(ALBUMS_FILE, $data); + json_response(['success' => true]); +} + +if ($action === 'album' && $method === 'DELETE') { + // Album löschen (Admin) + require_admin(); + + $input = json_decode(file_get_contents('php://input'), true); + + if (!csrf_validate($input['csrf'] ?? '')) { + json_response(['error' => 'Ungültiges CSRF-Token'], 403); + } + + $id = $input['id'] ?? ''; + + $data = read_json(ALBUMS_FILE); + $data['albums'] = array_filter($data['albums'], fn($a) => $a['id'] !== $id); + $data['albums'] = array_values($data['albums']); + write_json(ALBUMS_FILE, $data); + + // Zugehörige Kommentare löschen + $comments = read_json(COMMENTS_FILE); + $comments['comments'] = array_filter($comments['comments'], fn($c) => $c['album_id'] !== $id); + $comments['comments'] = array_values($comments['comments']); + write_json(COMMENTS_FILE, $comments); + + json_response(['success' => true]); +} + +// === KOMMENTARE === + +if ($action === 'comments' && $method === 'GET') { + // Kommentare für Album abrufen (öffentlich) + $album_id = $_GET['album_id'] ?? ''; + + $data = read_json(COMMENTS_FILE); + $comments = array_filter($data['comments'] ?? [], fn($c) => $c['album_id'] === $album_id); + + // Nach Datum sortieren (neueste zuerst) + usort($comments, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); + + json_response(['comments' => array_values($comments)]); +} + +if ($action === 'comment' && $method === 'POST') { + // Kommentar erstellen (öffentlich) + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['album_id']) || empty($input['author']) || empty($input['text'])) { + json_response(['error' => 'Album-ID, Name und Text sind Pflichtfelder'], 400); + } + + // Honeypot-Check (Spam-Schutz) + if (!empty($input['website'])) { + json_response(['success' => true]); // Fake-Erfolg für Bots + } + + // Rate-Limiting: Max 5 Kommentare pro Minute pro IP + $ip = $_SERVER['REMOTE_ADDR']; + $rate_file = DATA_PATH . 'rate_' . md5($ip) . '.json'; + $rate_data = read_json($rate_file); + $now = time(); + $rate_data['times'] = array_filter($rate_data['times'] ?? [], fn($t) => $t > $now - 60); + + if (count($rate_data['times']) >= 5) { + json_response(['error' => 'Zu viele Kommentare. Bitte warte eine Minute.'], 429); + } + + $rate_data['times'][] = $now; + write_json($rate_file, $rate_data); + + $comment = [ + 'id' => generate_uuid(), + 'album_id' => $input['album_id'], + 'author' => trim($input['author']), + 'text' => trim($input['text']), + 'created_at' => date('c') + ]; + + $data = read_json(COMMENTS_FILE); + $data['comments'][] = $comment; + write_json(COMMENTS_FILE, $data); + + json_response(['success' => true, 'comment' => $comment]); +} + +if ($action === 'comment' && $method === 'DELETE') { + // Kommentar löschen (Admin) + require_admin(); + + $input = json_decode(file_get_contents('php://input'), true); + + if (!csrf_validate($input['csrf'] ?? '')) { + json_response(['error' => 'Ungültiges CSRF-Token'], 403); + } + + $id = $input['id'] ?? ''; + + $data = read_json(COMMENTS_FILE); + $data['comments'] = array_filter($data['comments'], fn($c) => $c['id'] !== $id); + $data['comments'] = array_values($data['comments']); + write_json(COMMENTS_FILE, $data); + + json_response(['success' => true]); +} + +// === TAGS === + +if ($action === 'tags' && $method === 'GET') { + // Alle verwendeten Tags abrufen (für Vorschläge) + $data = read_json(ALBUMS_FILE); + $tags = []; + + foreach ($data['albums'] ?? [] as $album) { + foreach ($album['tags'] ?? [] as $tag) { + $tags[$tag] = ($tags[$tag] ?? 0) + 1; + } + } + + arsort($tags); + json_response(['tags' => array_keys($tags)]); +} + +// === JAHRE/MONATE === + +if ($action === 'dates' && $method === 'GET') { + // Verfügbare Jahre und Monate + $data = read_json(ALBUMS_FILE); + $years = []; + + foreach ($data['albums'] ?? [] as $album) { + $year = substr($album['date'], 0, 4); + $month = substr($album['date'], 5, 2); + + if (!isset($years[$year])) { + $years[$year] = []; + } + if (!in_array($month, $years[$year])) { + $years[$year][] = $month; + } + } + + // Sortieren + krsort($years); + foreach ($years as &$months) { + sort($months); + } + + json_response(['dates' => $years]); +} + +// === AUTH === + +if ($action === 'login' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + $password = $input['password'] ?? ''; + + if (password_verify($password, ADMIN_PASSWORD_HASH)) { + $_SESSION['admin_logged_in'] = true; + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + json_response(['success' => true, 'csrf' => $_SESSION['csrf_token']]); + } + + // Verzögerung gegen Brute-Force + sleep(1); + json_response(['error' => 'Falsches Passwort'], 401); +} + +if ($action === 'logout' && $method === 'POST') { + session_destroy(); + json_response(['success' => true]); +} + +if ($action === 'check_auth' && $method === 'GET') { + json_response([ + 'authenticated' => !empty($_SESSION['admin_logged_in']), + 'csrf' => $_SESSION['csrf_token'] ?? '' + ]); +} + +// === THUMBNAIL UPLOAD === + +if ($action === 'upload_thumbnail' && $method === 'POST') { + require_admin(); + + if (empty($_POST['csrf']) || !csrf_validate($_POST['csrf'])) { + json_response(['error' => 'Ungültiges CSRF-Token'], 403); + } + + if (empty($_FILES['thumbnail']) || $_FILES['thumbnail']['error'] !== UPLOAD_ERR_OK) { + json_response(['error' => 'Kein Bild hochgeladen'], 400); + } + + $file = $_FILES['thumbnail']; + $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + + if (!in_array($file['type'], $allowed)) { + json_response(['error' => 'Nur JPG, PNG, GIF und WebP erlaubt'], 400); + } + + if ($file['size'] > 5 * 1024 * 1024) { + json_response(['error' => 'Maximale Dateigrösse: 5MB'], 400); + } + + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $filename = generate_uuid() . '.' . $ext; + $path = THUMBNAIL_PATH . $filename; + + if (!move_uploaded_file($file['tmp_name'], $path)) { + json_response(['error' => 'Upload fehlgeschlagen'], 500); + } + + json_response(['success' => true, 'path' => THUMBNAIL_URL . $filename]); +} + +// Unbekannte Aktion +json_response(['error' => 'Unbekannte Aktion'], 404); diff --git a/familyalbums/config.php b/familyalbums/config.php new file mode 100644 index 0000000..6f40191 --- /dev/null +++ b/familyalbums/config.php @@ -0,0 +1,91 @@ + []]); +} +if (!file_exists(COMMENTS_FILE)) { + write_json(COMMENTS_FILE, ['comments' => []]); +} diff --git a/familyalbums/data/.htaccess b/familyalbums/data/.htaccess new file mode 100644 index 0000000..e346128 --- /dev/null +++ b/familyalbums/data/.htaccess @@ -0,0 +1,8 @@ +# Deny access to all files in this directory + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/familyalbums/data/albums.json b/familyalbums/data/albums.json new file mode 100644 index 0000000..8a37a99 --- /dev/null +++ b/familyalbums/data/albums.json @@ -0,0 +1,14 @@ +{ + "albums": [ + { + "id": "demo-001", + "title": "Weihnachten 2024", + "url": "https://nextcloud.example.com/apps/photos/public/demo", + "date": "2024-12-25", + "tags": ["weihnachten", "familie", "2024"], + "description": "Bescherung und Festessen bei der Familie", + "thumbnail": "", + "created_at": "2024-12-26T10:00:00+01:00" + } + ] +} diff --git a/familyalbums/data/comments.json b/familyalbums/data/comments.json new file mode 100644 index 0000000..d484239 --- /dev/null +++ b/familyalbums/data/comments.json @@ -0,0 +1,3 @@ +{ + "comments": [] +} diff --git a/familyalbums/index.php b/familyalbums/index.php new file mode 100644 index 0000000..4ead178 --- /dev/null +++ b/familyalbums/index.php @@ -0,0 +1,449 @@ + + + + + + + <?= e($pageTitle) ?> + + + + + + +
+
+
+

+ +

+ + Admin + +
+
+
+ + +
+
+
+ +
+
+ + +
+
+ + + + + + + + + +
+
+
+ + +
+
+ +
+ + + +
+ +
+
+ + + + + + + + + +