From e7939ce98e3ca89336ae05caa3fad66f59909412 Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 27 May 2026 09:41:56 +0200 Subject: [PATCH] =?UTF-8?q?B=C3=BCndel=20A-D:=20Race-Fixes,=20JWT-Cleanup,?= =?UTF-8?q?=20Storage-Watchdog,=20HTTPException,=20SW=20by-v1112?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A — Founder-Number-Race (Audit-Fund aus Agent 2) - partner.py PATCH /admin/users: SELECT COUNT + UPDATE+1 → atomares UPDATE mit Sub-Query. WHERE-Klausel prüft Limit + dass User noch nicht is_founder=1 ist. rowcount=0 → 'Plätze vergeben'. - dogs.py POST /dogs (erster Hund triggert Gründer-Aktivierung): selbes Pattern. Zusätzlich AND is_founder_pending=1 als Schutz. - Sub-Queries werden gegen Snapshot VOR dem UPDATE evaluiert (SQL-Spec), daher keine 'doppelte Nummer' möglich auch wenn zwei User gleichzeitig den ersten Hund anlegen. B — JWT-Blacklist-Cleanup-Job - _purge_expired_jwt() in auth.py existierte schon, war aber nicht verdrahtet → jwt_blacklist wuchs monoton. - Neuer Scheduler-Job _job_purge_jwt_blacklist täglich 03:30 (nach poison_archive, in ruhiger Zeit), mit _log_job für Error-Digest. C — iOS Storage-Quota-Watchdog (PWA-Stabilität) - offline-indicator.js: _checkStorageQuota() per navigator.storage.estimate() beim Init + alle 60s im Interval. - Bei >=80% Auslastung: Tile-Cache auf 100 Einträge trimmen (statt default 500). Verhindert QuotaExceededError auf iOS-PWA (~50MB). - Bei >=90%: einmaliger Toast-Hinweis pro Session 'Speicher fast voll — Tiles werden gelöscht'. D — HTTPException in osm.py - 'raise Exception("Alle Overpass-Instanzen fehlgeschlagen")' wurde zu HTTP 500 → User-unfriendly. Jetzt 503 mit klarer Message 'Kartendaten gerade nicht verfügbar'. Tests 19/19 grün. --- VERSION | 2 +- backend/routes/dogs.py | 24 ++++++++++------ backend/routes/osm.py | 2 +- backend/routes/partner.py | 35 +++++++++++++++-------- backend/scheduler.py | 21 ++++++++++++++ backend/static/index.html | 24 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 39 +++++++++++++++++++++++++- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 10 files changed, 116 insertions(+), 37 deletions(-) diff --git a/VERSION b/VERSION index d1d06ad..76482c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1111 \ No newline at end of file +1112 \ No newline at end of file diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 9cc2820..8004df3 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -180,14 +180,22 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)): if dog_count == 1: # genau dieser erste Hund plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag) if plausible: - total = conn.execute( - "SELECT COUNT(*) FROM users WHERE is_founder=1" - ).fetchone()[0] - if total < 100: - conn.execute( - "UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?", - (total + 1, user["id"]) - ) + # Atomare Gründer-Vergabe — Race-frei via Sub-Query im UPDATE. + # Wenn schon 100 Founder oder User schon is_founder=1 → kein Update (rowcount=0) + conn.execute( + """UPDATE users + SET is_founder = 1, + founder_number = ( + SELECT IFNULL(MAX(founder_number), 0) + 1 + FROM users WHERE is_founder = 1 + ), + is_founder_pending = 0 + WHERE id = ? + AND is_founder_pending = 1 + AND (is_founder IS NULL OR is_founder = 0) + AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""", + (user["id"],) + ) return dict(dog) diff --git a/backend/routes/osm.py b/backend/routes/osm.py index d130898..4d1f36f 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -110,7 +110,7 @@ async def _fetch_overpass(query): except Exception as exc: logger.warning(f"Overpass Verbindungsfehler {url}: {exc}") break # nächste URL - raise Exception("Alle Overpass-Instanzen fehlgeschlagen") + raise HTTPException(503, "Kartendaten gerade nicht verfügbar — bitte später nochmal.") def _stale_tiles(poi_type, tiles): stale = [] diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 359dc1c..b2d21f0 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -93,21 +93,34 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm if not target: raise HTTPException(404, "User nicht gefunden.") if updates.get("is_founder") == 1 and not target["founder_number"]: - # Neue Gründer-Nummer zuweisen - total = conn.execute( - "SELECT COUNT(*) FROM users WHERE is_founder=1" - ).fetchone()[0] - if total >= FOUNDER_MAX: + # Atomare Gründer-Vergabe — kein TOCTOU mehr zwischen COUNT und UPDATE. + # Sub-Query wird gegen Snapshot vor dem UPDATE evaluiert (SQL-Spec). + cur = conn.execute( + """UPDATE users + SET is_founder = 1, + founder_number = ( + SELECT IFNULL(MAX(founder_number), 0) + 1 + FROM users WHERE is_founder = 1 + ) + WHERE id = ? + AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < ? + AND (is_founder IS NULL OR is_founder = 0)""", + (user_id, FOUNDER_MAX) + ) + if cur.rowcount == 0: raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.") - updates["founder_number"] = total + 1 + # is_founder + founder_number sind atomar gesetzt — aus updates entfernen + updates.pop("is_founder", None) + updates.pop("founder_number", None) elif updates.get("is_founder") == 0: # Gründer-Status entfernen → founder_number ebenfalls leeren updates["founder_number"] = None - set_clause = ", ".join(f"{k}=?" for k in updates) - conn.execute( - f"UPDATE users SET {set_clause} WHERE id=?", - (*updates.values(), user_id) - ) + if updates: # nach atomarer Founder-Vergabe ggf. leer + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE users SET {set_clause} WHERE id=?", + (*updates.values(), user_id) + ) row = conn.execute( "SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?", (user_id,) diff --git a/backend/scheduler.py b/backend/scheduler.py index 11dcf62..fd04c22 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -46,6 +46,14 @@ def start(): misfire_grace_time=3600, coalesce=True, ) + _scheduler.add_job( + _job_purge_jwt_blacklist, + CronTrigger(hour=3, minute=30), # täglich 03:30 Uhr, nach poison_archive + id="purge_jwt_blacklist", + replace_existing=True, + misfire_grace_time=3600, + coalesce=True, + ) _scheduler.add_job( _job_weather_alert, CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr @@ -2231,3 +2239,16 @@ async def _job_error_digest(): except Exception as e: logger.error(f"Error-Digest: Mail-Fehler: {e}") _log_job("error_digest", "error", str(e)) + + +def _job_purge_jwt_blacklist(): + """Räumt abgelaufene Einträge aus jwt_blacklist auf — sonst wächst die + Tabelle monoton mit jedem Logout. Läuft täglich 03:30.""" + try: + from auth import _purge_expired_jwt + deleted = _purge_expired_jwt() + logger.info(f"jwt_blacklist: {deleted} abgelaufene Einträge gelöscht.") + _log_job("purge_jwt_blacklist", "ok", f"{deleted} entries deleted") + except Exception as e: + logger.exception(f"jwt_blacklist purge fehlgeschlagen: {e}") + _log_job("purge_jwt_blacklist", "error", str(e)) diff --git a/backend/static/index.html b/backend/static/index.html index a06076c..02a2004 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 46463d7..84f25ec 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1111'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1112'; // ← 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; diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js index ad83c2b..234fca0 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -234,6 +234,42 @@ window.OfflineIndicator = (() => { navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls }); } + // ---------------------------------------------------------- + // Storage-Quota überwachen — iOS-PWA hat ~50MB Limit + // ---------------------------------------------------------- + let _storageWarned = false; + async function _checkStorageQuota() { + if (!navigator.storage?.estimate) return; + try { + const { usage = 0, quota = 0 } = await navigator.storage.estimate(); + if (!quota) return; + const ratio = usage / quota; + + // Ab 80% Auslastung: Tile-Cache aggressiv trimmen (per SW-Message) + if (ratio >= 0.8) { + const cache = await caches.open(CACHE_TILES).catch(() => null); + if (cache) { + const keys = await cache.keys(); + // Auf 100 Tiles trimmen (statt 500) bei knappem Speicher + if (keys.length > 100) { + const toDelete = keys.slice(0, keys.length - 100); + await Promise.all(toDelete.map(k => cache.delete(k).catch(() => {}))); + } + } + // Einmaliger User-Hinweis pro Session bei kritischer Auslastung (>90%) + if (ratio >= 0.9 && !_storageWarned && window.UI?.toast) { + _storageWarned = true; + const mb = Math.round(usage / 1024 / 1024); + const max = Math.round(quota / 1024 / 1024); + window.UI.toast.warning( + `Speicher fast voll (${mb}/${max} MB) — älteste Karten-Tiles werden gelöscht.`, + 6000 + ); + } + } + } catch {} + } + // Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte function _prefetchPages() { ['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost'] @@ -305,7 +341,8 @@ window.OfflineIndicator = (() => { if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh(); }); } - setInterval(() => { _prefetchData(); refresh(); }, 60_000); + _checkStorageQuota(); // beim Init prüfen + setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000); } return { init, refresh, openStatus }; diff --git a/backend/static/landing.html b/backend/static/landing.html index efbe3ad..b3ecbcd 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 6a4127b..52cf3a0 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1111'; +const VER = '1112'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten