diff --git a/backend/database.py b/backend/database.py index eeb1add..1a70aa5 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1747,71 +1747,6 @@ def _migrate(conn_factory): ) """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS community_adoption ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, - name TEXT NOT NULL, - rasse TEXT, - alter_jahre REAL, - geschlecht TEXT, - foto_url TEXT, - beschreibung TEXT NOT NULL, - gruende TEXT, - ort TEXT, - plz TEXT, - lat REAL, - lon REAL, - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS community_adoption_interest ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - listing_id INTEGER NOT NULL REFERENCES community_adoption(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - nachricht TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(listing_id, user_id) - ) - """) - - # ---- Wetter-Log (historische Vorhersage-Daten) ---- - conn.execute(""" - CREATE TABLE IF NOT EXISTS weather_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - logged_at TEXT NOT NULL DEFAULT (datetime('now')), - date TEXT NOT NULL, - lat_r REAL NOT NULL, - lon_r REAL NOT NULL, - temp_max REAL, - temp_min REAL, - feels_max REAL, - precip_prob INTEGER, - precip_sum REAL, - wind_kmh REAL, - wind_dir TEXT, - uv_index REAL, - weathercode INTEGER, - weatherdesc TEXT, - sunrise TEXT, - sunset TEXT, - asphalt_temp REAL, - asphalt_warn TEXT, - zecken TEXT, - pollen_erle INTEGER, - pollen_birke INTEGER, - pollen_graeser INTEGER, - pollen_beifuss INTEGER, - pollen_ambrosia INTEGER, - forecast_json TEXT, - UNIQUE(date, lat_r, lon_r) - ) - """) - # ---- Favoriten-Tierarzt + Gesundheitsdokumente ---- conn.execute(""" CREATE TABLE IF NOT EXISTS favorite_vets ( diff --git a/backend/main.py b/backend/main.py index 229a856..8b259f7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,6 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware -from brotli_asgi import BrotliMiddleware from contextlib import asynccontextmanager from database import init_db @@ -135,7 +134,6 @@ class MediaCacheMiddleware(BaseHTTPMiddleware): return response app.add_middleware(MediaCacheMiddleware) -app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4) app.add_middleware(GZipMiddleware, minimum_size=1000) diff --git a/backend/requirements.txt b/backend/requirements.txt index 414ec32..c4e830c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,4 +15,3 @@ odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 python-dateutil>=2.9 -brotli-asgi==1.4.0 diff --git a/backend/routes/admin.py b/backend/routes/admin.py index c2ffebb..cd3fee1 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -356,15 +356,11 @@ async def list_users( # ------------------------------------------------------------------ @router.patch("/users/{uid}") async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)): - # Rollenwechsel + Privileg-Flags nur für Admins + # Rollenwechsel nur für Admins if data.rolle is not None and user["rolle"] != "admin": raise HTTPException(403, "Rollenwechsel nur für Admins.") if data.rolle and data.rolle not in ("user", "moderator", "admin"): raise HTTPException(400, "Ungültige Rolle.") - if data.is_moderator is not None and user["rolle"] != "admin": - raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.") - if data.is_social_media is not None and user["rolle"] != "admin": - raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.") with db() as conn: target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone() diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py index bde0986..d742ccc 100644 --- a/backend/routes/adoption.py +++ b/backend/routes/adoption.py @@ -13,17 +13,10 @@ import os import math import logging import asyncio -import uuid import httpx from datetime import datetime, timedelta -from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException -from pydantic import BaseModel -from typing import Optional +from fastapi import APIRouter, Query, BackgroundTasks from database import db -from auth import get_current_user -from routes.push import send_push_to_user - -MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") logger = logging.getLogger(__name__) router = APIRouter() @@ -297,251 +290,3 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): except Exception as e: logger.warning(f"Geocode PLZ {plz}: {e}") return {"lat": None, "lon": None, "display": plz} - - -# ================================================================== -# Community Adoption — Privates Weitervermittlungs-Board -# ================================================================== - -class InterestBody(BaseModel): - nachricht: Optional[str] = None - - -# ------------------------------------------------------------------ -# GET /api/adoption/community/my — eigene Inserate -# ------------------------------------------------------------------ -@router.get("/community/my") -def community_my(user=Depends(get_current_user)): - with db() as conn: - rows = conn.execute(""" - SELECT ca.*, - u.name AS besitzer_name, - (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count - FROM community_adoption ca - JOIN users u ON u.id = ca.user_id - WHERE ca.user_id = ? AND ca.status != 'deleted' - ORDER BY ca.created_at DESC - """, (user["id"],)).fetchall() - return [dict(r) for r in rows] - - -# ------------------------------------------------------------------ -# GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe) -# ------------------------------------------------------------------ -@router.get("/community") -def community_list( - lat: Optional[float] = Query(None), - lon: Optional[float] = Query(None), - radius: float = Query(200.0, description="Radius in km (default 200)"), - user=Depends(get_current_user), -): - with db() as conn: - rows = conn.execute(""" - SELECT ca.*, - u.name AS besitzer_name, - (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count, - (SELECT COUNT(*) FROM community_adoption_interest i2 - WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested - FROM community_adoption ca - JOIN users u ON u.id = ca.user_id - WHERE ca.status = 'active' - ORDER BY ca.created_at DESC - LIMIT 50 - """, (user["id"],)).fetchall() - - result = [] - for row in rows: - d = dict(row) - d["user_interested"] = bool(d.pop("_user_interested", 0)) - if lat is not None and lon is not None and d.get("lat") and d.get("lon"): - dist = _haversine(lat, lon, d["lat"], d["lon"]) - d["distanz_km"] = round(dist, 1) - if dist > radius: - continue - else: - d["distanz_km"] = None - result.append(d) - - if lat is not None and lon is not None: - result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999) - - return result - - -# ------------------------------------------------------------------ -# POST /api/adoption/community — Inserat erstellen -# ------------------------------------------------------------------ -@router.post("/community", status_code=201) -async def community_create( - name: str = Form(...), - beschreibung: str = Form(...), - rasse: str = Form(""), - alter_jahre: Optional[float] = Form(None), - geschlecht: str = Form(""), - gruende: str = Form(""), - ort: str = Form(""), - plz: str = Form(""), - lat: Optional[float] = Form(None), - lon: Optional[float] = Form(None), - dog_id: Optional[int] = Form(None), - foto: Optional[UploadFile] = File(None), - user=Depends(get_current_user), -): - foto_url = None - - if foto and foto.filename: - MAX_SIZE = 5 * 1024 * 1024 - header = await foto.read(12) - if len(header) < 3: - raise HTTPException(400, "Ungültige Datei") - is_jpeg = header[:3] == b"\xff\xd8\xff" - is_png = header[:4] == b"\x89PNG" - is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP" - if not (is_jpeg or is_png or is_webp): - raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt") - rest = await foto.read(MAX_SIZE) - if len(rest) >= MAX_SIZE: - raise HTTPException(400, "Foto zu groß (max 5 MB)") - data = header + rest - - folder = os.path.join(MEDIA_DIR, "adoption") - os.makedirs(folder, exist_ok=True) - filename = f"{uuid.uuid4()}.jpg" - filepath = os.path.join(folder, filename) - with open(filepath, "wb") as f: - f.write(data) - foto_url = f"/media/adoption/{filename}" - - with db() as conn: - cur = conn.execute(""" - INSERT INTO community_adoption - (user_id, dog_id, name, rasse, alter_jahre, geschlecht, - foto_url, beschreibung, gruende, ort, plz, lat, lon) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - user["id"], dog_id, name, rasse or None, alter_jahre, - geschlecht or None, foto_url, beschreibung, - gruende or None, ort or None, plz or None, lat, lon, - )) - new_id = cur.lastrowid - row = conn.execute( - "SELECT * FROM community_adoption WHERE id = ?", (new_id,) - ).fetchone() - return dict(row) - - -# ------------------------------------------------------------------ -# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) -# ------------------------------------------------------------------ -class _StatusBody(BaseModel): - status: str - -@router.patch("/community/{listing_id}") -def community_update_status( - listing_id: int, - body: _StatusBody, - user=Depends(get_current_user), -): - allowed = {"active", "reserved", "vermittelt"} - if body.status not in allowed: - raise HTTPException(400, f"Status muss einer von {allowed} sein") - status = body.status - with db() as conn: - cur = conn.execute(""" - UPDATE community_adoption - SET status = ?, updated_at = datetime('now') - WHERE id = ? AND user_id = ? - """, (status, listing_id, user["id"])) - if cur.rowcount == 0: - raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") - return {"ok": True} - - -# ------------------------------------------------------------------ -# DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer) -# ------------------------------------------------------------------ -@router.delete("/community/{listing_id}") -def community_delete(listing_id: int, user=Depends(get_current_user)): - with db() as conn: - cur = conn.execute(""" - UPDATE community_adoption - SET status = 'deleted', updated_at = datetime('now') - WHERE id = ? AND user_id = ? - """, (listing_id, user["id"])) - if cur.rowcount == 0: - raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") - return {"ok": True} - - -# ------------------------------------------------------------------ -# POST /api/adoption/community/{id}/interest — Interesse bekunden -# ------------------------------------------------------------------ -@router.post("/community/{listing_id}/interest", status_code=201) -def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)): - nachricht = (body.nachricht if body else None) or None - with db() as conn: - listing = conn.execute( - "SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", - (listing_id,) - ).fetchone() - if not listing: - raise HTTPException(404, "Inserat nicht gefunden") - if listing["user_id"] == user["id"]: - raise HTTPException(400, "Eigenes Inserat") - try: - conn.execute(""" - INSERT INTO community_adoption_interest (listing_id, user_id, nachricht) - VALUES (?, ?, ?) - """, (listing_id, user["id"], nachricht)) - except Exception: - raise HTTPException(409, "Interesse bereits bekundet") - - try: - send_push_to_user(listing["user_id"], { - "title": "Jemand interessiert sich für deinen Hund \U0001f43e", - "body": f"{user['name']} möchte mehr über {listing['name']} erfahren.", - "url": "/#adoption", - }) - except Exception as e: - logger.warning(f"Push interest: {e}") - - return {"ok": True} - - -# ------------------------------------------------------------------ -# DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen -# ------------------------------------------------------------------ -@router.delete("/community/{listing_id}/interest") -def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)): - with db() as conn: - cur = conn.execute(""" - DELETE FROM community_adoption_interest - WHERE listing_id = ? AND user_id = ? - """, (listing_id, user["id"])) - if cur.rowcount == 0: - raise HTTPException(404, "Kein Interesse gefunden") - return {"ok": True} - - -# ------------------------------------------------------------------ -# GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer) -# ------------------------------------------------------------------ -@router.get("/community/{listing_id}/interests") -def community_interests(listing_id: int, user=Depends(get_current_user)): - with db() as conn: - listing = conn.execute( - "SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", - (listing_id,) - ).fetchone() - if not listing: - raise HTTPException(404, "Inserat nicht gefunden") - if listing["user_id"] != user["id"]: - raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen") - rows = conn.execute(""" - SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url - FROM community_adoption_interest i - JOIN users u ON u.id = i.user_id - WHERE i.listing_id = ? - ORDER BY i.created_at ASC - """, (listing_id,)).fetchall() - return [dict(r) for r in rows] diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 4772ae6..13d857d 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -26,14 +26,12 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P def _send_verification_email(email: str, name: str, token: str): if not _SMTP_READY: return - import html as _html from routes.outreach import _send_smtp from mailer import email_html url = f"{_APP_URL}/api/auth/verify-email/{token}" subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" - _ename = _html.escape(name) body_html = f""" -

