Release v1.2.1 — Launch-Ready: Security, E-Mail-Verifikation, Passwort-vergessen, Forum public, Mailing-System

This commit is contained in:
rene 2026-04-30 20:24:59 +02:00
commit be300bf5f4
11 changed files with 299 additions and 16 deletions

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"]) user_id = int(payload["sub"])
with db() as conn: with db() as conn:
row = conn.execute( 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,) (user_id,)
).fetchone() ).fetchone()

View file

@ -565,6 +565,9 @@ def _migrate(conn_factory):
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
("users", "founder_number", "INTEGER"), ("users", "founder_number", "INTEGER"),
("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), ("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: with conn_factory() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:

View file

@ -64,6 +64,19 @@ app = FastAPI(
redoc_url = None, 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) # Globales File-Upload-Limit (20 MB)
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024 _MAX_UPLOAD_BYTES = 20 * 1024 * 1024

View file

@ -3,6 +3,7 @@
import os import os
import secrets import secrets
import string import string
from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Response, Depends 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.") raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.")
if is_username_blocked(name): if is_username_blocked(name):
raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.") 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: with db() as conn:
if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone(): 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") @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: with db() as conn:
row = conn.execute( row = conn.execute(
"SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) "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) _send_verification_email(row["email"], row["name"], token)
return {"ok": True} 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}

View file

@ -166,6 +166,8 @@ async def list_threads(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.post("/threads", status_code=201) @router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): 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(): if not data.titel.strip():
raise HTTPException(400, "Titel darf nicht leer sein.") raise HTTPException(400, "Titel darf nicht leer sein.")
if not data.text.strip(): 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) @router.post("/threads/{thread_id}/posts", status_code=201)
async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)): 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(): if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.") raise HTTPException(400, "Text darf nicht leer sein.")
with db() as conn: with db() as conn:

View file

@ -1,5 +1,6 @@
"""BAN YARO — Mailing (Admin)""" """BAN YARO — Mailing (Admin)"""
import imaplib
import os import os
import smtplib import smtplib
import ssl import ssl
@ -9,6 +10,8 @@ from email.utils import formataddr
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@ -16,9 +19,12 @@ from auth import require_admin
from database import db from database import db
router = APIRouter() router = APIRouter()
_log = logging.getLogger(__name__)
_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") _SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de")
_SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) _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 = { _ACCOUNTS = {
"partner": { "partner": {
@ -35,23 +41,73 @@ _ACCOUNTS = {
}, },
} }
# Mögliche Namen für den Sent-Ordner (Hetzner/Dovecot)
_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"]
def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
def _imap_save_sent(msg_bytes: bytes, account: str):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]: if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") _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"])
_, 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
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 = "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,
)
_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:
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = formataddr((acc["name"], acc["from"])) msg["From"] = formataddr((acc["name"], acc["from"]))
msg["To"] = to msg["To"] = to
msg["Reply-To"] = acc["from"] msg["Reply-To"] = acc["from"]
msg.attach(MIMEText(body, "plain", "utf-8")) 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 = _build_message(to, subject, body, account)
msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
s.ehlo() s.ehlo()
s.starttls(context=ctx) s.starttls(context=ctx)
s.login(acc["user"], acc["pass"]) 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)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -189,4 +189,5 @@
<symbol id="certificate" viewBox="0 0 256 256"> <symbol id="certificate" viewBox="0 0 256 256">
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/> <path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
</symbol> </symbol>
<symbol id="envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,144H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol></svg> <symbol id="envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,144H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol>
<symbol id="arrow-bend-up-left" viewBox="0 0 256 256"><path d="M232,200a8,8,0,0,1-16,0,88.1,88.1,0,0,0-88-88H88v40a8,8,0,0,1-13.66,5.66l-48-48a8,8,0,0,1,0-11.32l48-48A8,8,0,0,1,88,56V96h40A104.11,104.11,0,0,1,232,200Z"/></symbol></svg>

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

View file

@ -3,8 +3,8 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
const App = (() => { const 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} // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token}
if (hashParams.verified === '1' || hashParams.verified === 1) { if (hashParams.verified === '1' || hashParams.verified === 1) {
if (state.user) state.user.email_verified = 1; if (state.user) state.user.email_verified = 1;

View file

@ -1239,6 +1239,14 @@ window.Page_settings = (() => {
// NICHT EINGELOGGT — Login / Registrierung // NICHT EINGELOGGT — Login / Registrierung
// ---------------------------------------------------------- // ----------------------------------------------------------
function _renderAuth(mode) { 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; _mode = mode;
_container.innerHTML = ` _container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0"> <div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
@ -1313,6 +1321,13 @@ window.Page_settings = (() => {
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)"> <button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Anmelden Anmelden
</button> </button>
<p style="text-align:center;margin-top:var(--space-3);font-size:var(--text-xs)">
<button type="button" id="forgot-pw-link"
class="btn btn-ghost"
style="font-size:var(--text-xs);color:var(--c-text-muted);padding:0">
Passwort vergessen?
</button>
</p>
</form> </form>
`; `;
} }
@ -1414,6 +1429,38 @@ window.Page_settings = (() => {
function _bindLoginForm() { function _bindLoginForm() {
_bindPwToggle('login-pw', 'login-pw-toggle'); _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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts.
</p>
<div>
<label class="form-label">E-Mail</label>
<input class="form-control" id="forgot-pw-email" type="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Link senden</button>`,
});
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 => { document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = e.target.querySelector('[type="submit"]'); const btn = e.target.querySelector('[type="submit"]');
@ -1610,6 +1657,93 @@ window.Page_settings = (() => {
setTimeout(remove, 12000); setTimeout(remove, 12000);
} }
// ----------------------------------------------------------
// PASSWORT ZURÜCKSETZEN
// ----------------------------------------------------------
function _renderResetPassword(token) {
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Neues Passwort</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
Wähle ein sicheres Passwort für deinen Account.
</p>
</div>
<form id="reset-pw-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label class="form-label">Neues Passwort</label>
<div style="position:relative">
<input class="form-control" type="password" id="reset-pw-input"
placeholder="Mindestens 8 Zeichen" autocomplete="new-password"
minlength="8" required style="padding-right:var(--space-10)">
<button type="button" id="reset-pw-toggle"
class="btn btn-ghost btn-icon"
style="position:absolute;right:var(--space-1);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);padding:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
</button>
</div>
<!-- Hundepassphrase-Generator -->
<div style="margin-top:var(--space-2);padding:var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Passwort-Vorschlag</span>
<button type="button" id="reset-gen-new"
style="margin-left:auto;font-size:var(--text-xs);color:var(--c-primary);
background:none;border:none;cursor:pointer;padding:0;font-weight:600">
neu
</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<code id="reset-gen-phrase"
style="flex:1;font-size:var(--text-sm);font-weight:700;
color:var(--c-text);letter-spacing:.02em;word-break:break-all"></code>
<button type="button" id="reset-gen-use"
class="btn btn-sm btn-secondary" style="flex-shrink:0">
Übernehmen
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-full">
Passwort speichern
</button>
</form>
</div>
`;
_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 // HELPER
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v575'; const CACHE_VERSION = 'by-v577';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache

View file

@ -15,8 +15,6 @@ services:
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
- VAPID_CONTACT=mailto:admin@banyaro.app - VAPID_CONTACT=mailto:admin@banyaro.app
- SMTP_SUPPORT_USER=support@banyaro.de
- SMTP_SUPPORT_PASS=Marbled-Drool8-Whacky
healthcheck: healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
interval: 30s interval: 30s