Initial commit: AegisSight-Monitor (OSINT-Monitoringsystem)

Dieser Commit ist enthalten in:
claude-dev
2026-03-04 17:53:18 +01:00
Commit 8312d24912
51 geänderte Dateien mit 19355 neuen und 0 gelöschten Zeilen

276
src/routers/auth.py Normale Datei
Datei anzeigen

@@ -0,0 +1,276 @@
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from models import (
MagicLinkRequest,
MagicLinkResponse,
VerifyTokenRequest,
VerifyCodeRequest,
TokenResponse,
UserMeResponse,
)
from auth import (
create_token,
get_current_user,
generate_magic_token,
generate_magic_code,
verify_password,
)
from database import db_dependency
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
from email_utils.sender import send_email
from email_utils.templates import magic_link_login_email
from email_utils.rate_limiter import magic_link_limiter, verify_code_limiter
import aiosqlite
logger = logging.getLogger("osint.auth")
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/magic-link", response_model=MagicLinkResponse)
async def request_magic_link(
data: MagicLinkRequest,
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic Link anfordern. Sendet E-Mail mit Link + Code."""
email = data.email.lower().strip()
ip = request.client.host if request.client else "unknown"
# Rate-Limit pruefen
allowed, reason = magic_link_limiter.check(email, ip)
if not allowed:
logger.warning(f"Rate-Limit fuer {email} von {ip}: {reason}")
# Trotzdem 200 zurueckgeben (kein Information-Leak)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Nutzer suchen
cursor = await db.execute(
"""SELECT u.id, u.email, u.username, u.role, u.organization_id, u.is_active,
o.is_active as org_active, o.slug as org_slug
FROM users u
JOIN organizations o ON o.id = u.organization_id
WHERE LOWER(u.email) = ?""",
(email,),
)
user = await cursor.fetchone()
if not user:
magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
if not user["is_active"]:
magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
if not user["org_active"]:
magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Lizenz pruefen
from services.license_service import check_license
lic = await check_license(db, user["organization_id"])
if lic.get("status") == "org_disabled":
magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Token + Code generieren
token = generate_magic_token()
code = generate_magic_code()
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
# Alte ungenutzte Magic Links fuer diese E-Mail invalidieren
await db.execute(
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
(email,),
)
# Neuen Magic Link speichern
await db.execute(
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
VALUES (?, ?, ?, 'login', ?, ?, ?)""",
(email, token, code, user["id"], expires_at, ip),
)
await db.commit()
# E-Mail senden
link = f"{MAGIC_LINK_BASE_URL}/auth/verify?token={token}"
subject, html = magic_link_login_email(user["username"], code, link)
await send_email(email, subject, html)
magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
@router.post("/verify", response_model=TokenResponse)
async def verify_magic_link(
data: VerifyTokenRequest,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic Link verifizieren (Token aus URL)."""
cursor = await db.execute(
"""SELECT ml.*, u.username, u.email, u.role, u.organization_id, u.is_active,
o.slug as org_slug, o.is_active as org_active
FROM magic_links ml
JOIN users u ON u.id = ml.user_id
JOIN organizations o ON o.id = u.organization_id
WHERE ml.token = ? AND ml.is_used = 0""",
(data.token,),
)
ml = await cursor.fetchone()
if not ml:
raise HTTPException(status_code=400, detail="Ungueltiger oder bereits verwendeter Link")
# Ablauf pruefen
now = datetime.now(timezone.utc)
expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if now > expires:
raise HTTPException(status_code=400, detail="Link abgelaufen. Bitte neuen Link anfordern.")
if not ml["is_active"] or not ml["org_active"]:
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
# Magic Link als verwendet markieren
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
# Letzten Login aktualisieren
await db.execute(
"UPDATE users SET last_login_at = ? WHERE id = ?",
(now.isoformat(), ml["user_id"]),
)
await db.commit()
# JWT erstellen
token = create_token(
user_id=ml["user_id"],
username=ml["username"],
email=ml["email"],
role=ml["role"],
tenant_id=ml["organization_id"],
org_slug=ml["org_slug"],
)
return TokenResponse(
access_token=token,
username=ml["username"],
)
@router.post("/verify-code", response_model=TokenResponse)
async def verify_magic_code(
data: VerifyCodeRequest,
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic Code verifizieren (6-stelliger Code + E-Mail)."""
email = data.email.lower().strip()
ip = request.client.host if request.client else "unknown"
# Brute-Force-Schutz: Fehlversuche pruefen
allowed, reason = verify_code_limiter.check(email, ip)
if not allowed:
logger.warning(f"Verify-Code Rate-Limit fuer {email} von {ip}: {reason}")
# Bei Sperre alle offenen Magic Links fuer diese E-Mail invalidieren
await db.execute(
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
(email,),
)
await db.commit()
raise HTTPException(status_code=429, detail=reason)
cursor = await db.execute(
"""SELECT ml.*, u.username, u.email as user_email, u.role, u.organization_id, u.is_active,
o.slug as org_slug, o.is_active as org_active
FROM magic_links ml
JOIN users u ON u.id = ml.user_id
JOIN organizations o ON o.id = u.organization_id
WHERE LOWER(ml.email) = ? AND ml.code = ? AND ml.is_used = 0
ORDER BY ml.created_at DESC LIMIT 1""",
(email, data.code),
)
ml = await cursor.fetchone()
if not ml:
verify_code_limiter.record_failure(email, ip)
logger.warning(f"Fehlgeschlagener Code-Versuch fuer {email} von {ip}")
raise HTTPException(status_code=400, detail="Ungueltiger Code")
# Ablauf pruefen
now = datetime.now(timezone.utc)
expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if now > expires:
raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.")
if not ml["is_active"] or not ml["org_active"]:
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
# Magic Link als verwendet markieren
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
# Letzten Login aktualisieren
await db.execute(
"UPDATE users SET last_login_at = ? WHERE id = ?",
(now.isoformat(), ml["user_id"]),
)
await db.commit()
# Fehlversuche-Zaehler nach Erfolg zuruecksetzen
verify_code_limiter.clear(email)
token = create_token(
user_id=ml["user_id"],
username=ml["username"],
email=ml["user_email"],
role=ml["role"],
tenant_id=ml["organization_id"],
org_slug=ml["org_slug"],
)
return TokenResponse(
access_token=token,
username=ml["username"],
)
@router.get("/me", response_model=UserMeResponse)
async def get_me(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aktuellen Nutzer mit Org-Info abfragen."""
# Org-Name laden
org_name = ""
if current_user.get("tenant_id"):
cursor = await db.execute(
"SELECT name FROM organizations WHERE id = ?",
(current_user["tenant_id"],),
)
org = await cursor.fetchone()
if org:
org_name = org["name"]
# Lizenzstatus laden
license_info = {}
if current_user.get("tenant_id"):
from services.license_service import check_license
license_info = await check_license(db, current_user["tenant_id"])
return UserMeResponse(
id=current_user["id"],
username=current_user["username"],
email=current_user.get("email", ""),
role=current_user["role"],
org_name=org_name,
org_slug=current_user.get("org_slug", ""),
tenant_id=current_user.get("tenant_id"),
license_status=license_info.get("status", "unknown"),
license_type=license_info.get("license_type", ""),
read_only=license_info.get("read_only", False),
)

115
src/routers/feedback.py Normale Datei
Datei anzeigen

@@ -0,0 +1,115 @@
"""Feedback-Router: Nutzer-Feedback per E-Mail an das Team."""
import html
import time
import logging
from collections import defaultdict
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import aiosmtplib
from fastapi import APIRouter, Depends, HTTPException, status
from auth import get_current_user
from models import FeedbackRequest
from config import (
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASSWORD,
SMTP_FROM_EMAIL,
SMTP_FROM_NAME,
SMTP_USE_TLS,
)
logger = logging.getLogger("osint.feedback")
router = APIRouter(prefix="/api", tags=["feedback"])
FEEDBACK_EMAIL = "feedback@aegis-sight.de"
CATEGORY_LABELS = {
"bug": "Fehlerbericht",
"feature": "Feature-Wunsch",
"question": "Frage",
"other": "Sonstiges",
}
# In-Memory Rate-Limiting: max 3 pro Nutzer/Stunde
_user_timestamps: dict[int, list[float]] = defaultdict(list)
_MAX_PER_HOUR = 3
_WINDOW = 3600
@router.post("/feedback", status_code=204)
async def send_feedback(
data: FeedbackRequest,
current_user: dict = Depends(get_current_user),
):
"""Feedback per E-Mail an das Team senden."""
user_id = current_user["id"]
# Rate-Limiting
now = time.time()
cutoff = now - _WINDOW
_user_timestamps[user_id] = [t for t in _user_timestamps[user_id] if t > cutoff]
if len(_user_timestamps[user_id]) >= _MAX_PER_HOUR:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Maximal 3 Feedback-Nachrichten pro Stunde. Bitte spaeter erneut versuchen.",
)
if not SMTP_HOST:
logger.warning("SMTP nicht konfiguriert - Feedback nicht gesendet")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="E-Mail-Versand nicht verfuegbar.",
)
username = current_user["username"]
email = current_user.get("email", "")
category_label = CATEGORY_LABELS.get(data.category, data.category)
message_escaped = html.escape(data.message)
subject = f"[AegisSight Feedback] {category_label} von {username}"
html_body = f"""\
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#151D2E;color:#E8ECF4;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="margin:0;color:#C8A851;">Neues Feedback</h2>
</div>
<div style="background:#1A2440;color:#E8ECF4;padding:20px;border-radius:0 0 8px 8px;">
<table style="border-collapse:collapse;">
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">Kategorie:</td><td><strong>{category_label}</strong></td></tr>
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">Nutzer:</td><td>{html.escape(username)}</td></tr>
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">E-Mail:</td><td>{html.escape(email) if email else "nicht hinterlegt"}</td></tr>
</table>
<hr style="border:none;border-top:1px solid #1E2D45;margin:16px 0;">
<div style="white-space:pre-wrap;line-height:1.5;">{message_escaped}</div>
</div>
</div>"""
msg = MIMEMultipart("alternative")
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
msg["To"] = FEEDBACK_EMAIL
msg["Subject"] = subject
if email:
msg["Reply-To"] = email
text_fallback = f"Feedback von {username} ({category_label}):\n\n{data.message}"
msg.attach(MIMEText(text_fallback, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=SMTP_HOST,
port=SMTP_PORT,
username=SMTP_USER if SMTP_USER else None,
password=SMTP_PASSWORD if SMTP_PASSWORD else None,
start_tls=SMTP_USE_TLS,
)
_user_timestamps[user_id].append(now)
logger.info(f"Feedback von {username} ({category_label}) gesendet")
except Exception as e:
logger.error(f"Feedback-E-Mail fehlgeschlagen: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="E-Mail konnte nicht gesendet werden.",
)

636
src/routers/incidents.py Normale Datei
Datei anzeigen

@@ -0,0 +1,636 @@
"""Incidents-Router: Lagen verwalten (Multi-Tenant)."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from models import IncidentCreate, IncidentUpdate, IncidentResponse, SubscriptionUpdate, SubscriptionResponse
from auth import get_current_user
from middleware.license_check import require_writable_license
from database import db_dependency
from datetime import datetime, timezone
import aiosqlite
import json
import re
import unicodedata
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
INCIDENT_UPDATE_COLUMNS = {
"title", "description", "type", "status", "refresh_mode",
"refresh_interval", "retention_days", "international_sources", "visibility",
}
async def _check_incident_access(
db: aiosqlite.Connection, incident_id: int, user_id: int, tenant_id: int
) -> aiosqlite.Row:
"""Lage laden und Zugriff pruefen (Tenant + Sichtbarkeit)."""
cursor = await db.execute(
"SELECT * FROM incidents WHERE id = ? AND tenant_id = ?",
(incident_id, tenant_id),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Lage nicht gefunden")
if row["visibility"] == "private" and row["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Kein Zugriff auf private Lage")
return row
async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict:
"""Incident-Row mit Statistiken und Ersteller-Name anreichern."""
incident = dict(row)
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM articles WHERE incident_id = ?",
(incident["id"],),
)
article_count = (await cursor.fetchone())["cnt"]
cursor = await db.execute(
"SELECT COUNT(DISTINCT source) as cnt FROM articles WHERE incident_id = ?",
(incident["id"],),
)
source_count = (await cursor.fetchone())["cnt"]
cursor = await db.execute(
"SELECT username FROM users WHERE id = ?",
(incident["created_by"],),
)
user_row = await cursor.fetchone()
incident["article_count"] = article_count
incident["source_count"] = source_count
incident["created_by_username"] = user_row["username"] if user_row else "Unbekannt"
return incident
@router.get("", response_model=list[IncidentResponse])
async def list_incidents(
status_filter: str = None,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Lagen des Tenants auflisten (oeffentliche + eigene private)."""
tenant_id = current_user.get("tenant_id")
user_id = current_user["id"]
query = "SELECT * FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
params = [tenant_id, user_id]
if status_filter:
query += " AND status = ?"
params.append(status_filter)
query += " ORDER BY updated_at DESC"
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
results = []
for row in rows:
results.append(await _enrich_incident(db, row))
return results
@router.post("", response_model=IncidentResponse, status_code=status.HTTP_201_CREATED)
async def create_incident(
data: IncidentCreate,
current_user: dict = Depends(require_writable_license),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Neue Lage anlegen."""
tenant_id = current_user.get("tenant_id")
now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
retention_days, international_sources, visibility,
tenant_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.title,
data.description,
data.type,
data.refresh_mode,
data.refresh_interval,
data.retention_days,
1 if data.international_sources else 0,
data.visibility,
tenant_id,
current_user["id"],
now,
now,
),
)
await db.commit()
cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (cursor.lastrowid,))
row = await cursor.fetchone()
return await _enrich_incident(db, row)
@router.get("/refreshing")
async def get_refreshing_incidents(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Gibt IDs aller Lagen mit laufendem Refresh zurueck (nur eigener Tenant)."""
tenant_id = current_user.get("tenant_id")
cursor = await db.execute(
"""SELECT rl.incident_id, rl.started_at FROM refresh_log rl
JOIN incidents i ON i.id = rl.incident_id
WHERE rl.status = 'running'
AND i.tenant_id = ?
AND (i.visibility = 'public' OR i.created_by = ?)""",
(tenant_id, current_user["id"]),
)
rows = await cursor.fetchall()
return {
"refreshing": [row["incident_id"] for row in rows],
"details": {str(row["incident_id"]): {"started_at": row["started_at"]} for row in rows},
}
@router.get("/{incident_id}", response_model=IncidentResponse)
async def get_incident(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Einzelne Lage abrufen."""
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
return await _enrich_incident(db, row)
@router.put("/{incident_id}", response_model=IncidentResponse)
async def update_incident(
incident_id: int,
data: IncidentUpdate,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lage aktualisieren."""
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
updates = {}
for field, value in data.model_dump(exclude_none=True).items():
if field not in INCIDENT_UPDATE_COLUMNS:
continue
updates[field] = value
if not updates:
return await _enrich_incident(db, row)
updates["updated_at"] = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [incident_id]
await db.execute(f"UPDATE incidents SET {set_clause} WHERE id = ?", values)
await db.commit()
cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,))
row = await cursor.fetchone()
return await _enrich_incident(db, row)
@router.delete("/{incident_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_incident(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lage loeschen (nur Ersteller)."""
tenant_id = current_user.get("tenant_id")
cursor = await db.execute(
"SELECT id, created_by FROM incidents WHERE id = ? AND tenant_id = ?",
(incident_id, tenant_id),
)
incident = await cursor.fetchone()
if not incident:
raise HTTPException(status_code=404, detail="Lage nicht gefunden")
if incident["created_by"] != current_user["id"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nur der Ersteller kann diese Lage loeschen",
)
await db.execute("DELETE FROM incidents WHERE id = ?", (incident_id,))
await db.commit()
@router.get("/{incident_id}/articles")
async def get_articles(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Artikel einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
(incident_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/{incident_id}/snapshots")
async def get_snapshots(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lageberichte (Snapshots) einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT id, incident_id, summary, sources_json,
article_count, fact_check_count, created_at
FROM incident_snapshots WHERE incident_id = ?
ORDER BY created_at DESC""",
(incident_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/{incident_id}/factchecks")
async def get_factchecks(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Faktenchecks einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
(incident_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/{incident_id}/refresh-log")
async def get_refresh_log(
incident_id: int,
limit: int = 20,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Refresh-Verlauf einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT id, started_at, completed_at, articles_found, status,
trigger_type, retry_count, error_message
FROM refresh_log WHERE incident_id = ?
ORDER BY started_at DESC LIMIT ?""",
(incident_id, min(limit, 100)),
)
rows = await cursor.fetchall()
results = []
for row in rows:
entry = dict(row)
if entry["started_at"] and entry["completed_at"]:
try:
start = datetime.fromisoformat(entry["started_at"])
end = datetime.fromisoformat(entry["completed_at"])
entry["duration_seconds"] = round((end - start).total_seconds(), 1)
except Exception:
entry["duration_seconds"] = None
else:
entry["duration_seconds"] = None
results.append(entry)
return results
@router.get("/{incident_id}/subscription", response_model=SubscriptionResponse)
async def get_subscription(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT notify_email_summary, notify_email_new_articles, notify_email_status_change
FROM incident_subscriptions WHERE user_id = ? AND incident_id = ?""",
(current_user["id"], incident_id),
)
row = await cursor.fetchone()
if row:
return dict(row)
return {"notify_email_summary": False, "notify_email_new_articles": False, "notify_email_status_change": False}
@router.put("/{incident_id}/subscription", response_model=SubscriptionResponse)
async def update_subscription(
incident_id: int,
data: SubscriptionUpdate,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage setzen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
await db.execute(
"""INSERT INTO incident_subscriptions (user_id, incident_id, notify_email_summary, notify_email_new_articles, notify_email_status_change)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, incident_id) DO UPDATE SET
notify_email_summary = excluded.notify_email_summary,
notify_email_new_articles = excluded.notify_email_new_articles,
notify_email_status_change = excluded.notify_email_status_change""",
(
current_user["id"],
incident_id,
1 if data.notify_email_summary else 0,
1 if data.notify_email_new_articles else 0,
1 if data.notify_email_status_change else 0,
),
)
await db.commit()
return {
"notify_email_summary": data.notify_email_summary,
"notify_email_new_articles": data.notify_email_new_articles,
"notify_email_status_change": data.notify_email_status_change,
}
@router.post("/{incident_id}/refresh")
async def trigger_refresh(
incident_id: int,
current_user: dict = Depends(require_writable_license),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Manuellen Refresh fuer eine Lage ausloesen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
from agents.orchestrator import orchestrator
enqueued = await orchestrator.enqueue_refresh(incident_id)
if not enqueued:
return {"status": "skipped", "incident_id": incident_id}
return {"status": "queued", "incident_id": incident_id}
@router.post("/{incident_id}/cancel-refresh")
async def cancel_refresh(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Laufenden Refresh fuer eine Lage abbrechen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
from agents.orchestrator import orchestrator
cancelled = await orchestrator.cancel_refresh(incident_id)
return {"status": "cancelling" if cancelled else "not_running"}
def _slugify(text: str) -> str:
"""Dateinamen-sicherer Slug aus Titel."""
replacements = {
"\u00e4": "ae", "\u00f6": "oe", "\u00fc": "ue", "\u00df": "ss",
"\u00c4": "Ae", "\u00d6": "Oe", "\u00dc": "Ue",
}
for src, dst in replacements.items():
text = text.replace(src, dst)
text = unicodedata.normalize("NFKD", text)
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[\s_]+", "-", text).strip("-")
return text[:80].lower()
def _build_markdown_export(
incident: dict, articles: list, fact_checks: list,
snapshots: list, scope: str, creator: str
) -> str:
"""Markdown-Dokument zusammenbauen."""
typ = "Hintergrundrecherche" if incident.get("type") == "research" else "Breaking News"
updated = (incident.get("updated_at") or "")[:16].replace("T", " ")
lines = []
lines.append(f"# {incident['title']}")
lines.append(f"> {typ} | Erstellt von {creator} | Stand: {updated}")
lines.append("")
# Lagebild
summary = incident.get("summary") or "*Noch kein Lagebild verf\u00fcgbar.*"
lines.append("## Lagebild")
lines.append("")
lines.append(summary)
lines.append("")
# Quellenverzeichnis aus sources_json
sources_json = incident.get("sources_json")
if sources_json:
try:
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
if sources:
lines.append("## Quellenverzeichnis")
lines.append("")
for i, src in enumerate(sources, 1):
name = src.get("name") or src.get("title") or src.get("url", "")
url = src.get("url", "")
if url:
lines.append(f"{i}. [{name}]({url})")
else:
lines.append(f"{i}. {name}")
lines.append("")
except (json.JSONDecodeError, TypeError):
pass
# Faktencheck
if fact_checks:
lines.append("## Faktencheck")
lines.append("")
for fc in fact_checks:
claim = fc.get("claim", "")
fc_status = fc.get("status", "")
sources_count = fc.get("sources_count", 0)
evidence = fc.get("evidence", "")
status_label = {
"confirmed": "Best\u00e4tigt", "unconfirmed": "Unbest\u00e4tigt",
"disputed": "Umstritten", "false": "Falsch",
}.get(fc_status, fc_status)
line = f"- **{claim}** \u2014 {status_label} ({sources_count} Quellen)"
if evidence:
line += f"\n {evidence}"
lines.append(line)
lines.append("")
# Scope=full: Artikel\u00fcbersicht
if scope == "full" and articles:
lines.append("## Artikel\u00fcbersicht")
lines.append("")
lines.append("| Headline | Quelle | Sprache | Datum |")
lines.append("|----------|--------|---------|-------|")
for art in articles:
headline = (art.get("headline_de") or art.get("headline") or "").replace("|", "/")
source = (art.get("source") or "").replace("|", "/")
lang = art.get("language", "")
pub = (art.get("published_at") or art.get("collected_at") or "")[:16]
lines.append(f"| {headline} | {source} | {lang} | {pub} |")
lines.append("")
# Scope=full: Snapshot-Verlauf
if scope == "full" and snapshots:
lines.append("## Snapshot-Verlauf")
lines.append("")
for snap in snapshots:
snap_date = (snap.get("created_at") or "")[:16].replace("T", " ")
art_count = snap.get("article_count", 0)
fc_count = snap.get("fact_check_count", 0)
lines.append(f"### Snapshot vom {snap_date}")
lines.append(f"Artikel: {art_count} | Faktenchecks: {fc_count}")
lines.append("")
snap_summary = snap.get("summary", "")
if snap_summary:
lines.append(snap_summary)
lines.append("")
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines.append("---")
lines.append(f"*Exportiert am {now} aus AegisSight Monitor*")
return "\n".join(lines)
def _build_json_export(
incident: dict, articles: list, fact_checks: list,
snapshots: list, scope: str, creator: str
) -> dict:
"""Strukturiertes JSON fuer Export."""
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
sources = []
sources_json = incident.get("sources_json")
if sources_json:
try:
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
except (json.JSONDecodeError, TypeError):
pass
export = {
"export_version": "1.0",
"exported_at": now,
"scope": scope,
"incident": {
"id": incident["id"],
"title": incident["title"],
"description": incident.get("description"),
"type": incident.get("type"),
"status": incident.get("status"),
"visibility": incident.get("visibility"),
"created_by": creator,
"created_at": incident.get("created_at"),
"updated_at": incident.get("updated_at"),
"summary": incident.get("summary"),
"international_sources": bool(incident.get("international_sources")),
},
"sources": sources,
"fact_checks": [
{
"claim": fc.get("claim"),
"status": fc.get("status"),
"sources_count": fc.get("sources_count"),
"evidence": fc.get("evidence"),
"checked_at": fc.get("checked_at"),
}
for fc in fact_checks
],
}
if scope == "full":
export["articles"] = [
{
"headline": art.get("headline"),
"headline_de": art.get("headline_de"),
"source": art.get("source"),
"source_url": art.get("source_url"),
"language": art.get("language"),
"published_at": art.get("published_at"),
"collected_at": art.get("collected_at"),
"verification_status": art.get("verification_status"),
}
for art in articles
]
export["snapshots"] = [
{
"created_at": snap.get("created_at"),
"article_count": snap.get("article_count"),
"fact_check_count": snap.get("fact_check_count"),
"summary": snap.get("summary"),
}
for snap in snapshots
]
return export
@router.get("/{incident_id}/export")
async def export_incident(
incident_id: int,
format: str = Query(..., pattern="^(md|json)$"),
scope: str = Query("report", pattern="^(report|full)$"),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lage als Markdown oder JSON exportieren."""
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
incident = dict(row)
# Ersteller-Name
cursor = await db.execute("SELECT username FROM users WHERE id = ?", (incident["created_by"],))
user_row = await cursor.fetchone()
creator = user_row["username"] if user_row else "Unbekannt"
# Artikel
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
(incident_id,),
)
articles = [dict(r) for r in await cursor.fetchall()]
# Faktenchecks
cursor = await db.execute(
"SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
(incident_id,),
)
fact_checks = [dict(r) for r in await cursor.fetchall()]
# Snapshots (nur bei full)
snapshots = []
if scope == "full":
cursor = await db.execute(
"SELECT * FROM incident_snapshots WHERE incident_id = ? ORDER BY created_at DESC",
(incident_id,),
)
snapshots = [dict(r) for r in await cursor.fetchall()]
# Dateiname
date_str = datetime.now(timezone.utc).strftime("%Y%m%d")
slug = _slugify(incident["title"])
scope_suffix = "_vollexport" if scope == "full" else ""
if format == "md":
body = _build_markdown_export(incident, articles, fact_checks, snapshots, scope, creator)
filename = f"{slug}{scope_suffix}_{date_str}.md"
media_type = "text/markdown; charset=utf-8"
else:
export_data = _build_json_export(incident, articles, fact_checks, snapshots, scope, creator)
body = json.dumps(export_data, ensure_ascii=False, indent=2)
filename = f"{slug}{scope_suffix}_{date_str}.json"
media_type = "application/json; charset=utf-8"
return StreamingResponse(
iter([body]),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

62
src/routers/notifications.py Normale Datei
Datei anzeigen

@@ -0,0 +1,62 @@
"""Notifications-Router: Persistente Benachrichtigungen (Multi-Tenant)."""
import logging
from fastapi import APIRouter, Depends, Query
from models import NotificationResponse, NotificationMarkReadRequest
from auth import get_current_user
from database import db_dependency
import aiosqlite
logger = logging.getLogger("osint.notifications")
router = APIRouter(prefix="/api/notifications", tags=["notifications"])
@router.get("", response_model=list[NotificationResponse])
async def list_notifications(
limit: int = Query(default=50, ge=1, le=200),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Eigene Notifications abrufen (neueste zuerst)."""
cursor = await db.execute(
"SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
(current_user["id"], limit),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/unread-count")
async def get_unread_count(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Anzahl ungelesener Notifications."""
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM notifications WHERE user_id = ? AND is_read = 0",
(current_user["id"],),
)
row = await cursor.fetchone()
return {"unread_count": row["cnt"]}
@router.put("/mark-read")
async def mark_notifications_read(
body: NotificationMarkReadRequest,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Notifications als gelesen markieren (bestimmte IDs oder alle)."""
if body.notification_ids:
placeholders = ",".join("?" for _ in body.notification_ids)
await db.execute(
f"UPDATE notifications SET is_read = 1 WHERE user_id = ? AND id IN ({placeholders})",
[current_user["id"]] + body.notification_ids,
)
else:
await db.execute(
"UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0",
(current_user["id"],),
)
await db.commit()
return {"ok": True}

527
src/routers/sources.py Normale Datei
Datei anzeigen

@@ -0,0 +1,527 @@
"""Sources-Router: Quellenverwaltung (Multi-Tenant)."""
import logging
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, status
from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest
from auth import get_current_user
from database import db_dependency, refresh_source_counts
from source_rules import discover_source, discover_all_feeds, evaluate_feeds_with_claude, _extract_domain, _detect_category, domain_to_display_name
import aiosqlite
logger = logging.getLogger("osint.sources")
router = APIRouter(prefix="/api/sources", tags=["sources"])
SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "status", "notes"}
def _check_source_ownership(source: dict, username: str):
"""Prueft ob der Nutzer die Quelle bearbeiten/loeschen darf."""
added_by = source.get("added_by", "")
if added_by == "system":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="System-Quellen koennen nicht veraendert werden",
)
if added_by and added_by != username:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nur der Ersteller kann diese Quelle bearbeiten",
)
@router.get("", response_model=list[SourceResponse])
async def list_sources(
source_type: str = None,
category: str = None,
source_status: str = None,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Quellen auflisten (global + org-spezifisch)."""
tenant_id = current_user.get("tenant_id")
# Global (tenant_id=NULL) + eigene Org
query = "SELECT * FROM sources WHERE (tenant_id IS NULL OR tenant_id = ?)"
params = [tenant_id]
if source_type:
query += " AND source_type = ?"
params.append(source_type)
if category:
query += " AND category = ?"
params.append(category)
if source_status:
query += " AND status = ?"
params.append(source_status)
query += " ORDER BY source_type, category, name"
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/stats")
async def get_source_stats(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte Quellen-Statistiken (global + eigene Org)."""
tenant_id = current_user.get("tenant_id")
cursor = await db.execute("""
SELECT
source_type,
COUNT(*) as count,
SUM(article_count) as total_articles
FROM sources
WHERE status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)
GROUP BY source_type
""", (tenant_id,))
rows = await cursor.fetchall()
stats = {
"rss_feed": {"count": 0, "articles": 0},
"web_source": {"count": 0, "articles": 0},
"excluded": {"count": 0, "articles": 0},
}
for row in rows:
st = row["source_type"]
if st in stats:
stats[st]["count"] = row["count"]
stats[st]["articles"] = row["total_articles"] or 0
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM articles WHERE tenant_id = ?",
(tenant_id,),
)
total_row = await cursor.fetchone()
return {
"by_type": stats,
"total_sources": sum(s["count"] for s in stats.values()),
"total_articles": total_row["cnt"],
}
@router.post("/discover", response_model=DiscoverResponse)
async def discover_source_endpoint(
data: DiscoverRequest,
current_user: dict = Depends(get_current_user),
):
"""RSS-Feed, Name, Kategorie und Domain einer URL automatisch erkennen."""
try:
result = await discover_source(data.url)
return result
except Exception as e:
logger.error(f"Discovery fehlgeschlagen: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Discovery fehlgeschlagen")
@router.post("/discover-multi", response_model=DiscoverMultiResponse)
async def discover_multi_endpoint(
data: DiscoverRequest,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Findet ALLE RSS-Feeds einer Domain, bewertet sie mit Claude und legt relevante als Quellen an."""
tenant_id = current_user.get("tenant_id")
try:
multi = await discover_all_feeds(data.url)
domain = multi["domain"]
category = multi["category"]
if not multi["feeds"]:
single = await discover_source(data.url)
sources = []
if single.get("rss_url"):
cursor = await db.execute(
"SELECT id FROM sources WHERE url = ?", (single["rss_url"],)
)
existing = await cursor.fetchone()
if not existing:
cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)""",
(single["name"], single["rss_url"], single["domain"],
single["source_type"], single["category"], current_user["username"], tenant_id),
)
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
row = await cursor.fetchone()
sources.append(dict(row))
return DiscoverMultiResponse(
domain=single.get("domain", domain),
category=single.get("category", category),
added_count=len(sources),
skipped_count=1 if not sources and single.get("rss_url") else 0,
total_found=1 if single.get("rss_url") else 0,
sources=sources,
fallback_single=True,
)
relevant_feeds = await evaluate_feeds_with_claude(domain, multi["feeds"])
cursor = await db.execute("SELECT url FROM sources WHERE url IS NOT NULL")
existing_urls = {row["url"] for row in await cursor.fetchall()}
new_ids = []
skipped = 0
for feed in relevant_feeds:
if feed["url"] in existing_urls:
skipped += 1
continue
cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
VALUES (?, ?, ?, 'rss_feed', ?, 'active', ?, ?)""",
(feed["name"], feed["url"], domain, category, current_user["username"], tenant_id),
)
new_ids.append(cursor.lastrowid)
existing_urls.add(feed["url"])
cursor = await db.execute(
"SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'web_source'",
(domain.lower(),),
)
if not await cursor.fetchone():
cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
VALUES (?, ?, ?, 'web_source', ?, 'active', ?, ?)""",
(domain_to_display_name(domain), f"https://{domain}", domain, category, current_user["username"], tenant_id),
)
new_ids.append(cursor.lastrowid)
await db.commit()
added_sources = []
if new_ids:
placeholders = ",".join("?" for _ in new_ids)
cursor = await db.execute(
f"SELECT * FROM sources WHERE id IN ({placeholders}) ORDER BY id",
new_ids,
)
added_sources = [dict(row) for row in await cursor.fetchall()]
return DiscoverMultiResponse(
domain=domain,
category=category,
added_count=len(added_sources),
skipped_count=skipped,
total_found=len(multi["feeds"]),
sources=added_sources,
fallback_single=False,
)
except Exception as e:
logger.error(f"Multi-Discovery fehlgeschlagen: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Multi-Discovery fehlgeschlagen")
@router.post("/rediscover-existing")
async def rediscover_existing_endpoint(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Einmalige Migration: Bestehende RSS-Quellen nach zusaetzlichen Feeds durchsuchen."""
tenant_id = current_user.get("tenant_id")
try:
cursor = await db.execute(
"SELECT * FROM sources WHERE source_type = 'rss_feed' AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)",
(tenant_id,),
)
existing_sources = [dict(row) for row in await cursor.fetchall()]
domains = defaultdict(list)
for src in existing_sources:
if src["domain"]:
domains[src["domain"]].append(src)
cursor = await db.execute("SELECT url FROM sources WHERE url IS NOT NULL")
existing_urls = {row["url"] for row in await cursor.fetchall()}
domains_processed = 0
feeds_added = 0
feeds_skipped = 0
for domain, sources in domains.items():
domains_processed += 1
base_url = f"https://{domain}"
try:
multi = await discover_all_feeds(base_url)
if not multi["feeds"]:
continue
relevant_feeds = await evaluate_feeds_with_claude(domain, multi["feeds"])
category = _detect_category(domain)
for feed in relevant_feeds:
if feed["url"] in existing_urls:
feeds_skipped += 1
continue
await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
VALUES (?, ?, ?, 'rss_feed', ?, 'active', ?, ?)""",
(feed["name"], feed["url"], domain, category, current_user["username"], tenant_id),
)
existing_urls.add(feed["url"])
feeds_added += 1
await db.commit()
except Exception as e:
logger.warning(f"Rediscovery fuer {domain} fehlgeschlagen: {e}")
continue
return {
"domains_processed": domains_processed,
"feeds_added": feeds_added,
"feeds_skipped": feeds_skipped,
}
except Exception as e:
logger.error(f"Rediscovery fehlgeschlagen: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Rediscovery fehlgeschlagen")
@router.post("/block-domain")
async def block_domain(
data: DomainActionRequest,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Domain sperren: Alle Feeds deaktivieren + excluded-Eintrag anlegen."""
tenant_id = current_user.get("tenant_id")
domain = data.domain.lower().strip()
username = current_user["username"]
cursor = await db.execute(
"SELECT added_by FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)",
(domain, tenant_id),
)
affected = await cursor.fetchall()
for row in affected:
ab = row["added_by"] or ""
if ab != "system" and ab != username and ab != "":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Domain enthaelt Quellen anderer Nutzer",
)
cursor = await db.execute(
"UPDATE sources SET status = 'inactive' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
(domain, tenant_id),
)
feeds_deactivated = cursor.rowcount
cursor = await db.execute(
"SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
(domain, tenant_id),
)
existing = await cursor.fetchone()
if existing:
excluded_id = existing["id"]
if data.notes:
await db.execute(
"UPDATE sources SET notes = ? WHERE id = ?",
(data.notes, excluded_id),
)
else:
cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, NULL, ?, 'excluded', 'sonstige', 'active', ?, ?, ?)""",
(domain, domain, data.notes, current_user["username"], tenant_id),
)
excluded_id = cursor.lastrowid
await db.commit()
return {
"domain": domain,
"feeds_deactivated": feeds_deactivated,
"excluded_id": excluded_id,
}
@router.post("/unblock-domain")
async def unblock_domain(
data: DomainActionRequest,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Domain entsperren: excluded-Eintrag loeschen + Feeds reaktivieren."""
tenant_id = current_user.get("tenant_id")
domain = data.domain.lower().strip()
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
(domain, tenant_id),
)
row = await cursor.fetchone()
has_feeds = row["cnt"] > 0
if has_feeds:
await db.execute(
"DELETE FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
(domain, tenant_id),
)
cursor = await db.execute(
"UPDATE sources SET status = 'active' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
(domain, tenant_id),
)
feeds_reactivated = cursor.rowcount
else:
await db.execute(
"""UPDATE sources SET source_type = 'web_source', status = 'active', notes = 'Entsperrt'
WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)""",
(domain, tenant_id),
)
feeds_reactivated = 0
await db.commit()
return {
"domain": domain,
"feeds_reactivated": feeds_reactivated,
}
@router.delete("/domain/{domain}")
async def delete_domain(
domain: str,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Quellen einer Domain loeschen (nur org-eigene, nicht globale)."""
tenant_id = current_user.get("tenant_id")
domain_lower = domain.lower().strip()
cursor = await db.execute(
"SELECT * FROM sources WHERE LOWER(domain) = ? AND tenant_id = ?",
(domain_lower, tenant_id),
)
rows = await cursor.fetchall()
if not rows:
raise HTTPException(status_code=404, detail="Keine Quellen fuer diese Domain gefunden")
username = current_user["username"]
for row in rows:
source = dict(row)
if source["added_by"] == "system":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Domain enthaelt System-Quellen, die nicht geloescht werden koennen",
)
if source["added_by"] and source["added_by"] != username:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Domain enthaelt Quellen anderer Nutzer",
)
await db.execute(
"DELETE FROM sources WHERE LOWER(domain) = ? AND tenant_id = ?",
(domain_lower, tenant_id),
)
await db.commit()
return {
"domain": domain_lower,
"deleted_count": len(rows),
}
@router.post("", response_model=SourceResponse, status_code=status.HTTP_201_CREATED)
async def create_source(
data: SourceCreate,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Neue Quelle hinzufuegen (org-spezifisch)."""
tenant_id = current_user.get("tenant_id")
cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.name,
data.url,
data.domain,
data.source_type,
data.category,
data.status,
data.notes,
current_user["username"],
tenant_id,
),
)
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
row = await cursor.fetchone()
return dict(row)
@router.put("/{source_id}", response_model=SourceResponse)
async def update_source(
source_id: int,
data: SourceUpdate,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Quelle bearbeiten."""
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
_check_source_ownership(dict(row), current_user["username"])
updates = {}
for field, value in data.model_dump(exclude_none=True).items():
if field not in SOURCE_UPDATE_COLUMNS:
continue
updates[field] = value
if not updates:
return dict(row)
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [source_id]
await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
row = await cursor.fetchone()
return dict(row)
@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
source_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Quelle loeschen."""
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
_check_source_ownership(dict(row), current_user["username"])
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
await db.commit()
@router.post("/refresh-counts")
async def trigger_refresh_counts(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Artikelzaehler fuer alle Quellen neu berechnen."""
await refresh_source_counts(db)
return {"status": "ok"}