Hallo {_ename},

+

Hallo {name},

willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.

@@ -308,15 +306,13 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", (token, expires, user["id"]) ) - import html as _html app_url = os.getenv("APP_URL", "https://banyaro.app") url = f"{app_url}/#reset-password?token={token}" subject = "Ban Yaro — Passwort zurücksetzen" from routes.outreach import _send_smtp from mailer import email_html - _ename = _html.escape(user['name']) body_html = f""" -

Hallo {_ename},

+

Hallo {user['name']},

du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.

diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index a44faa0..c7a9066 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -315,13 +315,11 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user values = list(fields.values()) + [dog_id, user["id"]] with db() as conn: - updated = conn.execute( + conn.execute( f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values - ).rowcount - if not updated: - raise HTTPException(404, "Hund nicht gefunden.") + ) dog = conn.execute( - "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + "SELECT * FROM dogs WHERE id=?", (dog_id,) ).fetchone() return dict(dog) @@ -415,8 +413,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)): os.remove(path) with db() as conn: conn.execute( - "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?", - (dog_id, user["id"]) + "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?", + (dog_id,) ) diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 2834ab0..fe730d5 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -641,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c # GET /api/forum/members/map # ------------------------------------------------------------------ @router.get("/members/map") -async def members_map(user=Depends(get_current_user)): +async def members_map(): with db() as conn: rows = conn.execute( """SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname, diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py index 59c73c2..8714ae2 100644 --- a/backend/routes/jobs.py +++ b/backend/routes/jobs.py @@ -1,6 +1,5 @@ """BAN YARO — Social-Media-Job Bewerbungs-System""" -import html as _html import os import uuid from datetime import datetime, timedelta @@ -99,9 +98,8 @@ async def apply( # Bestätigungs-Mail an Bewerber try: - _name = _html.escape(name) body = f""" -

Hallo {_name},

+

Hallo {name},

deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen. Wir melden uns bald bei dir! @@ -112,7 +110,7 @@ async def apply( email, "Deine Bewerbung bei Ban Yaro 🐾", email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), - f"Hallo {_name}, deine Bewerbung ist eingegangen!", + f"Hallo {name}, deine Bewerbung ist eingegangen!", ) except Exception: pass @@ -121,22 +119,16 @@ async def apply( try: admin_email = os.getenv("ADMIN_EMAIL", "") if admin_email: - _ename = _html.escape(name) - _eemail = _html.escape(email) - _edog_name = _html.escape(dog_name) - _edog_rasse = _html.escape(dog_rasse) - _ehandle = _html.escape(social_handle) - _emotivation = _html.escape(motivation[:300]) admin_body = f"""

Neue Job-Bewerbung eingegangen:

- - - - + + + +
Name{_ename}
E-Mail{_eemail}
Hund{_edog_name} ({_edog_rasse})
Social{_ehandle}
Name{name}
E-Mail{email}
Hund{dog_name} ({dog_rasse})
Social{social_handle}
Anhänge{len([f for f in files if f.filename])} Datei(en)
-

{_emotivation}{"…" if len(motivation)>300 else ""}

""" +

{motivation[:300]}{"…" if len(motivation)>300 else ""}

""" await send_email( admin_email, f"[Banyaro Jobs] Neue Bewerbung — {name}", @@ -301,17 +293,16 @@ async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)): def _send_status_mail(email: str, name: str, status: str, note: str): import asyncio - _ename = _html.escape(name) texts = { "reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾", - f"

Hallo {_ename},

wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

"), + f"

Hallo {name},

wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

"), "accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉", - f"

Hallo {_ename},

wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

"), + f"

Hallo {name},

wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

"), "rejected": ("Deine Bewerbung bei Ban Yaro", - f"

Hallo {_ename},

vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

"), + f"

Hallo {name},

vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

"), } - subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

Hallo {_ename},

")) - note_html = f'
{_html.escape(note)}
' if note else "" + subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

Hallo {name},

")) + note_html = f'
{note}
' if note else "" body = body_start + note_html async def _send(): diff --git a/backend/routes/litters.py b/backend/routes/litters.py index ddc810c..82ba96f 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -265,14 +265,13 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): eltern = conn.execute( "SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,) ).fetchone() - import html as _html welfare_body = f"""

Kritischer Tierschutz-Hinweis bestätigt

- - - - + + + +
Züchter{_html.escape(zuechter)}
Zwinger{_html.escape(zwinger)}
Vater{_html.escape(eltern['vater_name'] or '—')}
Mutter{_html.escape(eltern['mutter_name'] or '—')}
Züchter{zuechter}
Zwinger{zwinger}
Vater{eltern['vater_name'] or '—'}
Mutter{eltern['mutter_name'] or '—'}
Wurf-ID#{litter_id}
""" try: diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index fa74871..1357a85 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -268,9 +268,6 @@ async def mod_poi_edit_action(edit_id: int, data: dict, raise HTTPException(409, "Korrektur wurde bereits bearbeitet.") if action == "approve": - _ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"} - if edit["field"] not in _ALLOWED_POI_FIELDS: - raise HTTPException(400, f"Ungültiges Feld: {edit['field']}") conn.execute( f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?", (edit["new_value"], edit["osm_id"]) diff --git a/backend/routes/streak.py b/backend/routes/streak.py index c387a68..ea03522 100644 --- a/backend/routes/streak.py +++ b/backend/routes/streak.py @@ -29,7 +29,7 @@ async def get_leaderboard(user=Depends(get_current_user)): JOIN dogs d ON d.id = ts.dog_id JOIN users u ON u.id = ts.user_id WHERE ts.current_streak > 0 - AND d.is_public = 1 + AND (d.is_public = 1 OR d.user_id = ts.user_id) ORDER BY ts.current_streak DESC LIMIT 10 """).fetchall() diff --git a/backend/routes/weather.py b/backend/routes/weather.py index fced719..319cfd2 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,9 +3,8 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ -from fastapi import APIRouter, Query, HTTPException, Depends +from fastapi import APIRouter, Query, HTTPException import weather as weather_module -from auth import get_current_user router = APIRouter() @@ -19,15 +18,3 @@ async def get_weather( return await weather_module.get_weather_for_location(lat, lon) except Exception as exc: raise HTTPException(503, f'Wetter nicht verfügbar: {exc}') - - -@router.get('/forecast') -async def get_weather_forecast( - lat: float = Query(..., ge=-90, le=90), - lon: float = Query(..., ge=-180, le=180), - user=Depends(get_current_user), -): - try: - return await weather_module.get_forecast(lat, lon) - except Exception as exc: - raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}') diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index 45f5bfb..bf3c19c 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -317,24 +317,19 @@ async def submit_foto( if not rights_confirmed: raise HTTPException(400, "Bildrechte-Bestätigung fehlt.") - _IMAGE_MAGIC = [ - b"\xff\xd8\xff", # JPEG - b"\x89PNG\r\n\x1a\n", # PNG - b"RIFF", # WebP (RIFF....WEBP) - b"GIF87a", b"GIF89a", # GIF - ] + # Dateiformat prüfen + ct = file.content_type or "" + if not ct.startswith("image/"): + raise HTTPException(400, "Nur Bilddateien erlaubt.") os.makedirs(SUBMIT_DIR, exist_ok=True) - ts = int(time.time()) + ts = int(time.time()) + filename = f"{slug}_{user['id']}_{ts}.jpg" + path = os.path.join(SUBMIT_DIR, filename) + content = await file.read() if len(content) > 8 * 1024 * 1024: raise HTTPException(400, "Datei zu groß (max. 8 MB).") - - if not any(content.startswith(magic) for magic in _IMAGE_MAGIC): - raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).") - - filename = f"{slug}_{user['id']}_{ts}.jpg" - path = os.path.join(SUBMIT_DIR, filename) with open(path, "wb") as f: f.write(content) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 27cf0d9..fa7f4e7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7592,18 +7592,9 @@ svg.empty-state-icon { font-size: 9px; font-weight: 800; letter-spacing: 0.12em; - color: white; + color: var(--c-text-secondary); opacity: 0.4; text-transform: uppercase; - transition: opacity 0.18s; -} -.wlabel.active { opacity: 1; } - -@media (min-width: 768px) { - #world-labels { gap: 48px; font-size: 11px; } - .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; } - .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); } - .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); } } /* Settings-Button */ diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 2b8028e..a9189b9 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -270,1361 +270,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index cb75a8f..5e77ed9 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index c6b26da..a40b99d 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -6,84 +6,69 @@ const API = (() => { - // ---------------------------------------------------------- - // Request-Deduplication: gleiche GET-URL nur einmal in-flight - // ---------------------------------------------------------- - const _inflight = new Map(); - // ---------------------------------------------------------- // Interner HTTP-Kern // ---------------------------------------------------------- - async function _doRequest(method, path, body, attempt) { + async function _request(method, path, body = null, options = {}) { const config = { method, headers: { 'Content-Type': 'application/json' }, - credentials: 'include', + credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet }; if (body && !(body instanceof FormData)) { config.body = JSON.stringify(body); } else if (body instanceof FormData) { - delete config.headers['Content-Type']; + delete config.headers['Content-Type']; // Browser setzt multipart/form-data config.body = body; } + // JWT aus localStorage als Bearer (für API-Calls die das brauchen) const token = localStorage.getItem('by_token'); - if (token) config.headers['Authorization'] = `Bearer ${token}`; + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } let response; try { response = await fetch(`/api${path}`, config); - } catch { - // Netzwerkfehler: bei GET bis zu 2 Retry-Versuche - if (method === 'GET' && attempt < 2) { - await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); - return _doRequest(method, path, body, attempt + 1); - } - const msg = 'Kein Internet — du bist offline.'; - if (window.UI?.toast) UI.toast.warning(msg, 4000); - throw new APIError(msg, 0, 'network'); + } catch (err) { + const offlineMsg = 'Kein Internet — du bist offline.'; + if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000); + throw new APIError(offlineMsg, 0, 'network'); } + // 204 No Content if (response.status === 204) return null; let data; - try { data = await response.json(); } catch { data = null; } + try { + data = await response.json(); + } catch { + data = null; + } if (!response.ok) { const message = data?.detail || data?.message || `Fehler ${response.status}`; - const isSwOffline = response.status === 503 && message.startsWith('Offline'); - - // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) - if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) { - await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); - return _doRequest(method, path, body, attempt + 1); + // SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück + const isOffline = response.status === 503 && message.startsWith('Offline'); + if (isOffline && window.UI && UI.toast) { + UI.toast.warning('Kein Internet — du bist offline.', 4000); } - - if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000); - throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code); + throw new APIError(message, response.status, isOffline ? 'network' : data?.code); } + // SW hat die Anfrage in die Offline-Queue eingereiht if (data?._queued) { - if (typeof UI !== 'undefined' && UI.toast) + if (typeof UI !== 'undefined' && UI.toast) { UI.toast.info('Offline gespeichert — wird automatisch synchronisiert'); + } return data; } return data; } - async function _request(method, path, body = null) { - // GET-Deduplication: laufende identische Anfragen zusammenfassen - if (method === 'GET') { - if (_inflight.has(path)) return _inflight.get(path); - const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path)); - _inflight.set(path, promise); - return promise; - } - return _doRequest(method, path, body, 0); - } - // ---------------------------------------------------------- // Öffentliche HTTP-Methoden // ---------------------------------------------------------- @@ -441,9 +426,8 @@ const API = (() => { // WETTER // ---------------------------------------------------------- const weather = { - alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, - get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, - forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); }, + alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, + get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a248787..003f1b5 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 = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '650'; // ← 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/js/pages/adoption.js b/backend/static/js/pages/adoption.js index b20682e..8e1bc3a 100644 --- a/backend/static/js/pages/adoption.js +++ b/backend/static/js/pages/adoption.js @@ -17,8 +17,6 @@ window.Page_adoption = (() => { let _activeTab = 'hunde'; let _data = null; // { animals, shelters, has_petfinder } let _loading = false; - let _communityData = null; // [] listings from /adoption/community - let _myListings = null; // [] eigene Inserate // ---------------------------------------------------------- // INIT @@ -92,12 +90,6 @@ window.Page_adoption = (() => { border-bottom:2px solid transparent;font-size:var(--text-sm)"> ${UI.icon('house-line')} Tierheime - @@ -221,43 +213,12 @@ window.Page_adoption = (() => { } } - async function _loadCommunity() { - const content = _container.querySelector('#adp-content'); - if (content) content.innerHTML = UI.skeleton(4); - try { - const url = _lat && _lon - ? `/adoption/community?lat=${_lat}&lon=${_lon}` - : '/adoption/community'; - _communityData = await API.get(url); - if (_appState?.user) { - try { - _myListings = await API.get('/adoption/community/my'); - } catch { - _myListings = []; - } - } - _renderCommunity(content); - } catch { - if (content) content.innerHTML = UI.emptyState({ - icon: 'warning', - title: 'Weitervermittlungs-Inserate konnten nicht geladen werden', - text: 'Bitte versuche es erneut.', - }); - } - } - // ---------------------------------------------------------- // INHALT RENDERN (je nach Tab) // ---------------------------------------------------------- function _renderContent() { const content = _container.querySelector('#adp-content'); if (!content) return; - - if (_activeTab === 'community') { - _loadCommunity(); - return; - } - if (!_data) { _showNoLocation(); return; } if (_activeTab === 'hunde') _renderHunde(content); @@ -494,442 +455,6 @@ window.Page_adoption = (() => { `; } - // ------------------------------------------------------------------ - // TAB: WEITERVERMITTLUNG (Community) - // ------------------------------------------------------------------ - function _renderCommunity(content) { - if (!content) return; - - const listings = _communityData || []; - const isLoggedIn = !!_appState?.user; - - const fabHtml = isLoggedIn ? ` - - ` : ''; - - if (!listings.length) { - content.innerHTML = ` -
-
🐾
-

Noch keine Hunde zur Weitervermittlung

-

- Hier können Halter Hunde privat zur Weitervermittlung anbieten — - zum Beispiel bei Umzug, Krankheit oder Allergie. -

- ${isLoggedIn ? ` - - ` : ` -

- Bitte anmelden, um ein Inserat zu erstellen. -

- `} -
- ${fabHtml} - `; - content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal); - content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); - return; - } - - // Eigene Inserate trennen - const myIds = new Set((_myListings || []).map(l => l.id)); - - content.innerHTML = ` -

- ${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung -

-
- ${listings.map(l => _communityCard(l)).join('')} -
- - ${isLoggedIn && _myListings && _myListings.length ? ` -
-

Meine Inserate

-
- ${_myListings.map(l => _myListingRow(l)).join('')} -
-
- ` : ''} - - ${fabHtml} - `; - - // Interest-Button Events - content.querySelectorAll('[data-adp-interest]').forEach(btn => { - btn.addEventListener('click', () => { - const id = btn.dataset.adpInterest; - const interested = btn.dataset.adpInterested === 'true'; - _handleInterest(id, interested, btn); - }); - }); - - // FAB - content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); - - // Meine Inserate: Status-Dropdown + Löschen - content.querySelectorAll('[data-adp-status-change]').forEach(sel => { - sel.addEventListener('change', async () => { - const id = sel.dataset.adpStatusChange; - try { - await API.patch(`/adoption/community/${id}`, { status: sel.value }); - UI.toast.success('Status aktualisiert.'); - _loadCommunity(); - } catch { - UI.toast.error('Status konnte nicht aktualisiert werden.'); - } - }); - }); - - content.querySelectorAll('[data-adp-delete]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!window.confirm('Inserat wirklich löschen?')) return; - try { - await API.del(`/adoption/community/${btn.dataset.adpDelete}`); - UI.toast.success('Inserat gelöscht.'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch { - UI.toast.error('Löschen fehlgeschlagen.'); - } - }); - }); - } - - function _communityCard(l) { - const foto = l.foto_url - ? `${_esc(l.name)}` - : '
🐾
'; - - const isActive = !l.status || l.status === 'active'; - const statusLabel = l.status === 'reserved' ? 'Reserviert' - : l.status === 'adopted' ? 'Vermittelt' - : ''; - - const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo' - : l.alter_kategorie === 'jung' ? 'Jung 6Mo–2J' - : l.alter_kategorie === 'adult' ? 'Adult 2–8J' - : l.alter_kategorie === 'senior' ? 'Senior >8J' - : ''; - - const genderIcon = l.geschlecht === 'maennlich' ? '♂' - : l.geschlecht === 'weiblich' ? '♀' - : ''; - - const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : ''; - const ort = [l.plz, l.ort].filter(Boolean).join(' '); - - const interestBtn = l.user_interested - ? `` - : ``; - - return ` -
- -
- ${foto} - ${!isActive ? ` -
- - ${_esc(statusLabel)} - -
- ` : ''} -
- -
-
- ${_esc(l.name)} -
- ${l.rasse ? `
- ${_esc(l.rasse)} -
` : ''} - -
- ${alterLabel ? ` - ${_esc(alterLabel)} - ` : ''} - ${genderIcon ? ` - ${genderIcon} - ` : ''} - ${distTxt ? ` - ${_esc(distTxt)} - ` : ''} -
- ${ort ? `
${_esc(ort)}
` : ''} - ${l.beschreibung ? `
- ${_esc(l.beschreibung)} -
` : ''} - ${l.interesse_count ? `
- ❤️ ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''} -
` : ''} -
- ${interestBtn} -
-
-
- `; - } - - function _myListingRow(l) { - const statusOptions = [ - { value: 'active', label: 'Aktiv' }, - { value: 'reserved', label: 'Reserviert' }, - { value: 'adopted', label: 'Vermittelt' }, - ]; - return ` -
-
-
- ${_esc(l.name)} -
-
- ${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''} -
-
- - -
- `; - } - - // ------------------------------------------------------------------ - // INTERESSE BEKUNDEN / ZURÜCKZIEHEN - // ------------------------------------------------------------------ - async function _handleInterest(id, isInterested, btn) { - if (!_appState?.user) { - UI.toast.error('Bitte anmelden um Interesse zu bekunden.'); - return; - } - - if (isInterested) { - // Interesse zurückziehen - try { - btn.disabled = true; - await API.del(`/adoption/community/${id}/interest`); - UI.toast.success('Interesse zurückgezogen.'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch { - UI.toast.error('Fehler beim Zurückziehen des Interesses.'); - btn.disabled = false; - } - return; - } - - // Interesse bekunden — Modal mit optionaler Nachricht - const body = ` -
-

- Du kannst optional eine Nachricht an den Anbieter schicken. -

-
- - -
-
- `; - const footer = ` -
- - -
- `; - - UI.modal.open({ title: 'Interesse bekunden', body, footer }); - - document.getElementById('adp-interest-form')?.addEventListener('submit', async e => { - e.preventDefault(); - const submitBtn = document.getElementById('adp-interest-submit'); - const fd = new FormData(e.target); - const payload = { nachricht: fd.get('nachricht') || null }; - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } - try { - await API.post(`/adoption/community/${id}/interest`, payload); - UI.modal.close(); - UI.toast.success('Interesse gemeldet!'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch { - UI.toast.error('Fehler beim Melden des Interesses.'); - if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; } - } - }); - } - - // ------------------------------------------------------------------ - // INSERAT ERSTELLEN — Modal - // ------------------------------------------------------------------ - function _openCreateModal() { - if (!_appState?.user) { - UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.'); - return; - } - - const body = ` -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - -
Mindestens 80 Zeichen
-
-
- - -
-
- - -
-
- `; - - const footer = ` -
- - -
- `; - - UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer }); - - document.getElementById('adp-create-form')?.addEventListener('submit', async e => { - e.preventDefault(); - const submitBtn = document.getElementById('adp-create-submit'); - const fd = new FormData(e.target); - - // Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text) - const beschreibung = (fd.get('beschreibung') || '').trim(); - if (beschreibung.length < 80) { - UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.'); - return; - } - - // FormData für multipart aufbauen - const postData = new FormData(); - postData.append('name', fd.get('name') || ''); - postData.append('rasse', fd.get('rasse') || ''); - postData.append('alter_kategorie', fd.get('alter_kategorie') || ''); - postData.append('geschlecht', fd.get('geschlecht') || ''); - postData.append('plz', fd.get('plz') || ''); - postData.append('ort', fd.get('ort') || ''); - postData.append('beschreibung', beschreibung); - postData.append('hintergrund', fd.get('hintergrund') || ''); - if (_lat) postData.append('lat', _lat); - if (_lon) postData.append('lon', _lon); - const fotoFile = document.getElementById('adp-create-foto')?.files?.[0]; - if (fotoFile) postData.append('foto', fotoFile); - - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } - try { - await API.upload('/adoption/community', postData); - UI.modal.close(); - UI.toast.success('Inserat erstellt!'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch (err) { - UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.'); - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`; - } - } - }); - } - // ---------------------------------------------------------- // HILFSFUNKTIONEN // ---------------------------------------------------------- diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8829bb6..f8488b6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -263,12 +263,6 @@ window.Page_settings = (() => { Kalender abonnieren -