diff --git a/Changes.md b/Changes.md new file mode 100644 index 0000000..b44f863 --- /dev/null +++ b/Changes.md @@ -0,0 +1,28 @@ +# Changelog + +--- + +## 2026-03-25 – Sicherheit, UX-Verbesserungen & Bugfixes + +### Sicherheit +- **Timing-sicherer Login**: Passwort- und Benutzernamevergleich auf `hash_equals()` umgestellt (verhindert Timing-Angriffe) +- **SVG-Upload gesperrt**: Logo-Upload akzeptiert nur noch PNG und JPEG – SVG wurde entfernt (XSS-Risiko) +- **CSV-Injection-Schutz**: Felder im Buchungsjournal-CSV-Export werden gegen Formula-Injection (`=`, `+`, `-`, `@`) abgesichert + +### Features +- **Inline-Editing**: Belegdatum und Rechnungsnr. können direkt in der Beleg-Detailansicht per Klick bearbeitet werden (kein Seitenreload) +- **Belegart-Filter**: Neue Filteroption in der Belegliste zum Filtern nach Belegart (Eingangsrechnung, Ausgangsrechnung, Quittung etc.) +- **Favicon & App-Icon**: Favicon für Browser-Tabs, Apple Touch Icon und Web-App-Manifest eingebunden + +### Robustheit +- **Datenbankfehler-Handling**: PDO-Exceptions in Invoice-, Booking- und BelegController werden separat abgefangen und geloggt statt als generische Fehler zu erscheinen + +--- + +## 2026-03-24 – Initiales Release + +### Features +- **Basis-System**: MVC-Framework, Routing, Authentifizierung, Datenbank-Migrations-System +- **Ausgangsrechnungen**: Erstellen, Verwalten und PDF-Export von Ausgangsrechnungen +- **EÜR-Vorbereitung**: Einnahmen-Überschuss-Rechnung als PDF-Export +- **UStVA-Vorbereitung**: Umsatzsteuer-Voranmeldung inkl. XML-Export für elster.de diff --git a/migrations/004_rechnungs_nr_from_filename.sql b/migrations/004_rechnungs_nr_from_filename.sql new file mode 100644 index 0000000..236f11a --- /dev/null +++ b/migrations/004_rechnungs_nr_from_filename.sql @@ -0,0 +1,8 @@ +-- Migration 004: Rechnungsnummer aus Dateinamen ableiten (bestehende Belege) +-- Setzt rechnungs_nr auf den Dateinamen ohne Extension, wo rechnungs_nr leer/NULL ist +-- und storage_path auf eine echte Datei zeigt (nicht automatisch erstellte Ausgangsrechnungen). +UPDATE belege +SET rechnungs_nr = REGEXP_REPLACE(filename, '\\.[^.]+$', ''), + updated_at = NOW() +WHERE (rechnungs_nr IS NULL OR rechnungs_nr = '') + AND storage_path != ''; diff --git a/public/static/favicon/apple-touch-icon.png b/public/static/favicon/apple-touch-icon.png new file mode 100644 index 0000000..bbf3c34 Binary files /dev/null and b/public/static/favicon/apple-touch-icon.png differ diff --git a/public/static/favicon/favicon-96x96.png b/public/static/favicon/favicon-96x96.png new file mode 100644 index 0000000..d94d740 Binary files /dev/null and b/public/static/favicon/favicon-96x96.png differ diff --git a/public/static/favicon/favicon.ico b/public/static/favicon/favicon.ico new file mode 100644 index 0000000..a9f62a1 Binary files /dev/null and b/public/static/favicon/favicon.ico differ diff --git a/public/static/favicon/favicon.svg b/public/static/favicon/favicon.svg new file mode 100644 index 0000000..9744551 --- /dev/null +++ b/public/static/favicon/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/static/favicon/site.webmanifest b/public/static/favicon/site.webmanifest new file mode 100644 index 0000000..6fbcf20 --- /dev/null +++ b/public/static/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "MyWebSite", + "short_name": "MySite", + "icons": [ + { + "src": "/favicon/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/favicon/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/public/static/favicon/web-app-manifest-192x192.png b/public/static/favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000..f56c046 Binary files /dev/null and b/public/static/favicon/web-app-manifest-192x192.png differ diff --git a/public/static/favicon/web-app-manifest-512x512.png b/public/static/favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000..ced8f28 Binary files /dev/null and b/public/static/favicon/web-app-manifest-512x512.png differ diff --git a/public/static/js/app.js b/public/static/js/app.js index 5882073..85ed5ee 100644 --- a/public/static/js/app.js +++ b/public/static/js/app.js @@ -255,6 +255,101 @@ function setupAccountSearch(inputId, hiddenId) { input.addEventListener('blur', () => setTimeout(closeDropdown, 150)); } +// ── Inline-Edit (Belegdatum / Rechnungsnr. in der Info-Bar) ────── +document.addEventListener('click', (e) => { + const trigger = e.target.closest('.inline-edit-trigger'); + if (!trigger || trigger.querySelector('input')) return; + + const bar = trigger.closest('[data-beleg-id]'); + if (!bar) return; + + const field = trigger.dataset.field; + const type = trigger.dataset.type || 'text'; + const rawValue = trigger.dataset.raw || ''; + + const input = document.createElement('input'); + input.type = type; + input.value = rawValue; + input.style.cssText = 'font-size:inherit;padding:2px 5px;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;min-width:' + (type === 'date' ? '140px' : '180px'); + + const originalHTML = trigger.innerHTML; + trigger.innerHTML = ''; + trigger.appendChild(input); + input.focus(); + if (type === 'text') input.select(); + + let cancelled = false; + + async function save() { + if (cancelled) return; + const newValue = input.value.trim(); + + const body = new URLSearchParams({ + rechnungs_nr: field === 'rechnungs_nr' ? newValue : bar.dataset.rechnungsNr || '', + belegtyp: bar.dataset.belegtyp || '', + beleg_datum: field === 'beleg_datum' ? newValue : bar.dataset.belegDatum || '', + betrag_brutto: bar.dataset.betragBrutto || '', + }); + + try { + const res = await fetch(`/belege/${bar.dataset.belegId}/meta`, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + if (!res.ok) throw new Error(); + + // Update stored data-attribute + if (field === 'beleg_datum') { + bar.dataset.belegDatum = newValue; + trigger.dataset.raw = newValue; + if (newValue) { + const [y, m, d] = newValue.split('-'); + trigger.textContent = `${d}.${m}.${y}`; + } else { + trigger.innerHTML = '– setzen –'; + } + } else { + bar.dataset.rechnungsNr = newValue; + trigger.dataset.raw = newValue; + trigger.textContent = newValue || ''; + if (!newValue) trigger.innerHTML = '– setzen –'; + } + } catch (_) { + trigger.innerHTML = originalHTML; + } + } + + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { cancelled = true; trigger.innerHTML = originalHTML; } + }); +}); + +// ── Konto-Select: Doppelklick / Enter → Kontonr. ins Filterfeld ── +function transferKontoNr(select) { + const opt = select.options[select.selectedIndex]; + if (!opt || !opt.value) return; + const kontoNr = opt.textContent.split(' – ')[0].trim(); + const filterInput = document.querySelector(`.konto-filter[data-target="${select.id}"]`); + if (filterInput) filterInput.value = kontoNr; +} + +document.addEventListener('dblclick', (e) => { + const select = e.target.closest('select#soll_konto_id, select#haben_konto_id'); + if (select) transferKontoNr(select); +}); + +document.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + const select = e.target.closest('select#soll_konto_id, select#haben_konto_id'); + if (select) { + e.preventDefault(); + transferKontoNr(select); + } +}); + // ── Flash Message Auto-dismiss ──────────────────────────────── document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.flash').forEach(el => { diff --git a/src/Auth.php b/src/Auth.php index e297673..104f321 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -13,8 +13,8 @@ class Auth if ( !isset($_SERVER['PHP_AUTH_USER']) || - $_SERVER['PHP_AUTH_USER'] !== $user || - $_SERVER['PHP_AUTH_PW'] !== $pass + !hash_equals($user, $_SERVER['PHP_AUTH_USER']) || + !hash_equals($pass, $_SERVER['PHP_AUTH_PW']) ) { header('WWW-Authenticate: Basic realm="Steuerkram"'); header('HTTP/1.1 401 Unauthorized'); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 4bfb21f..2c0d58c 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -24,18 +24,22 @@ class BelegController public function index(array $params): void { $filter = $_GET; - $page = max(1, (int)($_GET['page'] ?? 1)); - $result = Beleg::all($filter, $page); + if (!isset($filter['year'])) $filter['year'] = (string)date('Y'); + if (!isset($filter['month'])) $filter['month'] = (string)date('n'); + $page = max(1, (int)($_GET['page'] ?? 1)); + $result = Beleg::all($filter, $page); + $totalAll = Beleg::all([], 1, 1)['total']; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Belege', 'content' => $this->renderView('belege/list', [ - 'rows' => $result['rows'], - 'total' => $result['total'], - 'pages' => $result['pages'], - 'page' => $page, - 'filter' => $filter, + 'rows' => $result['rows'], + 'total' => $result['total'], + 'totalAll' => $totalAll, + 'pages' => $result['pages'], + 'page' => $page, + 'filter' => $filter, ]), 'topbarActions' => '+ Beleg hochladen', ]); @@ -99,6 +103,7 @@ class BelegController if (!is_dir($absDir)) mkdir($absDir, 0755, true); $filename = pathinfo($file['name'], PATHINFO_BASENAME); + $filenameWithoutExt = pathinfo($file['name'], PATHINFO_FILENAME); $storagePath = "{$relDir}/original.{$ext}"; $absPath = dirname(__DIR__, 2) . "/public/{$storagePath}"; @@ -112,8 +117,8 @@ class BelegController 'file_hash' => $hash, 'beleg_datum' => $_POST['beleg_datum'] ?? null, 'eingangs_datum'=> $_POST['eingangs_datum'] ?? date('Y-m-d'), - 'rechnungs_nr' => $_POST['rechnungs_nr'] ?? null, - 'betrag_brutto' => $_POST['betrag_brutto'] ?? null, + 'rechnungs_nr' => $filenameWithoutExt, + 'betrag_brutto' => $_POST['betrag_brutto'] !== '' ? $_POST['betrag_brutto'] : null, 'belegtyp' => $_POST['belegtyp'] ?? 'Eingangsrechnung', 'notes' => $_POST['notes'] ?? null, ]); @@ -164,10 +169,16 @@ class BelegController public function updateMeta(array $params): void { - $id = (int)$params['id']; - $beleg = Beleg::find($id); - if (!$beleg) { http_response_code(404); return; } + $isAjax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'; + $id = (int)$params['id']; + $beleg = Beleg::find($id); + + if (!$beleg) { + if ($isAjax) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'Nicht gefunden.']); return; } + http_response_code(404); return; + } if ($beleg['locked']) { + if ($isAjax) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']); return; } View::redirect("/belege/{$id}", 'GoBD: Gesperrter Beleg kann nicht geändert werden.', 'error'); return; } @@ -176,9 +187,15 @@ class BelegController 'rechnungs_nr' => trim($_POST['rechnungs_nr'] ?? ''), 'belegtyp' => $_POST['belegtyp'] ?? $beleg['belegtyp'], 'beleg_datum' => $_POST['beleg_datum'] ?: null, - 'betrag_brutto' => $_POST['betrag_brutto'] !== '' ? $_POST['betrag_brutto'] : null, + 'betrag_brutto' => ($_POST['betrag_brutto'] ?? '') !== '' ? $_POST['betrag_brutto'] : null, ]); + if ($isAjax) { + header('Content-Type: application/json'); + echo json_encode(['ok' => true]); + return; + } + View::redirect("/belege/{$id}", 'Metadaten gespeichert.'); } diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index a49c7ec..eb0d6a3 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -15,10 +15,13 @@ class BookingController { public function journal(array $params): void { - $filter = $_GET; - $page = max(1, (int)($_GET['page'] ?? 1)); - $result = Booking::journal($filter, $page); - $years = FiscalYear::availableYears(); + $filter = $_GET; + if (!isset($filter['year'])) $filter['year'] = (string)date('Y'); + if (!isset($filter['month'])) $filter['month'] = (string)date('n'); + $page = max(1, (int)($_GET['page'] ?? 1)); + $result = Booking::journal($filter, $page); + $totalAll = Booking::journal([], 1, 1)['total']; + $years = FiscalYear::availableYears(); $accounts = Account::all(['aktiv' => 1]); $view = new View(); @@ -27,6 +30,7 @@ class BookingController 'content' => $this->renderView('bookings/journal', [ 'rows' => $result['rows'], 'total' => $result['total'], + 'totalAll' => $totalAll, 'pages' => $result['pages'], 'page' => $page, 'filter' => $filter, @@ -58,18 +62,18 @@ class BookingController foreach ($result['rows'] as $row) { fputcsv($out, [ - $row['buchungs_nr'], + $this->csvSanitize((string)$row['buchungs_nr']), $row['buchungs_datum'], - $row['beleg_nr'], + $this->csvSanitize((string)$row['beleg_nr']), $row['soll_nr'], - $row['soll_name'], + $this->csvSanitize((string)$row['soll_name']), $row['haben_nr'], - $row['haben_name'], + $this->csvSanitize((string)$row['haben_name']), number_format((float)$row['betrag_netto'], 2, ',', ''), number_format((float)$row['ust_satz'], 2, ',', ''), number_format((float)$row['betrag_ust'], 2, ',', ''), number_format((float)$row['betrag_brutto'], 2, ',', ''), - $row['buchungstext'], + $this->csvSanitize((string)$row['buchungstext']), $row['is_reversal'] ? 'Ja' : 'Nein', ], ';'); } @@ -103,6 +107,9 @@ class BookingController $data['beleg_id'] = $belegId; Booking::create($data); View::redirect("/belege/{$belegId}", 'Buchung erfasst.'); + } catch (\PDOException $e) { + error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage()); + $this->renderBelegDetail($belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data); } catch (\Exception $e) { $this->renderBelegDetail($belegId, ['_exception' => $e->getMessage()], $data); } @@ -159,11 +166,22 @@ class BookingController Booking::reverse($id, $reason); $booking = Booking::find($id); View::redirect("/belege/{$booking['beleg_id']}", 'Buchung wurde storniert.'); + } catch (\PDOException $e) { + error_log('[Steuerkram] PDO-Fehler in BookingController::reverse(): ' . $e->getMessage()); + View::redirect('/journal', 'Datenbankfehler beim Stornieren der Buchung.', 'error'); } catch (\Exception $e) { View::redirect('/journal', $e->getMessage(), 'error'); } } + private function csvSanitize(string $value): string + { + if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) { + return "'" . $value; + } + return $value; + } + private function renderView(string $template, array $data): string { extract($data, EXTR_SKIP); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 1db6193..83c6553 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -157,6 +157,9 @@ class InvoiceController try { Invoice::update($id, $data); View::redirect("/invoices/{$id}", 'Rechnung gespeichert.'); + } catch (\PDOException $e) { + error_log('[Steuerkram] PDO-Fehler in InvoiceController::update(): ' . $e->getMessage()); + View::redirect("/invoices/{$id}", 'Datenbankfehler beim Speichern der Rechnung.', 'error'); } catch (\Exception $e) { View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); } @@ -168,6 +171,9 @@ class InvoiceController try { $nr = Invoice::finalize($id); View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert."); + } catch (\PDOException $e) { + error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage()); + View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error'); } catch (\Exception $e) { View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); } @@ -270,6 +276,10 @@ class InvoiceController \Steuerkram\Database::commit(); View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gebucht (Beleg ' . $belegNr . ').'); + } catch (\PDOException $e) { + \Steuerkram\Database::rollBack(); + error_log('[Steuerkram] PDO-Fehler in InvoiceController::book(): ' . $e->getMessage()); + View::redirect("/invoices/{$id}", 'Datenbankfehler beim Buchen der Rechnung.', 'error'); } catch (\Exception $e) { \Steuerkram\Database::rollBack(); View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index 914eee2..6a56368 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -64,12 +64,12 @@ class SettingsController $file = $_FILES['logo']; $mime = mime_content_type($file['tmp_name']); - if (!in_array($mime, ['image/png', 'image/jpeg', 'image/svg+xml'])) { - View::redirect('/settings', 'Logo muss PNG, JPEG oder SVG sein.', 'error'); + if (!in_array($mime, ['image/png', 'image/jpeg'])) { + View::redirect('/settings', 'Logo muss PNG oder JPEG sein.', 'error'); return; } - $ext = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/svg+xml' => 'svg'][$mime]; + $ext = ['image/png' => 'png', 'image/jpeg' => 'jpg'][$mime]; $relPath = 'static/uploads/logo/logo.' . $ext; $absDir = dirname(__DIR__, 2) . '/public/static/uploads/logo'; diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index b94fb47..9c36c9b 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -7,7 +7,13 @@ $testMode = Setting::isTestMode(); ?> -
+
Belegnr.
@@ -18,16 +24,34 @@ $testMode = Setting::isTestMode();
- Belegdatum
- + Belegdatum✎' : '' ?>
+ + + – setzen –' ?> + + + +
Eingangsdatum
- Rechnungsnr.
- –' ?> + Rechnungsnr.✎' : '' ?>
+ + + – setzen –' ?> + + + –' ?> +
Brutto lt. Beleg
diff --git a/src/Views/belege/list.php b/src/Views/belege/list.php index f683f87..cc1693b 100644 --- a/src/Views/belege/list.php +++ b/src/Views/belege/list.php @@ -5,19 +5,25 @@
- = 2020; $y--): ?> - + $m): ?> - + + + + + + - - + $m): ?> @@ -30,7 +30,7 @@
- Buchungen + von Buchungen
diff --git a/src/Views/layout/base.php b/src/Views/layout/base.php index a774abc..84c7491 100644 --- a/src/Views/layout/base.php +++ b/src/Views/layout/base.php @@ -5,6 +5,12 @@ <?= \Steuerkram\View::h($pageTitle ?? 'Steuerkram') ?> – Steuerkram + + + + + +
diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index f1c95e0..0a889b1 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string { } ?>