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
287 lines
10 KiB
Python
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}
|