Add encrypted day storage and personal backups

This commit is contained in:
2026-04-13 12:04:17 +02:00
parent 4a884dd166
commit 0a8ccef5a7
7 changed files with 478 additions and 7 deletions
+242 -3
View File
@@ -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);