This commit is contained in:
Xoconoch
2025-08-05 12:01:30 -06:00
parent c2660ccff1
commit 9b8cb025b2
9 changed files with 108 additions and 50 deletions

View File

@@ -22,12 +22,19 @@ UMASK=0022
# Enable authentication # Enable authentication
ENABLE_AUTH=true ENABLE_AUTH=true
# Basic Authentication settings # Basic Authentication settings. CHANGE THE JWT_SECRET
JWT_SECRET=your-super-secret-jwt-key-change-in-production JWT_SECRET=long-random-text
JWT_EXPIRATION_HOURS=24
# 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_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123 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 Configuration
SSO_ENABLED=true SSO_ENABLED=true
SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback

13
app.py
View File

@@ -168,7 +168,8 @@ def create_app():
title="Spotizerr API", title="Spotizerr API",
description="Music download service API", description="Music download service API",
version="1.0.0", version="1.0.0",
lifespan=lifespan lifespan=lifespan,
redirect_slashes=True # Enable automatic trailing slash redirects
) )
# Set up CORS # Set up CORS
@@ -197,8 +198,8 @@ def create_app():
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", tags=["config"]) app.include_router(config_router, prefix="/api/config", tags=["config"])
app.include_router(search_router, prefix="/api", 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"])
@@ -233,12 +234,16 @@ def create_app():
if os.path.exists("spotizerr-ui/dist"): if os.path.exists("spotizerr-ui/dist"):
app.mount("/static", StaticFiles(directory="spotizerr-ui/dist"), name="static") 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}") @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"""
static_dir = "spotizerr-ui/dist" 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 it's a file that exists, serve it
if full_path and os.path.exists(os.path.join(static_dir, full_path)): if full_path and os.path.exists(os.path.join(static_dir, full_path)):
return FileResponse(os.path.join(static_dir, full_path)) return FileResponse(os.path.join(static_dir, full_path))

View File

@@ -167,6 +167,9 @@ async def require_auth_from_state(request: Request) -> User:
# Dependency function to require admin role # Dependency function to require admin role
async def require_admin_from_state(request: Request) -> User: async def require_admin_from_state(request: Request) -> User:
"""Require admin role using request state""" """Require admin role using request state"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
user = await require_auth_from_state(request) user = await require_auth_from_state(request)
if user.role != "admin": if user.role != "admin":

View File

@@ -11,7 +11,8 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.get("/search") @router.get("/")
@router.get("")
async def handle_search(request: Request, current_user: User = Depends(require_auth_from_state)): async def handle_search(request: Request, current_user: User = Depends(require_auth_from_state)):
""" """
Handle search requests for tracks, albums, playlists, or artists. Handle search requests for tracks, albums, playlists, or artists.

View File

@@ -210,6 +210,7 @@ def save_watch_config_http(watch_config_data): # Renamed
@router.get("/") @router.get("/")
@router.get("")
async def handle_config(current_user: User = Depends(require_admin_from_state)): async def handle_config(current_user: User = Depends(require_admin_from_state)):
"""Handles GET requests for the main configuration.""" """Handles GET requests for the main configuration."""
try: try:

View File

@@ -1,7 +1,7 @@
import { useContext, useState, useRef, useEffect } from "react"; import { useContext, useState, useRef, useEffect } from "react";
import { FaTimes, FaSync, FaCheckCircle, FaExclamationCircle, FaHourglassHalf, FaMusic, FaCompactDisc, FaStepForward } from "react-icons/fa"; 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 { 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 // Circular Progress Component
const CircularProgress = ({ const CircularProgress = ({
@@ -452,9 +452,10 @@ const QueueItemCard = ({ item, cachedStatus }: { item: QueueItem, cachedStatus:
export const Queue = () => { export const Queue = () => {
const context = useContext(QueueContext); const context = useContext(QueueContext);
const { authEnabled, isAuthenticated } = useAuth();
// Check if user is authenticated // Check if user is authenticated (only relevant when auth is enabled)
const hasValidToken = authApiClient.getToken() !== null; const isUserAuthenticated = !authEnabled || isAuthenticated;
const [startY, setStartY] = useState<number | null>(null); const [startY, setStartY] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -751,7 +752,7 @@ export const Queue = () => {
}; };
}, [isVisible]); }, [isVisible]);
if (!context || !isVisible || !hasValidToken) return null; if (!context || !isVisible || !isUserAuthenticated) return null;
// Optimize: Calculate status once per item and reuse throughout render // Optimize: Calculate status once per item and reuse throughout render
const itemsWithStatus = items.map(item => ({ const itemsWithStatus = items.map(item => ({

View File

@@ -335,6 +335,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
return () => window.removeEventListener("storage", handleStorageChange); return () => window.removeEventListener("storage", handleStorageChange);
}, [initializeAuth]); }, [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 // Enhanced context value with new methods
const contextValue = { const contextValue = {
// State // State

View File

@@ -140,16 +140,26 @@ export function QueueProvider({ children }: { children: ReactNode }) {
if (sseConnection.current) return; if (sseConnection.current) return;
try { try {
// Check if we have a valid token before connecting let eventSource: EventSource;
// Only check for auth token if auth is enabled
if (authEnabled) {
const token = authApiClient.getToken(); const token = authApiClient.getToken();
if (!token) { if (!token) {
console.warn("SSE: No auth token available, skipping connection"); console.warn("SSE: Auth is enabled but no auth token available, skipping connection");
return; return;
} }
// Include token as query parameter for SSE authentication // Include token as query parameter for SSE authentication
const sseUrl = `/api/prgs/stream?token=${encodeURIComponent(token)}`; const sseUrl = `/api/prgs/stream?token=${encodeURIComponent(token)}`;
const eventSource = new EventSource(sseUrl); 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);
}
sseConnection.current = eventSource; sseConnection.current = eventSource;
eventSource.onopen = () => { eventSource.onopen = () => {
@@ -364,7 +374,8 @@ export function QueueProvider({ children }: { children: ReactNode }) {
console.warn("SSE connection error:", error); console.warn("SSE connection error:", error);
} }
// Check if this might be an auth error by testing if we still have a valid token // Only check for auth errors if auth is enabled
if (authEnabled) {
const token = authApiClient.getToken(); const token = authApiClient.getToken();
if (!token) { if (!token) {
console.warn("SSE: Connection error and no auth token - stopping reconnection attempts"); console.warn("SSE: Connection error and no auth token - stopping reconnection attempts");
@@ -373,6 +384,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
stopHealthCheck(); stopHealthCheck();
return; return;
} }
}
eventSource.close(); eventSource.close();
sseConnection.current = null; sseConnection.current = null;
@@ -410,7 +422,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
toast.error("Failed to establish connection"); toast.error("Failed to establish connection");
} }
} }
}, [createQueueItemFromTask, scheduleRemoval, startHealthCheck]); }, [createQueueItemFromTask, scheduleRemoval, startHealthCheck, authEnabled]);
const disconnectSSE = useCallback(() => { const disconnectSSE = useCallback(() => {
if (sseConnection.current) { if (sseConnection.current) {

View File

@@ -15,6 +15,7 @@ class AuthApiClient {
private apiClient: AxiosInstance; private apiClient: AxiosInstance;
private token: string | null = null; private token: string | null = null;
private isCheckingToken: boolean = false; private isCheckingToken: boolean = false;
private authEnabled: boolean = false; // Track if auth is enabled
constructor() { constructor() {
this.apiClient = axios.create({ this.apiClient = axios.create({
@@ -31,7 +32,8 @@ class AuthApiClient {
// Request interceptor to add auth token // Request interceptor to add auth token
this.apiClient.interceptors.request.use( this.apiClient.interceptors.request.use(
(config) => { (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}`; config.headers.Authorization = `Bearer ${this.token}`;
} }
return config; return config;
@@ -55,6 +57,8 @@ class AuthApiClient {
(error) => { (error) => {
// Handle authentication errors // Handle authentication errors
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Only process auth errors if auth is enabled
if (this.authEnabled) {
// Only clear token for auth-related endpoints // Only clear token for auth-related endpoints
const requestUrl = error.config?.url || ""; const requestUrl = error.config?.url || "";
const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth"); const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth");
@@ -63,8 +67,8 @@ class AuthApiClient {
// Clear invalid token only for auth endpoints // Clear invalid token only for auth endpoints
this.clearToken(); this.clearToken();
// Only show auth error if auth is enabled and not during initial token check // Only show auth error if not during initial token check
if (error.response?.data?.auth_enabled && !this.isCheckingToken) { if (!this.isCheckingToken) {
toast.error("Session Expired", { toast.error("Session Expired", {
description: "Please log in again to continue.", description: "Please log in again to continue.",
}); });
@@ -74,10 +78,19 @@ class AuthApiClient {
// The token might still be valid for auth endpoints // The token might still be valid for auth endpoints
console.log(`401 error on non-auth endpoint: ${requestUrl}`); console.log(`401 error on non-auth endpoint: ${requestUrl}`);
} }
} else {
// 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) { } else if (error.response?.status === 403) {
// Only show access denied errors if auth is enabled
if (this.authEnabled) {
toast.error("Access Denied", { toast.error("Access Denied", {
description: "You don't have permission to perform this action.", 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") { } else if (error.code === "ECONNABORTED") {
toast.error("Request Timed Out", { toast.error("Request Timed Out", {
description: "The server did not respond in time. Please try again later.", description: "The server did not respond in time. Please try again later.",
@@ -342,6 +355,15 @@ class AuthApiClient {
return `/api/auth/sso/login/${provider}`; 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 // Expose the underlying axios instance for other API calls
get client() { get client() {
return this.apiClient; return this.apiClient;