Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8ccef5a7 |
+242
-3
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class EntryCrypto
|
||||
{
|
||||
private const HEADER = "MOODENC1\n";
|
||||
|
||||
private string $fallbackKeyPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -114,7 +114,7 @@ $brandSubtitle = match ($page) {
|
||||
<?= $content ?>
|
||||
|
||||
<footer class="site-footer glass-panel">
|
||||
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.0</a>
|
||||
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
|
||||
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -350,6 +350,32 @@
|
||||
</article>
|
||||
|
||||
<aside class="stack-column">
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Backup</p>
|
||||
<h3>Eigene Einträge sichern</h3>
|
||||
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
|
||||
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="export_backup">
|
||||
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
|
||||
<?php if (empty($backupAvailable)): ?>
|
||||
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="import_backup">
|
||||
<label>
|
||||
<span>Backup importieren</span>
|
||||
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
|
||||
</label>
|
||||
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
|
||||
<button class="ghost-button" type="submit">Backup importieren</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Sicherheit</p>
|
||||
<h3>Passwort ändern</h3>
|
||||
|
||||
Reference in New Issue
Block a user