Big Sweep: Security + Race-Conditions + Tests + DSGVO + A11y, SW by-v1095

SECURITY (auth.py, routes/auth.py, database.py, main.py)
- JWT bekommt jti; Logout trägt in neue jwt_blacklist-Tabelle ein,
  decode_token() prüft → server-side Invalidierung
- JWT-Expiry default 30 → 7 Tage (ENV JWT_EXPIRY_DAYS überschreibt)
- Sliding-Refresh-Middleware: erneuert Cookie wenn >50% verbraucht
  (Schwelle via JWT_REFRESH_FRACTION, Default 2)
- Login-Lockout in DB-Tabelle login_attempts (5 Versuche / 15 Min,
  überlebt Container-Restart) — alte In-Memory-Lockouts ersetzt
- SMTP-Versand: alle 'except: pass' durch logger.exception ersetzt;
  Fehlversuche landen in failed_emails-Tabelle für späteres Retry
- Referral-Counter Race gefixt: UPDATE partner_codes SET uses=uses+1
  ... WHERE uses<max_uses RETURNING — atomar statt SELECT+UPDATE

RACE CONDITIONS (routes/invoices.py, database.py)
- Neue invoice_counters-Tabelle für atomare Nummernvergabe
- _next_invoice_number nutzt BEGIN IMMEDIATE + atomares UPDATE
- Funktioniert für RG- und ST-Prefixe (Stornorechnungen)
- Race-Test verifiziert (5 Threads × 20 Calls = 100 eindeutige Nummern)

VERSION + TESTS + ERROR-DIGEST (VERSION, Makefile, tests/, scheduler.py)
- Neue VERSION-Datei (Single Source of Truth) — main.py liest beim
  Startup
- Makefile-Target 'make bump' propagiert in sw.js, app.js, index.html
- Makefile-Target 'make test' setzt venv auf, läuft pytest
- 19 Smoke-Tests in tests/ (health, auth, diary, invoice) — alle grün
- Scheduler: täglicher _job_error_digest um 06:30 → schickt Error-
  Zusammenfassung an ADMIN_EMAIL (still wenn keine Errors)

DSGVO + A11Y + ERSTE-HILFE
- landing.html: 'HTML und ODS' → 'JSON' (tatsächlich implementiert)
- datenschutz.js: Sektion Account-Löschung erweitert (sofort gelöscht /
  anonymisiert / 10 Jahre für Rechnungen)
- erste-hilfe.js: prominentes Warning-Banner oben (ersetzt keine
  Tierarzt-Beratung); Notfallnummern gruppiert nach Land, TODO-Platz-
  halter für AT-Uni-Klinik, CH Tox Info Suisse, CH Tierspital Zürich
- ui.js Modal: ESC schließt, Focus-Trap, Auto-Focus erstes Element,
  Restore Focus auf vorigen Caller
- impressum.js Kontaktformular: Labels mit for=cf-name etc.

NEUE DB-TABELLEN (idempotent via CREATE TABLE IF NOT EXISTS)
- jwt_blacklist, login_attempts, failed_emails, invoice_counters

NEUE ENV-VARS
- JWT_REFRESH_FRACTION (Default 2)
- JWT_EXPIRY_DAYS Default geändert (30 → 7)
This commit is contained in:
rene 2026-05-26 20:12:01 +02:00
parent 6224044654
commit 9394bab1fb
23 changed files with 1208 additions and 78 deletions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1094'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1095'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;

View file

