From 31fae63658ff16d59dda76e86cf0f08ee9528416 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:54:51 +0200 Subject: [PATCH 1/6] Feature: Gesendete Mails via IMAP in Sent-Ordner ablegen --- backend/routes/outreach.py | 56 +++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index 11f4152..ab4e7fb 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -1,5 +1,6 @@ """BAN YARO — Mailing (Admin)""" +import imaplib import os import smtplib import ssl @@ -19,6 +20,8 @@ router = APIRouter() _SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") _SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +_IMAP_HOST = os.getenv("IMAP_HOST", "mail.your-server.de") +_IMAP_PORT = int(os.getenv("IMAP_PORT", "993")) _ACCOUNTS = { "partner": { @@ -35,23 +38,62 @@ _ACCOUNTS = { }, } +# Mögliche Namen für den Sent-Ordner (Hetzner/Dovecot) +_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"] + + +def _imap_save_sent(msg_bytes: bytes, account: str): + acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] + if not acc["user"] or not acc["pass"]: + return + try: + ctx = ssl.create_default_context() + with imaplib.IMAP4_SSL(_IMAP_HOST, _IMAP_PORT, ssl_context=ctx) as imap: + imap.login(acc["user"], acc["pass"]) + # Sent-Ordner finden + folder = None + _, folders = imap.list() + available = [f.decode() for f in (folders or [])] + for candidate in _SENT_CANDIDATES: + if any(candidate.lower() in f.lower() for f in available): + folder = candidate + break + if not folder: + folder = "Sent" # Fallback: anlegen lassen + imap.append( + folder, + r"\Seen", + imaplib.Time2Internaldate(datetime.now().timestamp()), + msg_bytes, + ) + except Exception: + pass # Nicht blockieren wenn IMAP fehlschlägt + + +def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart: + acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = formataddr((acc["name"], acc["from"])) + msg["To"] = to + msg["Reply-To"] = acc["from"] + msg.attach(MIMEText(body, "plain", "utf-8")) + return msg + def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] if not acc["user"] or not acc["pass"]: raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = formataddr((acc["name"], acc["from"])) - msg["To"] = to - msg["Reply-To"] = acc["from"] - msg.attach(MIMEText(body, "plain", "utf-8")) + msg = _build_message(to, subject, body, account) + msg_bytes = msg.as_bytes() ctx = ssl.create_default_context() with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: s.ehlo() s.starttls(context=ctx) s.login(acc["user"], acc["pass"]) - s.sendmail(acc["from"], [to], msg.as_bytes()) + s.sendmail(acc["from"], [to], msg_bytes) + _imap_save_sent(msg_bytes, account) # ------------------------------------------------------------------ From 4c6dd07c310410baa6347698a9deb0e736358921 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:03:23 +0200 Subject: [PATCH 2/6] =?UTF-8?q?Fix:=20IMAP=20Sent-Ordner=20=E2=80=94=20ech?= =?UTF-8?q?ten=20Namen=20aus=20LIST-Antwort=20extrahieren=20(INBOX.Sent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/outreach.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index ab4e7fb..6ec066c 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -10,13 +10,16 @@ from email.utils import formataddr from datetime import datetime from typing import List, Optional +import logging + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from auth import require_admin from database import db -router = APIRouter() +router = APIRouter() +_log = logging.getLogger(__name__) _SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") _SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) @@ -45,29 +48,40 @@ _SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesend def _imap_save_sent(msg_bytes: bytes, account: str): acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] if not acc["user"] or not acc["pass"]: + _log.warning("IMAP: Account '%s' nicht konfiguriert, überspringe.", account) return try: ctx = ssl.create_default_context() with imaplib.IMAP4_SSL(_IMAP_HOST, _IMAP_PORT, ssl_context=ctx) as imap: imap.login(acc["user"], acc["pass"]) - # Sent-Ordner finden + _, raw_folders = imap.list() + available = [f.decode(errors="replace") for f in (raw_folders or [])] + _log.info("IMAP Ordner (%s): %s", account, available) + + # Echten Ordnernamen aus LIST-Antwort extrahieren + # Format: '(\Flags) "." INBOX.Sent' → letztes Token folder = None - _, folders = imap.list() - available = [f.decode() for f in (folders or [])] - for candidate in _SENT_CANDIDATES: - if any(candidate.lower() in f.lower() for f in available): - folder = candidate + for line in available: + name = line.rsplit('"." ', 1)[-1].strip().strip('"') + for candidate in _SENT_CANDIDATES: + if candidate.lower() in name.lower(): + folder = name + break + if folder: break if not folder: - folder = "Sent" # Fallback: anlegen lassen - imap.append( + folder = "INBOX.Sent" + _log.info("IMAP: speichere in Ordner '%s' (%s)", folder, account) + + typ, data = imap.append( folder, r"\Seen", imaplib.Time2Internaldate(datetime.now().timestamp()), msg_bytes, ) - except Exception: - pass # Nicht blockieren wenn IMAP fehlschlägt + _log.info("IMAP append: %s %s", typ, data) + except Exception as e: + _log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e) def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart: From 3291930d074b40e03d53fe007627ea9767c6aae4 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:05:52 +0200 Subject: [PATCH 3/6] =?UTF-8?q?Fix:=20arrow-bend-up-left=20Icon=20zum=20Sp?= =?UTF-8?q?rite=20hinzugef=C3=BCgt=20(Vorlage=20laden)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/icons/phosphor.svg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 2fcc061..3fcf69f 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -189,4 +189,5 @@ - \ No newline at end of file + + \ No newline at end of file From 82d6417d094cfca3b49e17e595e16b14b457ad52 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:18:53 +0200 Subject: [PATCH 4/6] =?UTF-8?q?Security:=20SMTP=5FSUPPORT=20Credentials=20?= =?UTF-8?q?aus=20docker-compose.yml=20entfernt=20=E2=80=94=20geh=C3=B6ren?= =?UTF-8?q?=20in=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d1f4a45..a3d6772 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,6 @@ services: - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_CONTACT=mailto:admin@banyaro.app - - SMTP_SUPPORT_USER=support@banyaro.de - - SMTP_SUPPORT_PASS=Marbled-Drool8-Whacky healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] interval: 30s From 526ff4221591cee4585ca8a6a9bde0361b242c17 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:23:43 +0200 Subject: [PATCH 5/6] Security: Passwort-Minimum, Rate Limits, Headers, Passwort-vergessen, email_verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Passwort-Minimum 8 Zeichen bei Register + Reset - Rate Limit auf /resend-verification (3/h) und /forgot-password (3/h) - Security-Headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy etc. - email_verified in get_current_user SELECT ergänzt - Forum: create_thread + create_post erfordern email_verified - POST /auth/forgot-password + /auth/reset-password (2h-Token, via support@) - DB-Migration: password_reset_token + password_reset_expires - Frontend: Passwort-vergessen-Modal im Login, Reset-Formular mit Passphrase-Generator - SW by-v576, APP_VER 553 --- backend/auth.py | 2 +- backend/database.py | 3 + backend/main.py | 13 +++ backend/routes/auth.py | 68 +++++++++++++- backend/routes/forum.py | 4 + backend/static/js/app.js | 10 ++- backend/static/js/pages/settings.js | 134 ++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 8 files changed, 232 insertions(+), 4 deletions(-) diff --git a/backend/auth.py b/backend/auth.py index 942a3f1..b2736f5 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index 76c1c2c..5ea9f4a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -565,6 +565,9 @@ def _migrate(conn_factory): ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), ("users", "founder_number", "INTEGER"), ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), + # Passwort-Zurücksetzen + ("users", "password_reset_token", "TEXT"), + ("users", "password_reset_expires", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index ddb4acc..e8720c9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -64,6 +64,19 @@ app = FastAPI( redoc_url = None, ) +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["X-XSS-Protection"] = "1; mode=block" + return response + +app.add_middleware(SecurityHeadersMiddleware) + + # Globales File-Upload-Limit (20 MB) _MAX_UPLOAD_BYTES = 20 * 1024 * 1024 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 7661c80..f810217 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,6 +3,7 @@ import os import secrets import string +from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, HTTPException, Request, Response, Depends @@ -77,6 +78,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.") if is_username_blocked(name): raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.") + if len(data.password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") with db() as conn: if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone(): @@ -247,7 +250,8 @@ async def verify_email(token: str): @router.post("/resend-verification") -async def resend_verification(user=Depends(get_current_user)): +async def resend_verification(request: Request, user=Depends(get_current_user)): + rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify") with db() as conn: row = conn.execute( "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) @@ -263,3 +267,65 @@ async def resend_verification(user=Depends(get_current_user)): ) _send_verification_email(row["email"], row["name"], token) return {"ok": True} + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + +class ResetPasswordRequest(BaseModel): + token: str + password: str + +@router.post("/forgot-password") +async def forgot_password(data: ForgotPasswordRequest, request: Request): + rl_check(request, max_requests=3, window_seconds=3600, key="forgot_pw") + with db() as conn: + user = conn.execute( + "SELECT id, name FROM users WHERE email=?", (data.email,) + ).fetchone() + # Immer OK zurückgeben — kein User-Enumeration + if user: + token = secrets.token_urlsafe(32) + expires = (datetime.utcnow() + timedelta(hours=2)).isoformat() + with db() as conn: + conn.execute( + "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", + (token, expires, user["id"]) + ) + app_url = os.getenv("APP_URL", "https://banyaro.app") + subject = "Ban Yaro — Passwort zurücksetzen" + body = ( + f"Hallo {user['name']},\n\n" + "du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n" + f"Klicke hier um ein neues Passwort zu setzen:\n" + f"{app_url}/#reset-password?token={token}\n\n" + "Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n" + "Viele Grüße,\nDas Ban Yaro Team" + ) + from routes.outreach import _send_smtp + try: + _send_smtp(data.email, subject, body, "support") + except Exception: + pass + return {"ok": True} + + +@router.post("/reset-password") +async def reset_password(data: ResetPasswordRequest, request: Request): + rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw") + if len(data.password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") + with db() as conn: + user = conn.execute( + "SELECT id, password_reset_expires FROM users WHERE password_reset_token=?", + (data.token,) + ).fetchone() + if not user: + raise HTTPException(400, "Ungültiger oder abgelaufener Link.") + if user["password_reset_expires"] < datetime.utcnow().isoformat(): + raise HTTPException(400, "Dieser Link ist abgelaufen. Bitte fordere einen neuen an.") + conn.execute( + "UPDATE users SET pw_hash=?, password_reset_token=NULL, password_reset_expires=NULL WHERE id=?", + (hash_password(data.password), user["id"]) + ) + return {"ok": True} diff --git a/backend/routes/forum.py b/backend/routes/forum.py index b6d204f..0cfe1df 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -166,6 +166,8 @@ async def list_threads( # ------------------------------------------------------------------ @router.post("/threads", status_code=201) async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): + if not user.get("email_verified"): + raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.") if not data.titel.strip(): raise HTTPException(400, "Titel darf nicht leer sein.") if not data.text.strip(): @@ -304,6 +306,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre # ------------------------------------------------------------------ @router.post("/threads/{thread_id}/posts", status_code=201) async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)): + if not user.get("email_verified"): + raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.") if not data.text.strip(): raise HTTPException(400, "Text darf nicht leer sein.") with db() as conn: diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6937188..728628b 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 = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '553'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -824,6 +824,14 @@ const App = (() => { }); } + // Passwort-Reset: #reset-password?token=xxx + if (hashPage === 'reset-password' && hashParams.token) { + sessionStorage.setItem('by_reset_token', hashParams.token); + history.replaceState(null, '', '/'); + navigate('settings', false); + return; + } + // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token} if (hashParams.verified === '1' || hashParams.verified === 1) { if (state.user) state.user.email_verified = 1; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index a0f3a9a..7c3679d 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -1239,6 +1239,14 @@ window.Page_settings = (() => { // NICHT EINGELOGGT — Login / Registrierung // ---------------------------------------------------------- function _renderAuth(mode) { + // Passwort-Reset über Link aus E-Mail + const resetToken = sessionStorage.getItem('by_reset_token'); + if (resetToken) { + sessionStorage.removeItem('by_reset_token'); + _renderResetPassword(resetToken); + return; + } + _mode = mode; _container.innerHTML = `
@@ -1313,6 +1321,13 @@ window.Page_settings = (() => { +

+ +

`; } @@ -1414,6 +1429,38 @@ window.Page_settings = (() => { function _bindLoginForm() { _bindPwToggle('login-pw', 'login-pw-toggle'); + + document.getElementById('forgot-pw-link')?.addEventListener('click', () => { + const id = 'forgot-pw-modal'; + UI.modal.open({ + title: 'Passwort zurücksetzen', + body: ` +
+

+ Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts. +

+
+ + +
+
`, + footer: ` + + `, + }); + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector(`[form="${id}"]`); + const email = document.getElementById('forgot-pw-email').value.trim(); + await UI.asyncButton(btn, async () => { + await API.post('/auth/forgot-password', { email }); + UI.modal.close(); + UI.toast.success('Falls ein Account existiert, haben wir dir einen Link geschickt.'); + }); + }); + }); + document.getElementById('auth-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); @@ -1610,6 +1657,93 @@ window.Page_settings = (() => { setTimeout(remove, 12000); } + // ---------------------------------------------------------- + // PASSWORT ZURÜCKSETZEN + // ---------------------------------------------------------- + function _renderResetPassword(token) { + _container.innerHTML = ` +
+
+ Ban Yaro +

Neues Passwort

+

+ Wähle ein sicheres Passwort für deinen Account. +

+
+ +
+
+ +
+ + +
+ +
+
+ 🐾 Passwort-Vorschlag + +
+
+ + +
+
+
+ + +
+
+ `; + + _bindPwToggle('reset-pw-input', 'reset-pw-toggle'); + + const phraseEl = document.getElementById('reset-gen-phrase'); + const pwInput = document.getElementById('reset-pw-input'); + const _refresh = () => { phraseEl.textContent = _genPassphrase(); }; + _refresh(); + document.getElementById('reset-gen-new')?.addEventListener('click', _refresh); + document.getElementById('reset-gen-use')?.addEventListener('click', () => { + pwInput.value = phraseEl.textContent; + pwInput.type = 'text'; + }); + + document.getElementById('reset-pw-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const password = document.getElementById('reset-pw-input').value; + await UI.asyncButton(btn, async () => { + const res = await API.post('/auth/reset-password', { token, password }); + if (res?.ok) { + UI.toast.success('Passwort geändert! Du kannst dich jetzt anmelden.'); + _renderAuth('login'); + } + }); + }); + } + // ---------------------------------------------------------- // HELPER // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 9b0bb55..a3a1bc2 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v575'; +const CACHE_VERSION = 'by-v576'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From f3e4a51178a52c726c9642658c49ea42215b6e90 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:24:55 +0200 Subject: [PATCH 6/6] =?UTF-8?q?Release=20v1.2.1=20=E2=80=94=20APP=5FVERSIO?= =?UTF-8?q?N=20bump,=20SW=20by-v577?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/js/app.js | 4 ++-- backend/static/sw.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 728628b..f1d577b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '553'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; const App = (() => { diff --git a/backend/static/sw.js b/backend/static/sw.js index a3a1bc2..d3afae4 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v576'; +const CACHE_VERSION = 'by-v577'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache