diff --git a/src/App.php b/src/App.php index e8bfdbb..4bd5ce4 100644 --- a/src/App.php +++ b/src/App.php @@ -321,9 +321,15 @@ final class App redirect('/track'); } - $previousEntry = $this->entries->find($user['username'], shift_date($entry['date'], -1)); - $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); - $this->entries->save($user['username'], $entry['date'], $entry, $evaluation); + $entries = $this->entries->all($user['username']); + $entryMap = []; + + foreach ($entries as $existingEntry) { + $entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry; + } + + $entryMap[$entry['date']] = $entry; + $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); flash('success', 'Der Tag wurde gespeichert.'); redirect('/track?date=' . rawurlencode($entry['date'])); @@ -395,6 +401,7 @@ final class App 'pushAvailable' => $pushAvailable, 'pushPublicKey' => $pushPublicKey, 'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']), + 'backupAvailable' => class_exists('ZipArchive'), 'users' => $user['is_admin'] ? $this->users->all() : [], 'maxScore' => $this->scoring->evaluate([ 'mood' => 10, @@ -433,6 +440,24 @@ final class App redirect('/options'); } + if ($form === 'export_backup') { + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); + $this->downloadUserBackup($user, $settings); + } + + if ($form === 'import_backup') { + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); + + try { + $imported = $this->importUserBackup($user, $settings); + flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.'); + } catch (RuntimeException $exception) { + flash('error', $exception->getMessage()); + } + + redirect('/options'); + } + if ($form === 'password') { $current = (string) ($_POST['current_password'] ?? ''); $new = (string) ($_POST['new_password'] ?? ''); @@ -519,6 +544,220 @@ final class App ]; } + private function persistUserEntries(string $username, array $settings, array $entries): void + { + $normalized = []; + + foreach ($entries as $entry) { + if (!is_array($entry)) { + continue; + } + + $normalizedEntry = $this->scoring->normalize($entry); + if (!$this->isValidDate((string) ($normalizedEntry['date'] ?? ''))) { + continue; + } + + $normalized[$normalizedEntry['date']] = $normalizedEntry; + } + + ksort($normalized, SORT_STRING); + + $previousEntry = null; + foreach ($normalized as $date => $entry) { + $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); + $this->entries->save($username, $date, $entry, $evaluation); + $previousEntry = $entry; + } + } + + private function downloadUserBackup(array $user, array $settings): never + { + if (!class_exists('ZipArchive')) { + flash('error', 'Für den Backup-Download fehlt auf diesem Server die ZIP-Erweiterung.'); + redirect('/options'); + } + + $entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings); + $tempPath = tempnam(sys_get_temp_dir(), 'mood-backup-'); + + if ($tempPath === false) { + throw new RuntimeException('Das Backup konnte gerade nicht vorbereitet werden.'); + } + + $zip = new ZipArchive(); + $opened = $zip->open($tempPath, ZipArchive::OVERWRITE); + + if ($opened !== true) { + @unlink($tempPath); + throw new RuntimeException('Das Backup konnte nicht als ZIP erstellt werden.'); + } + + foreach ($entries as $entry) { + $date = (string) ($entry['date'] ?? ''); + if (!$this->isValidDate($date)) { + continue; + } + + $markdown = $this->entries->exportMarkdown( + (string) ($user['username'] ?? ''), + $date, + $entry, + $entry['evaluation'] ?? $this->scoring->evaluate($entry, $settings) + ); + + $zip->addFromString($date . '.txt', $markdown); + } + + $zip->close(); + + $fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip'; + + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $fileName . '"'); + header('Content-Length: ' . (string) filesize($tempPath)); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + + readfile($tempPath); + @unlink($tempPath); + exit; + } + + private function importUserBackup(array $user, array $settings): int + { + $files = uploaded_files('backup_files'); + if ($files === []) { + throw new RuntimeException('Bitte wähle mindestens eine Backup-Datei aus.'); + } + + $importedEntries = []; + + foreach ($files as $file) { + $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($error === UPLOAD_ERR_NO_FILE) { + continue; + } + + if ($error !== UPLOAD_ERR_OK) { + throw new RuntimeException('Eine Backup-Datei konnte nicht hochgeladen werden.'); + } + + $tmpName = (string) ($file['tmp_name'] ?? ''); + $name = trim((string) ($file['name'] ?? '')); + + if ($tmpName === '' || !is_uploaded_file($tmpName)) { + throw new RuntimeException('Eine Backup-Datei ist ungültig.'); + } + + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + + if ($extension === 'zip') { + foreach ($this->entriesFromZip($tmpName) as $date => $entry) { + $importedEntries[$date] = $entry; + } + continue; + } + + if ($extension !== 'txt') { + throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.'); + } + + $date = $this->dateFromBackupFileName($name); + $content = (string) file_get_contents($tmpName); + $entry = $this->entries->parseMarkdown($content, $date); + + if ($entry === null) { + throw new RuntimeException('Mindestens eine Tagesdatei konnte nicht gelesen werden.'); + } + + $importedEntries[$date] = $entry; + } + + if ($importedEntries === []) { + throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.'); + } + + $existingEntries = $this->entries->all((string) ($user['username'] ?? '')); + $entryMap = []; + + foreach ($existingEntries as $entry) { + if (!is_array($entry) || !$this->isValidDate((string) ($entry['date'] ?? ''))) { + continue; + } + + $entryMap[$entry['date']] = $entry; + } + + foreach ($importedEntries as $date => $entry) { + $entryMap[$date] = $entry; + } + + $this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap)); + + return count($importedEntries); + } + + private function entriesFromZip(string $path): array + { + if (!class_exists('ZipArchive')) { + throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.'); + } + + $zip = new ZipArchive(); + $opened = $zip->open($path); + + if ($opened !== true) { + throw new RuntimeException('Das ZIP-Backup konnte nicht geöffnet werden.'); + } + + $entries = []; + + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = (string) $zip->getNameIndex($index); + if ($name === '' || str_ends_with($name, '/')) { + continue; + } + + $baseName = basename($name); + if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) { + continue; + } + + $date = $this->dateFromBackupFileName($baseName); + $content = $zip->getFromIndex($index); + + if (!is_string($content)) { + continue; + } + + $entry = $this->entries->parseMarkdown($content, $date); + if ($entry !== null) { + $entries[$date] = $entry; + } + } + + $zip->close(); + + return $entries; + } + + private function dateFromBackupFileName(string $fileName): string + { + $baseName = basename($fileName); + + if (!preg_match('/^(\d{4}-\d{2}-\d{2})\.txt$/', $baseName, $matches)) { + throw new RuntimeException('Backup-Dateien müssen als YYYY-MM-DD.txt benannt sein.'); + } + + $date = (string) ($matches[1] ?? ''); + if (!$this->isValidDate($date)) { + throw new RuntimeException('Eine Backup-Datei enthält ein ungültiges Datum.'); + } + + return $date; + } + private function buildDashboardCharts(array $entries): array { $recent = array_slice($entries, -30); diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index ee854ba..77ec0a6 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -4,6 +4,13 @@ declare(strict_types=1); final class EntryRepository { + private EntryCrypto $crypto; + + public function __construct() + { + $this->crypto = new EntryCrypto(); + } + public function save(string $username, string $date, array $entry, array $evaluation): void { $path = $this->pathFor($username, $date); @@ -13,7 +20,8 @@ final class EntryRepository mkdir($directory, 0775, true); } - file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation)); + $markdown = $this->toMarkdown($username, $date, $entry, $evaluation); + file_put_contents($path, $this->crypto->encrypt($markdown), LOCK_EX); } public function find(string $username, string $date): ?array @@ -24,7 +32,14 @@ final class EntryRepository return null; } - return $this->parse((string) file_get_contents($path), $date); + $content = (string) file_get_contents($path); + $plaintext = $this->crypto->decrypt($content); + + if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) { + file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX); + } + + return $this->parse($plaintext, $date); } public function all(string $username): array @@ -41,7 +56,14 @@ final class EntryRepository $entries = []; foreach ($files as $file) { $date = basename($file, '.txt'); - $parsed = $this->parse((string) file_get_contents($file), $date); + $content = (string) file_get_contents($file); + $plaintext = $this->crypto->decrypt($content); + + if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) { + file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX); + } + + $parsed = $this->parse($plaintext, $date); if ($parsed !== null) { $entries[] = $parsed; } @@ -50,6 +72,18 @@ final class EntryRepository return $entries; } + public function parseMarkdown(string $content, string $fallbackDate): ?array + { + $plaintext = $this->crypto->decrypt($content); + + return $this->parse($plaintext, $fallbackDate); + } + + public function exportMarkdown(string $username, string $date, array $entry, array $evaluation): string + { + return $this->toMarkdown($username, $date, $entry, $evaluation); + } + private function directoryFor(string $username): string { return storage_path('users/' . normalize_username($username) . '/days'); diff --git a/src/Support/EntryCrypto.php b/src/Support/EntryCrypto.php new file mode 100644 index 0000000..891a9a7 --- /dev/null +++ b/src/Support/EntryCrypto.php @@ -0,0 +1,140 @@ +fallbackKeyPath = storage_path('system/entry-encryption.key'); + } + + public function isAvailable(): bool + { + return function_exists('openssl_encrypt') + && function_exists('openssl_decrypt') + && function_exists('random_bytes'); + } + + public function shouldEncrypt(): bool + { + return $this->isAvailable(); + } + + public function isEncrypted(string $content): bool + { + return str_starts_with($content, self::HEADER); + } + + public function encrypt(string $plaintext): string + { + if (!$this->shouldEncrypt()) { + return $plaintext; + } + + $iv = random_bytes(12); + $tag = ''; + $ciphertext = openssl_encrypt( + $plaintext, + 'aes-256-gcm', + $this->key(), + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if (!is_string($ciphertext) || $tag === '') { + throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.'); + } + + $payload = json_encode([ + 'iv' => base64_encode($iv), + 'tag' => base64_encode($tag), + 'data' => base64_encode($ciphertext), + ], JSON_UNESCAPED_SLASHES); + + if (!is_string($payload)) { + throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.'); + } + + return self::HEADER . $payload; + } + + public function decrypt(string $content): string + { + if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) { + return $content; + } + + $payload = substr($content, strlen(self::HEADER)); + $decoded = json_decode($payload, true); + + if ( + !is_array($decoded) + || !is_string($decoded['iv'] ?? null) + || !is_string($decoded['tag'] ?? null) + || !is_string($decoded['data'] ?? null) + ) { + throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.'); + } + + $plaintext = openssl_decrypt( + (string) base64_decode($decoded['data'], true), + 'aes-256-gcm', + $this->key(), + OPENSSL_RAW_DATA, + (string) base64_decode($decoded['iv'], true), + (string) base64_decode($decoded['tag'], true) + ); + + if (!is_string($plaintext)) { + throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.'); + } + + return $plaintext; + } + + private function key(): string + { + $configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: '')); + if ($configured !== '') { + return hash('sha256', $configured, true); + } + + $stored = $this->readFallbackKey(); + if ($stored !== null) { + return $stored; + } + + $raw = random_bytes(32); + $directory = dirname($this->fallbackKeyPath); + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX); + @chmod($this->fallbackKeyPath, 0600); + + return $raw; + } + + private function readFallbackKey(): ?string + { + if (!is_file($this->fallbackKeyPath)) { + return null; + } + + $raw = trim((string) file_get_contents($this->fallbackKeyPath)); + if ($raw === '') { + return null; + } + + $decoded = base64_decode($raw, true); + + return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null; + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index 7b38e2e..07756b2 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -5,6 +5,7 @@ declare(strict_types=1); require __DIR__ . '/helpers.php'; require __DIR__ . '/Support/Defaults.php'; require __DIR__ . '/Support/Auth.php'; +require __DIR__ . '/Support/EntryCrypto.php'; require __DIR__ . '/Support/View.php'; require __DIR__ . '/Support/WebPushService.php'; require __DIR__ . '/Domain/UserRepository.php'; diff --git a/src/helpers.php b/src/helpers.php index 43206db..82775f3 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -352,6 +352,37 @@ function base64url_decode(string $data): string return $decoded; } +function uploaded_files(string $field): array +{ + $raw = $_FILES[$field] ?? null; + if (!is_array($raw) || !isset($raw['name'])) { + return []; + } + + if (!is_array($raw['name'])) { + return [[ + 'name' => (string) ($raw['name'] ?? ''), + 'type' => (string) ($raw['type'] ?? ''), + 'tmp_name' => (string) ($raw['tmp_name'] ?? ''), + 'error' => (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE), + 'size' => (int) ($raw['size'] ?? 0), + ]]; + } + + $files = []; + foreach ($raw['name'] as $index => $name) { + $files[] = [ + 'name' => (string) ($name ?? ''), + 'type' => (string) ($raw['type'][$index] ?? ''), + 'tmp_name' => (string) ($raw['tmp_name'][$index] ?? ''), + 'error' => (int) ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE), + 'size' => (int) ($raw['size'][$index] ?? 0), + ]; + } + + return $files; +} + function normalize_sport_type_id(string $value): string { $value = trim(strtr($value, [ diff --git a/templates/layout.php b/templates/layout.php index 8c8de87..baf1ae4 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -114,7 +114,7 @@ $brandSubtitle = match ($page) { diff --git a/templates/pages/options.php b/templates/pages/options.php index 69799e3..ff37746 100644 --- a/templates/pages/options.php +++ b/templates/pages/options.php @@ -350,6 +350,32 @@