fix: sécuriser watchlist, favorites, downloads et recommendations sans auth (#15)

- router_favorites.py: toutes les routes requièrent maintenant l'auth
  - GET utilise get_optional_user + login_prompt.html pour HTMX
  - POST/DELETE/toggle requièrent get_current_user_from_token
  - Filtrage par user_id dans toutes les requêtes favorites
- router_downloads.py: GET list et GET status protégés (401 sans token)
- router_recommendations.py: GET protégé (login_prompt HTMX, 401 JSON)
- router_sonarr.py: tous les endpoints de gestion protégés
  - Webhooks restent publics (reçus de Sonarr)
- app/favorites.py: ajout du paramètre user_id à toutes les méthodes

Closes #15
This commit is contained in:
root
2026-04-02 22:20:29 +00:00
parent c0f9c0c1c4
commit 5d264d8f3b
5 changed files with 154 additions and 45 deletions
+34 -18
View File
@@ -27,11 +27,15 @@ class FavoritesManager:
url: str, url: str,
provider: str, provider: str,
metadata: Optional[Dict] = None, metadata: Optional[Dict] = None,
poster_url: Optional[str] = None poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict: ) -> Dict:
"""Add an anime to favorites""" """Add an anime to favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
@@ -53,17 +57,21 @@ class FavoritesManager:
url=url, url=url,
provider=provider, provider=provider,
anime_metadata=metadata or {}, anime_metadata=metadata or {},
poster_url=poster_url poster_url=poster_url,
user_id=user_id
) )
session.add(fav) session.add(fav)
session.commit() session.commit()
session.refresh(fav) session.refresh(fav)
return self._to_dict(fav) return self._to_dict(fav)
async def remove_favorite(self, anime_id: str) -> bool: async def remove_favorite(self, anime_id: str, user_id: str = "default") -> bool:
"""Remove an anime from favorites""" """Remove an anime from favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
session.delete(existing) session.delete(existing)
@@ -71,10 +79,13 @@ class FavoritesManager:
return True return True
return False return False
async def get_favorite(self, anime_id: str) -> Optional[Dict]: async def get_favorite(self, anime_id: str, user_id: str = "default") -> Optional[Dict]:
"""Get a specific favorite by ID""" """Get a specific favorite by ID"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
return self._to_dict(existing) return self._to_dict(existing)
@@ -82,6 +93,7 @@ class FavoritesManager:
async def list_favorites( async def list_favorites(
self, self,
user_id: str = "default",
sort_by: str = "created_at", sort_by: str = "created_at",
order: str = "desc", order: str = "desc",
filter_provider: Optional[str] = None, filter_provider: Optional[str] = None,
@@ -89,11 +101,11 @@ class FavoritesManager:
) -> List[Dict]: ) -> List[Dict]:
"""List all favorites with optional sorting and filtering""" """List all favorites with optional sorting and filtering"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable) statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
if filter_provider: if filter_provider:
statement = statement.where(FavoriteTable.provider == filter_provider) statement = statement.where(FavoriteTable.provider == filter_provider)
# SQLite JSON filtering for genres is complex, handle it in Python # SQLite JSON filtering for genres is complex, handle it in Python
results = session.exec(statement).all() results = session.exec(statement).all()
favorites = [self._to_dict(fav) for fav in results] favorites = [self._to_dict(fav) for fav in results]
@@ -123,10 +135,13 @@ class FavoritesManager:
return favorites return favorites
async def is_favorite(self, anime_id: str) -> bool: async def is_favorite(self, anime_id: str, user_id: str = "default") -> bool:
"""Check if an anime is in favorites""" """Check if an anime is in favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
return session.exec(statement).first() is not None return session.exec(statement).first() is not None
async def toggle_favorite( async def toggle_favorite(
@@ -136,21 +151,22 @@ class FavoritesManager:
url: str, url: str,
provider: str, provider: str,
metadata: Optional[Dict] = None, metadata: Optional[Dict] = None,
poster_url: Optional[str] = None poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict: ) -> Dict:
"""Toggle an anime in favorites (add if not exists, remove if exists)""" """Toggle an anime in favorites (add if not exists, remove if exists)"""
is_fav = await self.is_favorite(anime_id) is_fav = await self.is_favorite(anime_id, user_id=user_id)
if is_fav: if is_fav:
await self.remove_favorite(anime_id) await self.remove_favorite(anime_id, user_id=user_id)
return {"action": "removed", "anime_id": anime_id} return {"action": "removed", "anime_id": anime_id}
else: else:
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url) fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url, user_id=user_id)
return {"action": "added", "anime_id": anime_id, "favorite": fav} return {"action": "added", "anime_id": anime_id, "favorite": fav}
async def get_stats(self) -> Dict: async def get_stats(self, user_id: str = "default") -> Dict:
"""Get statistics about favorites""" """Get statistics about favorites"""
favorites = await self.list_favorites() favorites = await self.list_favorites(user_id=user_id)
total = len(favorites) total = len(favorites)
# Count by provider # Count by provider
+20 -6
View File
@@ -2,13 +2,15 @@
Download management routes for Ohm Stream Downloader API. Download management routes for Ohm Stream Downloader API.
""" """
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.download_manager import DownloadManager from app.download_manager import DownloadManager
from app.models import DownloadRequest from app.models import DownloadRequest
from app.routers.router_auth import get_current_user_from_token from app.models.auth import User
from app.routers.router_auth import get_current_user_from_token, get_optional_user
router = APIRouter(prefix="/api/downloads", tags=["downloads"]) router = APIRouter(prefix="/api/downloads", tags=["downloads"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -24,20 +26,28 @@ async def get_downloads(
request: Request, request: Request,
html: bool = Query(False), html: bool = Query(False),
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get list of all download tasks. Returns HTML for HTMX requests.""" """Get list of all download tasks. Returns HTML for HTMX requests."""
tasks = download_manager.get_all_tasks()
# Strictly check for HTMX or explicit HTML flag
is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request") is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
tasks = download_manager.get_all_tasks()
if html or is_htmx: if html or is_htmx:
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.") print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
return templates.TemplateResponse( return templates.TemplateResponse(
"components/downloads_list.html", "components/downloads_list.html",
{"request": request, "tasks": tasks} {"request": request, "tasks": tasks}
) )
print(f"[DOWNLOADS] API Request. Returning JSON.") print(f"[DOWNLOADS] API Request. Returning JSON.")
return {"downloads": tasks} return {"downloads": tasks}
@@ -56,8 +66,12 @@ async def create_download(
async def get_download_status( async def get_download_status(
task_id: str, task_id: str,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get status of a specific download task""" """Get status of a specific download task"""
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = download_manager.get_task(task_id) task = download_manager.get_task(task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
+56 -12
View File
@@ -2,24 +2,42 @@
Favorites management routes for Ohm Stream Downloader API. Favorites management routes for Ohm Stream Downloader API.
""" """
from fastapi import APIRouter, HTTPException from typing import Optional
from fastapi.requests import Request from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from app.favorites import get_favorites_manager from app.favorites import get_favorites_manager
from app.models.auth import User
from app.routers.router_auth import get_current_user_from_token, get_optional_user
router = APIRouter(prefix="/api/favorites", tags=["favorites"]) router = APIRouter(prefix="/api/favorites", tags=["favorites"])
templates = Jinja2Templates(directory="templates")
@router.get("") @router.get("")
async def list_favorites( async def list_favorites(
request: Request,
sort_by: str = "created_at", sort_by: str = "created_at",
order: str = "desc", order: str = "desc",
filter_provider: str = None, filter_provider: Optional[str] = None,
filter_genre: str = None, filter_genre: Optional[str] = None,
html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""List all favorite anime with optional sorting and filtering""" """List all favorite anime with optional sorting and filtering"""
is_htmx = request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorites = await fav_manager.list_favorites( favorites = await fav_manager.list_favorites(
user_id=current_user.id,
sort_by=sort_by, sort_by=sort_by,
order=order, order=order,
filter_provider=filter_provider, filter_provider=filter_provider,
@@ -38,7 +56,11 @@ async def list_favorites(
@router.post("") @router.post("")
async def add_favorite(request: Request): async def add_favorite(
request: Request,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Add an anime to favorites""" """Add an anime to favorites"""
data = await request.json() data = await request.json()
@@ -51,6 +73,7 @@ async def add_favorite(request: Request):
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorite = await fav_manager.add_favorite( favorite = await fav_manager.add_favorite(
user_id=current_user.id,
anime_id=data["anime_id"], anime_id=data["anime_id"],
title=data["title"], title=data["title"],
url=data["url"], url=data["url"],
@@ -59,34 +82,45 @@ async def add_favorite(request: Request):
poster_url=data.get("poster_url"), poster_url=data.get("poster_url"),
) )
response.headers["HX-Trigger"] = '{"show-toast": {"message": "' + favorite['title'] + ' ajouté aux favoris", "type": "success"}}'
return {"status": "added", "favorite": favorite} return {"status": "added", "favorite": favorite}
@router.delete("/{anime_id}") @router.delete("/{anime_id}")
async def remove_favorite(anime_id: str): async def remove_favorite(
anime_id: str,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Remove an anime from favorites""" """Remove an anime from favorites"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
removed = await fav_manager.remove_favorite(anime_id) removed = await fav_manager.remove_favorite(anime_id, user_id=current_user.id)
if not removed: if not removed:
raise HTTPException(status_code=404, detail="Favorite not found") raise HTTPException(status_code=404, detail="Favorite not found")
response.headers["HX-Trigger"] = '{"show-toast": {"message": "Favori supprimé", "type": "info"}}'
return {"status": "removed", "anime_id": anime_id} return {"status": "removed", "anime_id": anime_id}
@router.get("/stats") @router.get("/stats")
async def get_favorites_stats(): async def get_favorites_stats(
current_user: User = Depends(get_current_user_from_token),
):
"""Get statistics about favorites""" """Get statistics about favorites"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
stats = await fav_manager.get_stats() stats = await fav_manager.get_stats(user_id=current_user.id)
return stats return stats
@router.get("/{anime_id}") @router.get("/{anime_id}")
async def get_favorite(anime_id: str): async def get_favorite(
anime_id: str,
current_user: User = Depends(get_current_user_from_token),
):
"""Get details of a specific favorite anime""" """Get details of a specific favorite anime"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorite = await fav_manager.get_favorite(anime_id) favorite = await fav_manager.get_favorite(anime_id, user_id=current_user.id)
if not favorite: if not favorite:
raise HTTPException(status_code=404, detail="Favorite not found") raise HTTPException(status_code=404, detail="Favorite not found")
@@ -95,7 +129,11 @@ async def get_favorite(anime_id: str):
@router.post("/toggle") @router.post("/toggle")
async def toggle_favorite(request: Request): async def toggle_favorite(
request: Request,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Toggle an anime in favorites""" """Toggle an anime in favorites"""
data = await request.json() data = await request.json()
@@ -108,6 +146,7 @@ async def toggle_favorite(request: Request):
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
result = await fav_manager.toggle_favorite( result = await fav_manager.toggle_favorite(
user_id=current_user.id,
anime_id=data["anime_id"], anime_id=data["anime_id"],
title=data["title"], title=data["title"],
url=data["url"], url=data["url"],
@@ -116,4 +155,9 @@ async def toggle_favorite(request: Request):
poster_url=data.get("poster_url"), poster_url=data.get("poster_url"),
) )
action = result.get("action", "unknown")
message = f"'{data['title']}' {'ajouté aux' if action == 'added' else 'retiré des'} favoris"
toast_type = "success" if action == "added" else "info"
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{message}", "type": "{toast_type}"}}}}'
return result return result
+18 -3
View File
@@ -6,10 +6,12 @@ import hashlib
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Request, Query, HTTPException from fastapi import APIRouter, Request, Query, HTTPException, Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.recommendation_engine import RecommendationEngine from app.recommendation_engine import RecommendationEngine
from app.models.auth import User
from app.routers.router_auth import get_optional_user, get_current_user_from_token
router = APIRouter(prefix="/api", tags=["recommendations"]) router = APIRouter(prefix="/api", tags=["recommendations"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -26,14 +28,25 @@ async def get_recommendations(
request: Request, request: Request,
limit: int = 15, limit: int = 15,
html: bool = Query(False), html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get personalized anime recommendations based on download history""" """Get personalized anime recommendations based on download history"""
is_htmx = request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
engine = RecommendationEngine(download_dir="downloads") engine = RecommendationEngine(download_dir="downloads")
try: try:
recommendations = await engine.get_personalized_recommendations(limit=limit) recommendations = await engine.get_personalized_recommendations(limit=limit)
if html or request.headers.get("HX-Request"): if html or is_htmx:
return templates.TemplateResponse( return templates.TemplateResponse(
"components/recommendations_list.html", "components/recommendations_list.html",
{"request": request, "recommendations": recommendations} {"request": request, "recommendations": recommendations}
@@ -140,7 +153,9 @@ async def get_top_anime(
@router.get("/stats/downloads") @router.get("/stats/downloads")
async def get_download_statistics(): async def get_download_statistics(
current_user: User = Depends(get_current_user_from_token),
):
"""Get download statistics and preferences""" """Get download statistics and preferences"""
engine = RecommendationEngine(download_dir="downloads") engine = RecommendationEngine(download_dir="downloads")
+26 -6
View File
@@ -68,14 +68,19 @@ async def test_sonarr_webhook(request: Request):
@router.get("/sonarr/config") @router.get("/sonarr/config")
async def get_sonarr_config(): async def get_sonarr_config(
current_user: User = Depends(get_current_user_from_token),
):
"""Get Sonarr webhook configuration""" """Get Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_config() return sonarr_handler.get_config()
@router.put("/sonarr/config") @router.put("/sonarr/config")
async def update_sonarr_config(config: SonarrConfig): async def update_sonarr_config(
config: SonarrConfig,
current_user: User = Depends(get_current_user_from_token),
):
"""Update Sonarr webhook configuration""" """Update Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
try: try:
@@ -87,14 +92,19 @@ async def update_sonarr_config(config: SonarrConfig):
@router.get("/sonarr/mappings") @router.get("/sonarr/mappings")
async def get_sonarr_mappings(): async def get_sonarr_mappings(
current_user: User = Depends(get_current_user_from_token),
):
"""Get all Sonarr to anime mappings""" """Get all Sonarr to anime mappings"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_mappings() return sonarr_handler.get_mappings()
@router.get("/sonarr/mappings/{series_id}") @router.get("/sonarr/mappings/{series_id}")
async def get_sonarr_mapping(series_id: int): async def get_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Get specific mapping by Sonarr series ID""" """Get specific mapping by Sonarr series ID"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
mapping = sonarr_handler.get_mapping(series_id) mapping = sonarr_handler.get_mapping(series_id)
@@ -104,7 +114,10 @@ async def get_sonarr_mapping(series_id: int):
@router.post("/sonarr/mappings") @router.post("/sonarr/mappings")
async def create_sonarr_mapping(mapping: SonarrMapping): async def create_sonarr_mapping(
mapping: SonarrMapping,
current_user: User = Depends(get_current_user_from_token),
):
"""Create or update a Sonarr to anime mapping""" """Create or update a Sonarr to anime mapping"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
try: try:
@@ -116,7 +129,10 @@ async def create_sonarr_mapping(mapping: SonarrMapping):
@router.delete("/sonarr/mappings/{series_id}") @router.delete("/sonarr/mappings/{series_id}")
async def delete_sonarr_mapping(series_id: int): async def delete_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Delete a Sonarr mapping""" """Delete a Sonarr mapping"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
success = sonarr_handler.delete_mapping(series_id) success = sonarr_handler.delete_mapping(series_id)
@@ -130,6 +146,7 @@ async def search_anime_for_sonarr(
q: str = Query(..., description="Series title to search"), q: str = Query(..., description="Series title to search"),
provider: str = Query("anime-sama", description="Anime provider to search"), provider: str = Query("anime-sama", description="Anime provider to search"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"), lang: str = Query("vostfr", description="Language (vostfr, vf)"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Search for anime on providers to create Sonarr mappings""" """Search for anime on providers to create Sonarr mappings"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -152,6 +169,7 @@ async def get_anime_episodes(
url: str = Query(..., description="Anime URL from provider"), url: str = Query(..., description="Anime URL from provider"),
provider: str = Query("anime-sama", description="Anime provider"), provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"), lang: str = Query("vostfr", description="Language (vostfr, vf)"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Get episode list for anime""" """Get episode list for anime"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -174,6 +192,7 @@ async def suggest_anime_mapping(
sonarr_title: str = Query(..., description="Sonarr series title"), sonarr_title: str = Query(..., description="Sonarr series title"),
provider: str = Query("anime-sama", description="Anime provider"), provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language"), lang: str = Query("vostfr", description="Language"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Suggest possible anime mappings based on Sonarr series title""" """Suggest possible anime mappings based on Sonarr series title"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -195,6 +214,7 @@ async def suggest_anime_mapping(
async def trigger_sonarr_download( async def trigger_sonarr_download(
request: SonarrDownloadRequest, request: SonarrDownloadRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user_from_token),
): ):
"""Manually trigger a download based on Sonarr information""" """Manually trigger a download based on Sonarr information"""
from main import download_manager from main import download_manager