diff --git a/backend/auth.py b/backend/auth.py index b2736f5..942a3f1 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, email_verified 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 FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index 5ea9f4a..76c1c2c 100644 --- a/backend/database.py +++ b/backend/database.py @@ -565,9 +565,6 @@ 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 e8720c9..ddb4acc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -64,19 +64,6 @@ 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 f810217..7661c80 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,7 +3,6 @@ import os import secrets import string -from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, HTTPException, Request, Response, Depends @@ -78,8 +77,6 @@ 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(): @@ -250,8 +247,7 @@ async def verify_email(token: str): @router.post("/resend-verification") -async def resend_verification(request: Request, user=Depends(get_current_user)): - rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify") +async def resend_verification(user=Depends(get_current_user)): with db() as conn: row = conn.execute( "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) @@ -267,65 +263,3 @@ async def resend_verification(request: Request, 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 0cfe1df..b6d204f 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -166,8 +166,6 @@ 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(): @@ -306,8 +304,6 @@ 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/routes/outreach.py b/backend/routes/outreach.py index 6ec066c..11f4152 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -1,6 +1,5 @@ """BAN YARO — Mailing (Admin)""" -import imaplib import os import smtplib import ssl @@ -10,21 +9,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() -_log = logging.getLogger(__name__) +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": { @@ -41,73 +35,23 @@ _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"]: - _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["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 = _build_message(to, subject, body, account) - msg_bytes = msg.as_bytes() + 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")) 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_bytes) - _imap_save_sent(msg_bytes, account) + s.sendmail(acc["from"], [to], msg.as_bytes()) # ------------------------------------------------------------------ diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 3fcf69f..2fcc061 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -189,5 +189,4 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f1d577b..6937188 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 = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '552'; // ← 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'; const App = (() => { @@ -824,14 +824,6 @@ 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 7c3679d..a0f3a9a 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -1239,14 +1239,6 @@ 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 = `
@@ -1321,13 +1313,6 @@ window.Page_settings = (() => { -

- -

`; } @@ -1429,38 +1414,6 @@ 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"]'); @@ -1657,93 +1610,6 @@ 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 d3afae4..9b0bb55 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-v577'; +const CACHE_VERSION = 'by-v575'; 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 diff --git a/docker-compose.yml b/docker-compose.yml index a3d6772..d1f4a45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ 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