@ -270,8 +270,14 @@ window.Page_datenschutz = (() => {
<strong>Datenexport (Art. 20 DSGVO):</strong> Du kannst jederzeit unter
Einstellungen Meine Daten exportieren" eine vollständige Kopie deiner
gespeicherten Daten als JSON-Datei herunterladen. Der Export enthält Profildaten,
Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen.
Hundedaten, Tagebuch (inkl. Medien-URLs), Gesundheitseinträge, Trainingsfortschritt,
Ausgaben, Verhaltensprotokoll, Versicherung, Ernährungsprofil und Futter-Reaktionen,
eigene Routen, Forum-Beiträge sowie Gassi-Teilnahmen und Gassi-Fotos.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Das JSON-Format ist maschinenlesbar und kann z. B. mit jedem Texteditor geöffnet
oder in andere Anwendungen importiert werden. Der Export wird direkt im Browser
erzeugt und nicht dauerhaft auf dem Server gespeichert.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Zur Ausübung weiterer Rechte wende dich per E-Mail an
@ -303,10 +309,31 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', `
<p style="${S.p}">
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst
einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil.
Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung.
Server-Logs werden nach 30 Tagen rotiert.
Server-Logs werden nach 30 Tagen rotiert. IP-Adressen werden ausschließlich
zur Sicherheit und für Rate-Limiting maximal 30 Tage gespeichert.
</p>`)}
${sec('Account-Löschung', `
<p style="${S.p}">
Wenn du deinen Account löschst, werden deine Daten nach folgendem Schema verarbeitet:
</p>
<ul style="${S.ul}">
<li><strong>Sofort und unwiderruflich gelöscht:</strong> Account, Hundeprofile, Tagebuch
und Tagebuch-Medien, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
Verhaltensprotokoll, Versicherung, Ernährungsprofil, Futter-Einträge und -Reaktionen,
Forum-Beiträge, eigene Notizen, Direktnachrichten, Freundschaften,
Push-Benachrichtigungen, Einstellungen und Welten-Konfiguration.</li>
<li><strong>Anonymisiert (Urheber-Bezug auf NULL gesetzt):</strong> Eigene Routen,
Forum-Threads sowie von dir angelegte Wiki-Inhalte bleiben zur Verfügbarkeit für
die Community erhalten, sind aber nicht mehr deinem Account zuordenbar.</li>
<li><strong>10 Jahre aufbewahrt (gesetzliche Pflicht):</strong> Rechnungen und
Rechnungspositionen aus kostenpflichtigen Abonnements gemäß § 147 AO. Diese
enthalten Name, E-Mail-Adresse und Rechnungsadresse zum Zeitpunkt der Rechnung
und können vor Ablauf der Frist nicht gelöscht werden.</li>
</ul>
<p style="${S.p};margin-top:var(--space-3)">
Es findet keine anonymisierte Weiterverarbeitung deiner privaten Inhalte
(Tagebuch, Gesundheit, Notizen) zu Trainings- oder Statistikzwecken statt.
</p>`)}
${sec('Mindestalter', `

View file

@ -15,10 +15,35 @@ window.Page_erste_hilfe = (() => {
// ----------------------------------------------------------------
// DATA
// ----------------------------------------------------------------
const NOTFALLNUMMERN = [
{ label: 'Tiergiftzentrale München', tel: '+4989 19240', display: '+49 89 19240' },
{ label: 'Tiergiftzentrale Berlin', tel: '+4930 19240', display: '+49 30 19240' },
{ label: 'Tiergiftzentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
// Liste von Notrufen, nach Land gruppiert.
// Struktur ist erweiterbar: weitere Länder/Städte einfach an die jeweilige
// Gruppe anhängen. Einträge mit tel:null werden als "TODO: Nummer einfügen"
// dargestellt — Rendering kümmert sich um Optik und tel: -Link.
const NOTFALLNUMMERN_GRUPPEN = [
{
land: 'Deutschland',
flag: 'DE',
eintraege: [
{ label: 'Tiergiftzentrale München', tel: '+4989 19240', display: '+49 89 19240' },
{ label: 'Tiergiftzentrale Berlin', tel: '+4930 19240', display: '+49 30 19240' },
],
},
{
land: 'Österreich',
flag: 'AT',
eintraege: [
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
{ label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' },
],
},
{
land: 'Schweiz',
flag: 'CH',
eintraege: [
{ label: 'Tox Info Suisse (Tiergiftnotruf)', tel: null, display: 'TODO: Nummer einfügen (ggf. 145)' },
{ label: 'Tierspital Zürich', tel: null, display: 'TODO: Nummer einfügen' },
],
},
];
const SCHNELL = [
@ -213,6 +238,7 @@ window.Page_erste_hilfe = (() => {
_container.innerHTML = `
<div id="eh-wrap" style="padding-bottom:var(--space-8)">
${_renderDisclaimer()}
${_renderNotfallbanner()}
${_renderSchnell()}
@ -244,13 +270,51 @@ window.Page_erste_hilfe = (() => {
_activateTab('lebensgefahr');
}
function _renderDisclaimer() {
return `
<div role="alert" style="display:flex;align-items:flex-start;gap:var(--space-3);
background:#fef3c7;color:#78350f;border-left:4px solid #d97706;
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-4);font-size:var(--text-sm);line-height:1.5">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:#d97706;width:22px;height:22px;margin-top:2px"><use href="/icons/phosphor.svg#warning"></use></svg>
<div>
<strong style="display:block;margin-bottom:2px">Diese Hinweise ersetzen keine tierärztliche Beratung.</strong>
Im Notfall sofort einen Tierarzt aufsuchen!
</div>
</div>
`;
}
function _renderNotfallbanner() {
const nums = NOTFALLNUMMERN.map(n => `
<a href="tel:${n.tel}"
style="display:flex;align-items:center;gap:var(--space-2);color:#fff;text-decoration:none;font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.15);border-radius:var(--radius-md)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
<span><strong>${n.label}</strong><br>${n.display}</span>
</a>
const renderEintrag = (n) => {
// Eintrag mit verfügbarer Nummer → tel:-Link
if (n.tel) {
return `
<a href="tel:${n.tel}"
style="display:flex;align-items:center;gap:var(--space-2);color:#fff;text-decoration:none;font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.15);border-radius:var(--radius-md)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
<span><strong>${n.label}</strong><br>${n.display}</span>
</a>
`;
}
// Eintrag ohne Nummer → ausgegrauter Platzhalter
return `
<div style="display:flex;align-items:center;gap:var(--space-2);color:rgba(255,255,255,0.85);font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.08);border-radius:var(--radius-md);border:1px dashed rgba(255,255,255,0.35)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
<span><strong>${n.label}</strong><br><em style="font-style:normal;opacity:.85">${n.display}</em></span>
</div>
`;
};
const gruppen = NOTFALLNUMMERN_GRUPPEN.map(g => `
<div>
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:var(--space-1)">
${g.flag} · ${g.land}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${g.eintraege.map(renderEintrag).join('')}
</div>
</div>
`).join('');
return `
@ -259,8 +323,8 @@ window.Page_erste_hilfe = (() => {
<svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg>
Tiergiftzentralen jetzt anrufen
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${nums}
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${gruppen}
</div>
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)">
Tierärztlicher Notdienst: Über die Tierarztsuche in der Banyaro-Karte

View file

@ -33,7 +33,7 @@ window.Page_impressum = (() => {
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<label for="cf-name" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<input id="cf-name" type="text" required maxlength="100"
placeholder="Dein Name"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -41,7 +41,7 @@ window.Page_impressum = (() => {
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
<label for="cf-email" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
<input id="cf-email" type="email" required maxlength="200"
placeholder="deine@email.de"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -50,7 +50,7 @@ window.Page_impressum = (() => {
</div>
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
<label for="cf-subject" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
<input id="cf-subject" type="text" required maxlength="150"
placeholder="Worum geht es?"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -58,7 +58,7 @@ window.Page_impressum = (() => {
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
<label for="cf-message" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
<textarea id="cf-message" required maxlength="3000" rows="5"
placeholder="Deine Nachricht…"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);

View file

@ -123,20 +123,69 @@ const UI = (() => {
};
overlay.addEventListener('focusin', _onFocusin);
_current = { overlay, onClose, _vvCleanup, _onFocusin };
// -----------------------------------------------------
// Accessibility: ESC schließt + Focus-Trap
// -----------------------------------------------------
const FOCUSABLE_SEL = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const _getFocusables = () =>
Array.from(modal?.querySelectorAll(FOCUSABLE_SEL) || [])
.filter(el => el.offsetParent !== null || el === document.activeElement);
const _prevFocus = document.activeElement;
const _onKeydown = e => {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab') return;
const focusables = _getFocusables();
if (!focusables.length) { e.preventDefault(); return; }
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first || !modal.contains(document.activeElement)) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last || !modal.contains(document.activeElement)) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', _onKeydown);
// Erstes fokussierbares Element autofokussieren (nach Render)
setTimeout(() => {
const focusables = _getFocusables();
// Schließen-Button überspringen, falls weitere Elemente vorhanden
const target = focusables.find(el => !el.classList.contains('modal-close-btn')) || focusables[0];
target?.focus();
}, 50);
_current = { overlay, onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus };
return overlay.querySelector('.modal');
}
function close() {
if (!_current) return;
const { onClose, _vvCleanup, _onFocusin } = _current;
const { onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus } = _current;
onClose?.();
_vvCleanup?.();
if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
if (_onKeydown) document.removeEventListener('keydown', _onKeydown);
_current.overlay.remove();
document.documentElement.classList.remove('modal-open');
_current = null;
// Fokus auf vorheriges Element zurücksetzen (falls noch im DOM)
if (_prevFocus && typeof _prevFocus.focus === 'function' && document.body.contains(_prevFocus)) {
try { _prevFocus.focus(); } catch (_) {}
}
// iOS Safari setzt den Zoom nach Input-Fokus nicht zurück — Viewport kurz neu setzen
const meta = document.querySelector('meta[name="viewport"]');
if (meta) {