banyaro/backend/routes/chat.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +02:00

287 lines
10 KiB
Python

"""BAN YARO — Direktnachrichten"""
import logging
import os
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
CHAT_DIR = os.path.join(MEDIA_DIR, "chat")
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
ONLINE_MINUTES = 3
def _conv_key(a: int, b: int):
"""Normalisiert Konversations-User-IDs: user_a < user_b."""
return (min(a, b), max(a, b))
@router.get("/conversations")
async def list_conversations(user=Depends(get_current_user)):
uid = user["id"]
online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat()
with db() as conn:
rows = conn.execute("""
SELECT c.id, c.last_msg_at,
CASE WHEN c.user_a_id=? THEN c.user_b_id ELSE c.user_a_id END AS partner_id,
CASE WHEN c.user_a_id=? THEN ub.name ELSE ua.name END AS partner_name,
CASE WHEN c.user_a_id=? THEN ub.last_seen ELSE ua.last_seen END AS partner_last_seen,
(SELECT text FROM direct_messages
WHERE conversation_id=c.id AND is_deleted=0
ORDER BY created_at DESC LIMIT 1) AS last_text,
(SELECT COUNT(*) FROM direct_messages
WHERE conversation_id=c.id
AND sender_id != ?
AND is_deleted=0
AND created_at > COALESCE(
CASE WHEN c.user_a_id=? THEN c.a_read_at ELSE c.b_read_at END,
'1970-01-01'
)
) AS unread_count
FROM conversations c
JOIN users ua ON ua.id=c.user_a_id
JOIN users ub ON ub.id=c.user_b_id
WHERE c.user_a_id=? OR c.user_b_id=?
ORDER BY COALESCE(c.last_msg_at, c.created_at) DESC
""", (uid, uid, uid, uid, uid, uid, uid)).fetchall()
result = []
for r in rows:
d = dict(r)
last_seen = d.get("partner_last_seen")
d["partner_online"] = bool(last_seen and last_seen >= online_cutoff)
result.append(d)
return result
class StartConvModel(BaseModel):
partner_id: int
@router.post("/conversations", status_code=201)
async def start_conversation(data: StartConvModel, user=Depends(get_current_user)):
uid = user["id"]
if uid == data.partner_id:
raise HTTPException(400, "Du kannst dir selbst keine Nachrichten schicken.")
a, b = _conv_key(uid, data.partner_id)
with db() as conn:
f = conn.execute("""
SELECT 1 FROM friendships
WHERE ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
AND status='accepted'
""", (uid, data.partner_id, data.partner_id, uid)).fetchone()
if not f:
raise HTTPException(403, "Ihr seid noch keine Freunde.")
existing = conn.execute(
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?", (a, b)
).fetchone()
if existing:
return {"conversation_id": existing["id"]}
cur = conn.execute(
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)", (a, b)
)
return {"conversation_id": cur.lastrowid}
@router.get("/conversations/{conv_id}")
async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
user=Depends(get_current_user)):
uid = user["id"]
online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat()
now_iso = datetime.utcnow().isoformat()
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
partner = conn.execute(
"SELECT name, last_seen FROM users WHERE id=?", (partner_id,)
).fetchone()
# Ungelesene Nachrichten des Partners als gelesen markieren
conn.execute("""
UPDATE direct_messages
SET read_at=?
WHERE conversation_id=? AND sender_id!=? AND read_at IS NULL
""", (now_iso, conv_id, uid))
msgs = conn.execute("""
SELECT m.id, m.sender_id, m.text, m.is_deleted, m.created_at,
m.media_url, m.media_type, m.read_at,
u.name AS sender_name
FROM direct_messages m
JOIN users u ON u.id=m.sender_id
WHERE m.conversation_id=?
ORDER BY m.created_at ASC
LIMIT ? OFFSET ?
""", (conv_id, limit, offset)).fetchall()
partner_last_seen = partner["last_seen"] if partner else None
partner_online = bool(partner_last_seen and partner_last_seen >= online_cutoff)
return {
"conversation_id": conv_id,
"partner_id": partner_id,
"partner_name": partner["name"] if partner else "Unbekannt",
"partner_online": partner_online,
"messages": [dict(m) for m in msgs],
}
class SendMsgModel(BaseModel):
text: str = Field(..., min_length=1, max_length=2000)
@router.post("/conversations/{conv_id}/messages", status_code=201)
async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_current_user)):
uid = user["id"]
text = data.text.strip()
if not text:
raise HTTPException(400, "Nachricht darf nicht leer sein.")
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
cur = conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text) VALUES (?,?,?)
""", (conv_id, uid, text))
msg_id = cur.lastrowid
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
preview = text[:100] + ("" if len(text) > 100 else "")
send_push_to_user(partner_id, {
"title": f"Nachricht von {user['name']}",
"body": preview,
"type": "chat_message",
"tag": f"chat-{conv_id}",
"data": {"page": "chat", "conversation_id": conv_id},
})
except Exception:
pass
return {"id": msg_id, "ok": True}
@router.post("/conversations/{conv_id}/upload", status_code=201)
async def upload_photo(conv_id: int, file: UploadFile = File(...),
user=Depends(get_current_user)):
uid = user["id"]
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(400, "Nur JPEG, PNG, GIF und WebP erlaubt.")
data = await file.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(400, "Datei zu groß (max. 10 MB).")
os.makedirs(CHAT_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(CHAT_DIR, filename)
with open(path, "wb") as f:
f.write(data)
media_url = f"/media/chat/{filename}"
media_type = file.content_type
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
cur = conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text, media_url, media_type)
VALUES (?,?,?,?,?)
""", (conv_id, uid, "", media_url, media_type))
msg_id = cur.lastrowid
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
send_push_to_user(partner_id, {
"title": f"Foto von {user['name']}",
"body": "hat dir ein Foto geschickt",
"type": "chat_message",
"tag": f"chat-{conv_id}",
"data": {"page": "chat", "conversation_id": conv_id},
})
except Exception:
pass
return {"id": msg_id, "media_url": media_url, "media_type": media_type, "ok": True}
@router.post("/heartbeat")
async def heartbeat(user=Depends(get_current_user)):
uid = user["id"]
now_iso = datetime.utcnow().isoformat()
with db() as conn:
conn.execute("UPDATE users SET last_seen=? WHERE id=?", (now_iso, uid))
return {"ok": True}
@router.post("/conversations/{conv_id}/read")
async def mark_read(conv_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404)
field = "a_read_at" if conv["user_a_id"] == uid else "b_read_at"
conn.execute(
f"UPDATE conversations SET {field}=datetime('now') WHERE id=?",
(conv_id,)
)
return {"ok": True}
@router.delete("/messages/{msg_id}")
async def delete_message(msg_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
msg = conn.execute(
"SELECT id FROM direct_messages WHERE id=? AND sender_id=?", (msg_id, uid)
).fetchone()
if not msg:
raise HTTPException(404, "Nachricht nicht gefunden.")
conn.execute(
"UPDATE direct_messages SET is_deleted=1, text='[gelöscht]' WHERE id=?",
(msg_id,)
)
return {"ok": True}