kleine Verbesserungen, Favicon und Sicherheit

This commit is contained in:
fds2610 2026-03-25 10:35:16 +01:00
parent a0ddc6a5d8
commit 4f06a19c2c
20 changed files with 276 additions and 42 deletions

28
Changes.md Normal file
View file

@ -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

View file

@ -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 != '';

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View file

@ -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 = '<span class="text-muted">– setzen –</span>';
}
} else {
bar.dataset.rechnungsNr = newValue;
trigger.dataset.raw = newValue;
trigger.textContent = newValue || '';
if (!newValue) trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
}
} 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 => {

View file

@ -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');

View file

@ -24,8 +24,11 @@ class BelegController
public function index(array $params): void
{
$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 = Beleg::all($filter, $page);
$totalAll = Beleg::all([], 1, 1)['total'];
$view = new View();
$view->render('layout/base.php', [
@ -33,6 +36,7 @@ class BelegController
'content' => $this->renderView('belege/list', [
'rows' => $result['rows'],
'total' => $result['total'],
'totalAll' => $totalAll,
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
@ -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
{
$isAjax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest';
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) { http_response_code(404); return; }
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.');
}

View file

@ -16,8 +16,11 @@ class BookingController
public function journal(array $params): void
{
$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]);
@ -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);

View file

@ -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');

View file

@ -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';

View file

@ -7,7 +7,13 @@ $testMode = Setting::isTestMode();
?>
<!-- Beleg-Info-Bar -->
<div class="card mb-2">
<div class="card mb-2"
id="beleg-info-bar"
data-beleg-id="<?= $h($beleg['id']) ?>"
data-belegtyp="<?= $h($beleg['belegtyp']) ?>"
data-beleg-datum="<?= $h($beleg['beleg_datum'] ?? '') ?>"
data-rechnungs-nr="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
data-betrag-brutto="<?= $h($beleg['betrag_brutto'] ?? '') ?>">
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
<div>
<span class="text-muted text-sm">Belegnr.</span><br>
@ -18,16 +24,34 @@ $testMode = Setting::isTestMode();
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
</div>
<div>
<span class="text-muted text-sm">Belegdatum</span><br>
<span class="text-muted text-sm">Belegdatum<?= !$beleg['locked'] ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
<?php if (!$beleg['locked']): ?>
<span class="inline-edit-trigger"
data-field="beleg_datum" data-type="date"
data-raw="<?= $h($beleg['beleg_datum'] ?? '') ?>"
title="Klicken zum Bearbeiten">
<?= $beleg['beleg_datum'] ? View::formatDate($beleg['beleg_datum']) : '<span class="text-muted">– setzen –</span>' ?>
</span>
<?php else: ?>
<?= View::formatDate($beleg['beleg_datum']) ?>
<?php endif; ?>
</div>
<div>
<span class="text-muted text-sm">Eingangsdatum</span><br>
<?= View::formatDate($beleg['eingangs_datum']) ?>
</div>
<div>
<span class="text-muted text-sm">Rechnungsnr.</span><br>
<span class="text-muted text-sm">Rechnungsnr.<?= !$beleg['locked'] ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
<?php if (!$beleg['locked']): ?>
<span class="inline-edit-trigger"
data-field="rechnungs_nr" data-type="text"
data-raw="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
title="Klicken zum Bearbeiten">
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">– setzen –</span>' ?>
</span>
<?php else: ?>
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">–</span>' ?>
<?php endif; ?>
</div>
<div>
<span class="text-muted text-sm">Brutto lt. Beleg</span><br>

View file

@ -5,19 +5,25 @@
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
<input type="search" name="search" placeholder="Belegnr., Dateiname, Rechnungsnr.…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
<select name="year">
<select name="year" style="width:80px">
<option value="">Alle Jahre</option>
<?php for ($y = (int)date('Y'); $y >= 2020; $y--): ?>
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
<?php endfor; ?>
</select>
<select name="month">
<option value="">Alle Monate</option>
<select name="month" style="width:70px">
<option value="">Alle</option>
<?php foreach (['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] as $i => $m): ?>
<option value="<?= $i+1 ?>"<?= ($filter['month'] ?? '') == $i+1 ? ' selected' : '' ?>><?= $m ?></option>
<?php endforeach; ?>
</select>
<select name="booked">
<select name="belegtyp" style="width:130px">
<option value="">Alle Belegarten</option>
<?php foreach (['Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges'] as $t): ?>
<option value="<?= $t ?>"<?= ($filter['belegtyp'] ?? '') === $t ? ' selected' : '' ?>><?= $t ?></option>
<?php endforeach; ?>
</select>
<select name="booked" style="width:90px">
<option value="">Alle</option>
<option value="0"<?= ($filter['booked'] ?? '') === '0' ? ' selected' : '' ?>>Ungebucht</option>
<option value="1"<?= ($filter['booked'] ?? '') === '1' ? ' selected' : '' ?>>Gebucht</option>
@ -27,7 +33,7 @@
</form>
</div>
<div class="toolbar-right">
<span class="text-muted text-sm"><?= $total ?> Belege</span>
<span class="text-muted text-sm"><?= $total ?> von <?= $totalAll ?> Belegen</span>
</div>
</div>

View file

@ -5,14 +5,14 @@
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
<input type="search" name="search" placeholder="Buchungstext suchen…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:220px">
<select name="year">
<select name="year" style="width:80px">
<option value="">Alle Jahre</option>
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
<select name="month">
<option value="">Alle Monate</option>
<select name="month" style="width:70px">
<option value="">Alle</option>
<?php foreach (['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] as $i => $m): ?>
<option value="<?= $i+1 ?>"<?= ($filter['month'] ?? '') == $i+1 ? ' selected' : '' ?>><?= $m ?></option>
<?php endforeach; ?>
@ -30,7 +30,7 @@
</form>
</div>
<div class="toolbar-right">
<span class="text-muted text-sm"><?= $total ?> Buchungen</span>
<span class="text-muted text-sm"><?= $total ?> von <?= $totalAll ?> Buchungen</span>
</div>
</div>

View file

@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= \Steuerkram\View::h($pageTitle ?? 'Steuerkram') ?> – Steuerkram</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="icon" type="image/png" href="/static/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/static/favicon/favicon.svg" />
<link rel="shortcut icon" href="/static/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Steuerkram" />
<link rel="manifest" href="/static/favicon/site.webmanifest" />
</head>
<body>
<div class="app-layout">

View file

@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string {
}
?>
<aside class="sidebar">
<div class="sidebar-brand">Steuer<span>kram</span></div>
<div class="sidebar-brand"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></div>
<div class="sidebar-section">
<div class="sidebar-label">Übersicht</div>