This commit is contained in:
Xoconoch
2025-08-19 09:36:22 -06:00
parent 015ae024a6
commit 6538cde022
2 changed files with 393 additions and 350 deletions

90
app.py
View File

@@ -8,7 +8,6 @@ import logging.handlers
import time import time
from pathlib import Path from pathlib import Path
import os import os
import atexit
import sys import sys
import redis import redis
import socket import socket
@@ -17,10 +16,15 @@ from urllib.parse import urlparse
# Run DB migrations as early as possible, before importing any routers that may touch DBs # Run DB migrations as early as possible, before importing any routers that may touch DBs
try: try:
from routes.migrations import run_migrations_if_needed from routes.migrations import run_migrations_if_needed
run_migrations_if_needed() run_migrations_if_needed()
logging.getLogger(__name__).info("Database migrations executed (if needed) early in startup.") logging.getLogger(__name__).info(
"Database migrations executed (if needed) early in startup."
)
except Exception as e: except Exception as e:
logging.getLogger(__name__).error(f"Database migration step failed early in startup: {e}", exc_info=True) logging.getLogger(__name__).error(
f"Database migration step failed early in startup: {e}", exc_info=True
)
# Import route routers (to be created) # Import route routers (to be created)
from routes.auth.credentials import router as credentials_router from routes.auth.credentials import router as credentials_router
@@ -44,7 +48,6 @@ from routes.auth import AUTH_ENABLED
from routes.auth.middleware import AuthMiddleware from routes.auth.middleware import AuthMiddleware
# Import and initialize routes (this will start the watch manager) # Import and initialize routes (this will start the watch manager)
import routes
# Configure application-wide logging # Configure application-wide logging
@@ -151,7 +154,9 @@ async def lifespan(app: FastAPI):
# Check Redis connection # Check Redis connection
if not check_redis_connection(): if not check_redis_connection():
logging.error("Failed to connect to Redis. Please ensure Redis is running and accessible.") logging.error(
"Failed to connect to Redis. Please ensure Redis is running and accessible."
)
# Don't exit, but warn - some functionality may not work # Don't exit, but warn - some functionality may not work
# Start Celery workers # Start Celery workers
@@ -177,7 +182,7 @@ def create_app():
description="Music download service API", description="Music download service API",
version="3.0.0", version="3.0.0",
lifespan=lifespan, lifespan=lifespan,
redirect_slashes=True # Enable automatic trailing slash redirects redirect_slashes=True, # Enable automatic trailing slash redirects
) )
# Set up CORS # Set up CORS
@@ -202,13 +207,16 @@ def create_app():
# Include SSO router if available # Include SSO router if available
try: try:
from routes.auth.sso import router as sso_router from routes.auth.sso import router as sso_router
app.include_router(sso_router, prefix="/api/auth", tags=["sso"]) app.include_router(sso_router, prefix="/api/auth", tags=["sso"])
logging.info("SSO functionality enabled") logging.info("SSO functionality enabled")
except ImportError as e: except ImportError as e:
logging.warning(f"SSO functionality not available: {e}") logging.warning(f"SSO functionality not available: {e}")
app.include_router(config_router, prefix="/api/config", tags=["config"]) app.include_router(config_router, prefix="/api/config", tags=["config"])
app.include_router(search_router, prefix="/api/search", tags=["search"]) app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(credentials_router, prefix="/api/credentials", tags=["credentials"]) app.include_router(
credentials_router, prefix="/api/credentials", tags=["credentials"]
)
app.include_router(album_router, prefix="/api/album", tags=["album"]) app.include_router(album_router, prefix="/api/album", tags=["album"])
app.include_router(track_router, prefix="/api/track", tags=["track"]) app.include_router(track_router, prefix="/api/track", tags=["track"])
app.include_router(playlist_router, prefix="/api/playlist", tags=["playlist"]) app.include_router(playlist_router, prefix="/api/playlist", tags=["playlist"])
@@ -245,19 +253,69 @@ def create_app():
# Serve React App - catch-all route for SPA (but not for API routes) # Serve React App - catch-all route for SPA (but not for API routes)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def serve_react_app(full_path: str): async def serve_react_app(full_path: str):
"""Serve React app with fallback to index.html for SPA routing""" """Serve React app with fallback to index.html for SPA routing. Prevent directory traversal."""
static_dir = "spotizerr-ui/dist" static_dir = "spotizerr-ui/dist"
static_dir_path = Path(static_dir).resolve()
index_path = static_dir_path / "index.html"
allowed_exts = {
".html",
".js",
".css",
".map",
".png",
".jpg",
".jpeg",
".svg",
".webp",
".ico",
".json",
".txt",
".woff",
".woff2",
".ttf",
".eot",
".mp3",
".ogg",
".mp4",
".webm",
}
# Don't serve React app for API routes (more specific check) # Don't serve React app for API routes (more specific check)
if full_path.startswith("api") or full_path.startswith("api/"): if full_path.startswith("api") or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found") raise HTTPException(status_code=404, detail="API endpoint not found")
# If it's a file that exists, serve it # Reject null bytes early
if full_path and os.path.exists(os.path.join(static_dir, full_path)): if "\x00" in full_path:
return FileResponse(os.path.join(static_dir, full_path)) return FileResponse(str(index_path))
# Sanitize path: normalize backslashes and strip URL schemes
sanitized = full_path.replace("\\", "/").lstrip("/")
if sanitized.startswith("http://") or sanitized.startswith("https://"):
return FileResponse(str(index_path))
# Resolve requested path safely and ensure it stays within static_dir
try:
requested_path = (static_dir_path / sanitized).resolve()
except Exception:
requested_path = index_path
# If traversal attempted or non-file within static dir, fall back to index.html for SPA routing
if not str(requested_path).startswith(str(static_dir_path)):
return FileResponse(str(index_path))
# Disallow hidden files (starting with dot) and enforce safe extensions
if requested_path.is_file():
name = requested_path.name
if name.startswith("."):
return FileResponse(str(index_path))
suffix = requested_path.suffix.lower()
if suffix in allowed_exts:
return FileResponse(str(requested_path))
# Not an allowed asset; fall back to SPA index
return FileResponse(str(index_path))
else: else:
# Fallback to index.html for SPA routing # Fallback to index.html for SPA routing
return FileResponse(os.path.join(static_dir, "index.html")) return FileResponse(str(index_path))
else: else:
logging.warning("React app build directory not found at spotizerr-ui/dist") logging.warning("React app build directory not found at spotizerr-ui/dist")
@@ -285,10 +343,4 @@ if __name__ == "__main__":
except ValueError: except ValueError:
port = 7171 port = 7171
uvicorn.run( uvicorn.run(app, host=host, port=port, log_level="info", access_log=True)
app,
host=host,
port=port,
log_level="info",
access_log=True
)

