diff --git a/.env.example b/.env.example index 2d26fa4..249c52d 100644 --- a/.env.example +++ b/.env.example @@ -22,12 +22,19 @@ UMASK=0022 # Enable authentication ENABLE_AUTH=true -# Basic Authentication settings -JWT_SECRET=your-super-secret-jwt-key-change-in-production -JWT_EXPIRATION_HOURS=24 +# Basic Authentication settings. CHANGE THE JWT_SECRET +JWT_SECRET=long-random-text + +# How much a session persists, in hours. 720h = 30 days. +JWT_EXPIRATION_HOURS=720 + +# Default admins creds, please change the password or delete this account after you create your own DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_PASSWORD=admin123 +# Whether to allow new users to register themselves or leave that only available for admins +DISABLE_REGISTRATION=false + # SSO Configuration SSO_ENABLED=true SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback diff --git a/app.py b/app.py index 5b96344..73f75ea 100755 --- a/app.py +++ b/app.py @@ -168,7 +168,8 @@ def create_app(): title="Spotizerr API", description="Music download service API", version="1.0.0", - lifespan=lifespan + lifespan=lifespan, + redirect_slashes=True # Enable automatic trailing slash redirects ) # Set up CORS @@ -197,8 +198,8 @@ def create_app(): logging.info("SSO functionality enabled") except ImportError as e: logging.warning(f"SSO functionality not available: {e}") - app.include_router(config_router, prefix="/api", tags=["config"]) - app.include_router(search_router, prefix="/api", tags=["search"]) + app.include_router(config_router, prefix="/api/config", tags=["config"]) + app.include_router(search_router, prefix="/api/search", tags=["search"]) app.include_router(credentials_router, prefix="/api/credentials", tags=["credentials"]) app.include_router(album_router, prefix="/api/album", tags=["album"]) app.include_router(track_router, prefix="/api/track", tags=["track"]) @@ -233,12 +234,16 @@ def create_app(): if os.path.exists("spotizerr-ui/dist"): app.mount("/static", StaticFiles(directory="spotizerr-ui/dist"), name="static") - # Serve React App - catch-all route for SPA + # Serve React App - catch-all route for SPA (but not for API routes) @app.get("/{full_path:path}") async def serve_react_app(full_path: str): """Serve React app with fallback to index.html for SPA routing""" static_dir = "spotizerr-ui/dist" + # Don't serve React app for API routes (more specific check) + if full_path.startswith("api") or full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="API endpoint not found") + # If it's a file that exists, serve it if full_path and os.path.exists(os.path.join(static_dir, full_path)): return FileResponse(os.path.join(static_dir, full_path)) diff --git a/routes/auth/middleware.py b/routes/auth/middleware.py index fb0c1ed..18073b3 100644 --- a/routes/auth/middleware.py +++ b/routes/auth/middleware.py @@ -167,6 +167,9 @@ async def require_auth_from_state(request: Request) -> User: # Dependency function to require admin role async def require_admin_from_state(request: Request) -> User: """Require admin role using request state""" + if not AUTH_ENABLED: + return User(username="system", role="admin") + user = await require_auth_from_state(request) if user.role != "admin": diff --git a/routes/core/search.py b/routes/core/search.py index b30b8ef..19dfcf5 100755 --- a/routes/core/search.py +++ b/routes/core/search.py @@ -11,7 +11,8 @@ logger = logging.getLogger(__name__) router = APIRouter() -@router.get("/search") +@router.get("/") +@router.get("") async def handle_search(request: Request, current_user: User = Depends(require_auth_from_state)): """ Handle search requests for tracks, albums, playlists, or artists. diff --git a/routes/system/config.py b/routes/system/config.py index f33b409..2512d94 100644 --- a/routes/system/config.py +++ b/routes/system/config.py @@ -210,6 +210,7 @@ def save_watch_config_http(watch_config_data): # Renamed @router.get("/") +@router.get("") async def handle_config(current_user: User = Depends(require_admin_from_state)): """Handles GET requests for the main configuration.""" try: diff --git a/spotizerr-ui/src/components/Queue.tsx b/spotizerr-ui/src/components/Queue.tsx index e79fa03..19cbb1f 100644 --- a/spotizerr-ui/src/components/Queue.tsx +++ b/spotizerr-ui/src/components/Queue.tsx @@ -1,7 +1,7 @@ import { useContext, useState, useRef, useEffect } from "react"; import { FaTimes, FaSync, FaCheckCircle, FaExclamationCircle, FaHourglassHalf, FaMusic, FaCompactDisc, FaStepForward } from "react-icons/fa"; import { QueueContext, type QueueItem, getStatus, getProgress, getCurrentTrackInfo, isActiveStatus, isTerminalStatus } from "@/contexts/queue-context"; -import { authApiClient } from "@/lib/api-client"; +import { useAuth } from "@/contexts/auth-context"; // Circular Progress Component const CircularProgress = ({ @@ -452,9 +452,10 @@ const QueueItemCard = ({ item, cachedStatus }: { item: QueueItem, cachedStatus: export const Queue = () => { const context = useContext(QueueContext); + const { authEnabled, isAuthenticated } = useAuth(); - // Check if user is authenticated - const hasValidToken = authApiClient.getToken() !== null; + // Check if user is authenticated (only relevant when auth is enabled) + const isUserAuthenticated = !authEnabled || isAuthenticated; const [startY, setStartY] = useState(null); const [isDragging, setIsDragging] = useState(false); @@ -751,7 +752,7 @@ export const Queue = () => { }; }, [isVisible]); - if (!context || !isVisible || !hasValidToken) return null; + if (!context || !isVisible || !isUserAuthenticated) return null; // Optimize: Calculate status once per item and reuse throughout render const itemsWithStatus = items.map(item => ({ diff --git a/spotizerr-ui/src/contexts/AuthProvider.tsx b/spotizerr-ui/src/contexts/AuthProvider.tsx index d192084..ab6a03e 100644 --- a/spotizerr-ui/src/contexts/AuthProvider.tsx +++ b/spotizerr-ui/src/contexts/AuthProvider.tsx @@ -335,6 +335,12 @@ export function AuthProvider({ children }: AuthProviderProps) { return () => window.removeEventListener("storage", handleStorageChange); }, [initializeAuth]); + // Update API client when auth enabled state changes + useEffect(() => { + authApiClient.setAuthEnabled(authEnabled); + console.log(`API client auth enabled state updated: ${authEnabled}`); + }, [authEnabled]); + // Enhanced context value with new methods const contextValue = { // State diff --git a/spotizerr-ui/src/contexts/QueueProvider.tsx b/spotizerr-ui/src/contexts/QueueProvider.tsx index 365a3dc..9c793c4 100644 --- a/spotizerr-ui/src/contexts/QueueProvider.tsx +++ b/spotizerr-ui/src/contexts/QueueProvider.tsx @@ -140,17 +140,27 @@ export function QueueProvider({ children }: { children: ReactNode }) { if (sseConnection.current) return; try { - // Check if we have a valid token before connecting - const token = authApiClient.getToken(); - if (!token) { - console.warn("SSE: No auth token available, skipping connection"); - return; + let eventSource: EventSource; + + // Only check for auth token if auth is enabled + if (authEnabled) { + const token = authApiClient.getToken(); + if (!token) { + console.warn("SSE: Auth is enabled but no auth token available, skipping connection"); + return; + } + + // Include token as query parameter for SSE authentication + const sseUrl = `/api/prgs/stream?token=${encodeURIComponent(token)}`; + eventSource = new EventSource(sseUrl); + } else { + // Auth is disabled, connect without token + console.log("SSE: Auth disabled, connecting without token"); + const sseUrl = `/api/prgs/stream`; + eventSource = new EventSource(sseUrl); } - - // Include token as query parameter for SSE authentication - const sseUrl = `/api/prgs/stream?token=${encodeURIComponent(token)}`; - const eventSource = new EventSource(sseUrl); - sseConnection.current = eventSource; + + sseConnection.current = eventSource; eventSource.onopen = () => { console.log("SSE connected successfully"); @@ -364,14 +374,16 @@ export function QueueProvider({ children }: { children: ReactNode }) { console.warn("SSE connection error:", error); } - // Check if this might be an auth error by testing if we still have a valid token - const token = authApiClient.getToken(); - if (!token) { - console.warn("SSE: Connection error and no auth token - stopping reconnection attempts"); - eventSource.close(); - sseConnection.current = null; - stopHealthCheck(); - return; + // Only check for auth errors if auth is enabled + if (authEnabled) { + const token = authApiClient.getToken(); + if (!token) { + console.warn("SSE: Connection error and no auth token - stopping reconnection attempts"); + eventSource.close(); + sseConnection.current = null; + stopHealthCheck(); + return; + } } eventSource.close(); @@ -410,7 +422,7 @@ export function QueueProvider({ children }: { children: ReactNode }) { toast.error("Failed to establish connection"); } } - }, [createQueueItemFromTask, scheduleRemoval, startHealthCheck]); + }, [createQueueItemFromTask, scheduleRemoval, startHealthCheck, authEnabled]); const disconnectSSE = useCallback(() => { if (sseConnection.current) { diff --git a/spotizerr-ui/src/lib/api-client.ts b/spotizerr-ui/src/lib/api-client.ts index f97c621..11bf3b1 100644 --- a/spotizerr-ui/src/lib/api-client.ts +++ b/spotizerr-ui/src/lib/api-client.ts @@ -15,6 +15,7 @@ class AuthApiClient { private apiClient: AxiosInstance; private token: string | null = null; private isCheckingToken: boolean = false; + private authEnabled: boolean = false; // Track if auth is enabled constructor() { this.apiClient = axios.create({ @@ -31,7 +32,8 @@ class AuthApiClient { // Request interceptor to add auth token this.apiClient.interceptors.request.use( (config) => { - if (this.token) { + // Only add auth header if auth is enabled and we have a token + if (this.authEnabled && this.token) { config.headers.Authorization = `Bearer ${this.token}`; } return config; @@ -55,29 +57,40 @@ class AuthApiClient { (error) => { // Handle authentication errors if (error.response?.status === 401) { - // Only clear token for auth-related endpoints - const requestUrl = error.config?.url || ""; - const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth"); - - if (isAuthEndpoint) { - // Clear invalid token only for auth endpoints - this.clearToken(); + // Only process auth errors if auth is enabled + if (this.authEnabled) { + // Only clear token for auth-related endpoints + const requestUrl = error.config?.url || ""; + const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth"); - // Only show auth error if auth is enabled and not during initial token check - if (error.response?.data?.auth_enabled && !this.isCheckingToken) { - toast.error("Session Expired", { - description: "Please log in again to continue.", - }); + if (isAuthEndpoint) { + // Clear invalid token only for auth endpoints + this.clearToken(); + + // Only show auth error if not during initial token check + if (!this.isCheckingToken) { + toast.error("Session Expired", { + description: "Please log in again to continue.", + }); + } + } else { + // For non-auth endpoints, just log the 401 but don't clear token + // The token might still be valid for auth endpoints + console.log(`401 error on non-auth endpoint: ${requestUrl}`); } } else { - // For non-auth endpoints, just log the 401 but don't clear token - // The token might still be valid for auth endpoints - console.log(`401 error on non-auth endpoint: ${requestUrl}`); + // Auth is disabled, 401 errors are expected for auth endpoints + console.log("401 error received but auth is disabled - this is expected"); } } else if (error.response?.status === 403) { - toast.error("Access Denied", { - description: "You don't have permission to perform this action.", - }); + // Only show access denied errors if auth is enabled + if (this.authEnabled) { + toast.error("Access Denied", { + description: "You don't have permission to perform this action.", + }); + } else { + console.log("403 error received but auth is disabled - this may be expected"); + } } else if (error.code === "ECONNABORTED") { toast.error("Request Timed Out", { description: "The server did not respond in time. Please try again later.", @@ -342,6 +355,15 @@ class AuthApiClient { return `/api/auth/sso/login/${provider}`; } + // Method to set auth enabled state (to be called by AuthProvider) + setAuthEnabled(enabled: boolean) { + this.authEnabled = enabled; + } + + getAuthEnabled(): boolean { + return this.authEnabled; + } + // Expose the underlying axios instance for other API calls get client() { return this.apiClient;