View File

@@ -1,9 +1,8 @@
from fastapi import APIRouter, HTTPException, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import json
import traceback
import logging import logging
from routes.utils.history_manager import history_manager from routes.utils.history_manager import history_manager
from typing import Any, Dict
# Import authentication dependencies # Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User from routes.auth.middleware import require_auth_from_state, User
@@ -15,7 +14,9 @@ router = APIRouter()
@router.get("/") @router.get("/")
@router.get("") @router.get("")
async def get_history(request: Request, current_user: User = Depends(require_auth_from_state)): async def get_history(
request: Request, current_user: User = Depends(require_auth_from_state)
):
""" """
Retrieve download history with optional filtering and pagination. Retrieve download history with optional filtering and pagination.
@@ -36,8 +37,10 @@ async def get_history(request: Request, current_user: User = Depends(require_aut
valid_types = ["track", "album", "playlist"] valid_types = ["track", "album", "playlist"]
if download_type and download_type not in valid_types: if download_type and download_type not in valid_types:
return JSONResponse( return JSONResponse(
content={"error": f"Invalid download_type. Must be one of: {valid_types}"}, content={
status_code=400 "error": f"Invalid download_type. Must be one of: {valid_types}"
},
status_code=400,
) )
# Validate status if provided # Validate status if provided
@@ -45,54 +48,50 @@ async def get_history(request: Request, current_user: User = Depends(require_aut
if status and status not in valid_statuses: if status and status not in valid_statuses:
return JSONResponse( return JSONResponse(
content={"error": f"Invalid status. Must be one of: {valid_statuses}"}, content={"error": f"Invalid status. Must be one of: {valid_statuses}"},
status_code=400 status_code=400,
) )
# Get history from manager # Get history from manager
history = history_manager.get_download_history( history = history_manager.get_download_history(
limit=limit, limit=limit, offset=offset, download_type=download_type, status=status
offset=offset,
download_type=download_type,
status=status
) )
# Add pagination info # Add pagination info
response_data = { response_data: Dict[str, Any] = {
"downloads": history, "downloads": history,
"pagination": { "pagination": {
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"returned_count": len(history) "returned_count": len(history),
} },
} }
filters: Dict[str, Any] = {}
if download_type: if download_type:
response_data["filters"] = {"download_type": download_type} filters["download_type"] = download_type
if status: if status:
if "filters" not in response_data: filters["status"] = status
response_data["filters"] = {} if filters:
response_data["filters"]["status"] = status response_data["filters"] = filters
return JSONResponse( return JSONResponse(content=response_data, status_code=200)
content=response_data,
status_code=200
)
except ValueError as e: except ValueError as e:
return JSONResponse( return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"}, content={"error": f"Invalid parameter value: {str(e)}"}, status_code=400
status_code=400
) )
except Exception as e: except Exception as e:
logger.error(f"Error retrieving download history: {e}", exc_info=True) logger.error(f"Error retrieving download history: {e}", exc_info=True)
return JSONResponse( return JSONResponse(
content={"error": "Failed to retrieve download history", "details": str(e)}, content={"error": "Failed to retrieve download history", "details": str(e)},
status_code=500 status_code=500,
) )
@router.get("/{task_id}") @router.get("/{task_id}")
async def get_download_by_task_id(task_id: str, current_user: User = Depends(require_auth_from_state)): async def get_download_by_task_id(
task_id: str, current_user: User = Depends(require_auth_from_state)
):
""" """
Retrieve specific download history by task ID. Retrieve specific download history by task ID.
@@ -105,24 +104,25 @@ async def get_download_by_task_id(task_id: str, current_user: User = Depends(req
if not download: if not download:
return JSONResponse( return JSONResponse(
content={"error": f"Download with task ID '{task_id}' not found"}, content={"error": f"Download with task ID '{task_id}' not found"},
status_code=404 status_code=404,
) )
return JSONResponse( return JSONResponse(content=download, status_code=200)
content=download,
status_code=200
)
except Exception as e: except Exception as e:
logger.error(f"Error retrieving download for task {task_id}: {e}", exc_info=True) logger.error(
f"Error retrieving download for task {task_id}: {e}", exc_info=True
)
return JSONResponse( return JSONResponse(
content={"error": "Failed to retrieve download", "details": str(e)}, content={"error": "Failed to retrieve download", "details": str(e)},
status_code=500 status_code=500,
) )
@router.get("/{task_id}/children") @router.get("/{task_id}/children")
async def get_download_children(task_id: str, current_user: User = Depends(require_auth_from_state)): async def get_download_children(
task_id: str, current_user: User = Depends(require_auth_from_state)
):
""" """
Retrieve children tracks for an album or playlist download. Retrieve children tracks for an album or playlist download.
@@ -136,14 +136,14 @@ async def get_download_children(task_id: str, current_user: User = Depends(requi
if not download: if not download:
return JSONResponse( return JSONResponse(
content={"error": f"Download with task ID '{task_id}' not found"}, content={"error": f"Download with task ID '{task_id}' not found"},
status_code=404 status_code=404,
) )
children_table = download.get("children_table") children_table = download.get("children_table")
if not children_table: if not children_table:
return JSONResponse( return JSONResponse(
content={"error": f"Download '{task_id}' has no children tracks"}, content={"error": f"Download '{task_id}' has no children tracks"},
status_code=404 status_code=404,
) )
# Get children tracks # Get children tracks
@@ -155,19 +155,21 @@ async def get_download_children(task_id: str, current_user: User = Depends(requi
"title": download.get("title"), "title": download.get("title"),
"children_table": children_table, "children_table": children_table,
"tracks": children, "tracks": children,
"track_count": len(children) "track_count": len(children),
} }
return JSONResponse( return JSONResponse(content=response_data, status_code=200)
content=response_data,
status_code=200
)
except Exception as e: except Exception as e:
logger.error(f"Error retrieving children for task {task_id}: {e}", exc_info=True) logger.error(
f"Error retrieving children for task {task_id}: {e}", exc_info=True
)
return JSONResponse( return JSONResponse(
content={"error": "Failed to retrieve download children", "details": str(e)}, content={
status_code=500 "error": "Failed to retrieve download children",
"details": str(e),
},
status_code=500,
) )
@@ -179,21 +181,23 @@ async def get_download_stats(current_user: User = Depends(require_auth_from_stat
try: try:
stats = history_manager.get_download_stats() stats = history_manager.get_download_stats()
return JSONResponse( return JSONResponse(content=stats, status_code=200)
content=stats,
status_code=200
)
except Exception as e: except Exception as e:
logger.error(f"Error retrieving download stats: {e}", exc_info=True) logger.error(f"Error retrieving download stats: {e}", exc_info=True)
return JSONResponse( return JSONResponse(
content={"error": "Failed to retrieve download statistics", "details": str(e)}, content={
status_code=500 "error": "Failed to retrieve download statistics",
"details": str(e),
},
status_code=500,
) )
@router.get("/search") @router.get("/search")
async def search_history(request: Request, current_user: User = Depends(require_auth_from_state)): async def search_history(
request: Request, current_user: User = Depends(require_auth_from_state)
):
""" """
Search download history by title or artist. Search download history by title or artist.
@@ -206,7 +210,7 @@ async def search_history(request: Request, current_user: User = Depends(require_
if not query: if not query:
return JSONResponse( return JSONResponse(
content={"error": "Missing required parameter: q (search query)"}, content={"error": "Missing required parameter: q (search query)"},
status_code=400 status_code=400,
) )
limit = min(int(request.query_params.get("limit", 50)), 200) # Cap at 200 limit = min(int(request.query_params.get("limit", 50)), 200) # Cap at 200
@@ -218,29 +222,27 @@ async def search_history(request: Request, current_user: User = Depends(require_
"query": query, "query": query,
"results": results, "results": results,
"result_count": len(results), "result_count": len(results),
"limit": limit "limit": limit,
} }
return JSONResponse( return JSONResponse(content=response_data, status_code=200)
content=response_data,
status_code=200
)
except ValueError as e: except ValueError as e:
return JSONResponse( return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"}, content={"error": f"Invalid parameter value: {str(e)}"}, status_code=400
status_code=400
) )
except Exception as e: except Exception as e:
logger.error(f"Error searching download history: {e}", exc_info=True) logger.error(f"Error searching download history: {e}", exc_info=True)
return JSONResponse( return JSONResponse(
content={"error": "Failed to search download history", "details": str(e)}, content={"error": "Failed to search download history", "details": str(e)},
status_code=500 status_code=500,
) )
@router.get("/recent") @router.get("/recent")
async def get_recent_downloads(request: Request, current_user: User = Depends(require_auth_from_state)): async def get_recent_downloads(
request: Request, current_user: User = Depends(require_auth_from_state)
):
""" """
Get most recent downloads. Get most recent downloads.
@@ -252,32 +254,26 @@ async def get_recent_downloads(request: Request, current_user: User = Depends(re
recent = history_manager.get_recent_downloads(limit) recent = history_manager.get_recent_downloads(limit)
response_data = { response_data = {"downloads": recent, "count": len(recent), "limit": limit}
"downloads": recent,
"count": len(recent),
"limit": limit
}
return JSONResponse( return JSONResponse(content=response_data, status_code=200)
content=response_data,
status_code=200
)
except ValueError as e: except ValueError as e:
return JSONResponse( return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"}, content={"error": f"Invalid parameter value: {str(e)}"}, status_code=400
status_code=400
) )
except Exception as e: except Exception as e:
logger.error(f"Error retrieving recent downloads: {e}", exc_info=True) logger.error(f"Error retrieving recent downloads: {e}", exc_info=True)
return JSONResponse( return JSONResponse(
content={"error": "Failed to retrieve recent downloads", "details": str(e)}, content={"error": "Failed to retrieve recent downloads", "details": str(e)},
status_code=500 status_code=500,
) )
@router.get("/failed") @router.get("/failed")
async def get_failed_downloads(request: Request, current_user: User = Depends(require_auth_from_state)): async def get_failed_downloads(
request: Request, current_user: User = Depends(require_auth_from_state)
):
""" """
Get failed downloads. Get failed downloads.
@@ -289,32 +285,26 @@ async def get_failed_downloads(request: Request, current_user: User = Depends(re
failed = history_manager.get_failed_downloads(limit) failed = history_manager.get_failed_downloads(limit)
response_data = { response_data = {"downloads": failed, "count": len(failed), "limit": limit}
"downloads": failed,
"count": len(failed),
"limit": limit
}
return JSONResponse( return JSONResponse(content=response_data, status_code=200)
content=response_data,
status_code=200
)
except ValueError as e: except ValueError as e:
return JSONResponse( return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"}, content={"error": f"Invalid parameter value: {str(e)}"}, status_code=400
status_code=400
) )
except Exception as e: except Exception as e:
logger.error(f"Error retrieving failed downloads: {e}", exc_info=True) logger.error(f"Error retrieving failed downloads: {e}", exc_info=True)
return JSONResponse( return JSONResponse(
content={"error": "Failed to retrieve failed downloads", "details": str(e)}, content={"error": "Failed to retrieve failed downloads", "details": str(e)},
status_code=500 status_code=500,
) )
@router.post("/cleanup") @router.post("/cleanup")
async def cleanup_old_history(request: Request, current_user: User = Depends(require_auth_from_state)): async def cleanup_old_history(
request: Request, current_user: User = Depends(require_auth_from_state)
):
""" """
Clean up old download history. Clean up old download history.
@@ -322,31 +312,32 @@ async def cleanup_old_history(request: Request, current_user: User = Depends(req
- days_old: Number of days old to keep (default: 30) - days_old: Number of days old to keep (default: 30)
""" """
try: try:
data = await request.json() if request.headers.get("content-type") == "application/json" else {} data = (
await request.json()
if request.headers.get("content-type") == "application/json"
else {}
)
days_old = data.get("days_old", 30) days_old = data.get("days_old", 30)
if not isinstance(days_old, int) or days_old <= 0: if not isinstance(days_old, int) or days_old <= 0:
return JSONResponse( return JSONResponse(
content={"error": "days_old must be a positive integer"}, content={"error": "days_old must be a positive integer"},
status_code=400 status_code=400,
) )
deleted_count = history_manager.clear_old_history(days_old) deleted_count = history_manager.clear_old_history(days_old)
response_data = { response_data = {
"message": f"Successfully cleaned up old download history", "message": "Successfully cleaned up old download history",
"deleted_records": deleted_count, "deleted_records": deleted_count,
"days_old": days_old "days_old": days_old,
} }
return JSONResponse( return JSONResponse(content=response_data, status_code=200)
content=response_data,
status_code=200
)
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up old history: {e}", exc_info=True) logger.error(f"Error cleaning up old history: {e}", exc_info=True)
return JSONResponse( return JSONResponse(
content={"error": "Failed to cleanup old history", "details": str(e)}, content={"error": "Failed to cleanup old history", "details": str(e)},
status_code=500 status_code=500,
) )