Hotfix
This commit is contained in:
13
.env.example
13
.env.example
@@ -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
13
app.py
@@ -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))
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 => ({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -140,17 +140,27 @@ 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;
|
||||||
const token = authApiClient.getToken();
|
|
||||||
if (!token) {
|
// Only check for auth token if auth is enabled
|
||||||
console.warn("SSE: No auth token available, skipping connection");
|
if (authEnabled) {
|
||||||
return;
|
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
|
sseConnection.current = eventSource;
|
||||||
const sseUrl = `/api/prgs/stream?token=${encodeURIComponent(token)}`;
|
|
||||||
const eventSource = new EventSource(sseUrl);
|
|
||||||
sseConnection.current = eventSource;
|
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
console.log("SSE connected successfully");
|
console.log("SSE connected successfully");
|
||||||
@@ -364,14 +374,16 @@ 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
|
||||||
const token = authApiClient.getToken();
|
if (authEnabled) {
|
||||||
if (!token) {
|
const token = authApiClient.getToken();
|
||||||
console.warn("SSE: Connection error and no auth token - stopping reconnection attempts");
|
if (!token) {
|
||||||
eventSource.close();
|
console.warn("SSE: Connection error and no auth token - stopping reconnection attempts");
|
||||||
sseConnection.current = null;
|
eventSource.close();
|
||||||
stopHealthCheck();
|
sseConnection.current = null;
|
||||||
return;
|
stopHealthCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,29 +57,40 @@ class AuthApiClient {
|
|||||||
(error) => {
|
(error) => {
|
||||||
// Handle authentication errors
|
// Handle authentication errors
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// Only clear token for auth-related endpoints
|
// Only process auth errors if auth is enabled
|
||||||
const requestUrl = error.config?.url || "";
|
if (this.authEnabled) {
|
||||||
const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth");
|
// Only clear token for auth-related endpoints
|
||||||
|
const requestUrl = error.config?.url || "";
|
||||||
if (isAuthEndpoint) {
|
const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth");
|
||||||
// Clear invalid token only for auth endpoints
|
|
||||||
this.clearToken();
|
|
||||||
|
|
||||||
// Only show auth error if auth is enabled and not during initial token check
|
if (isAuthEndpoint) {
|
||||||
if (error.response?.data?.auth_enabled && !this.isCheckingToken) {
|
// Clear invalid token only for auth endpoints
|
||||||
toast.error("Session Expired", {
|
this.clearToken();
|
||||||
description: "Please log in again to continue.",
|
|
||||||
});
|
// 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 {
|
} else {
|
||||||
// For non-auth endpoints, just log the 401 but don't clear token
|
// Auth is disabled, 401 errors are expected for auth endpoints
|
||||||
// The token might still be valid for auth endpoints
|
console.log("401 error received but auth is disabled - this is expected");
|
||||||
console.log(`401 error on non-auth endpoint: ${requestUrl}`);
|
|
||||||
}
|
}
|
||||||
} else if (error.response?.status === 403) {
|
} else if (error.response?.status === 403) {
|
||||||
toast.error("Access Denied", {
|
// Only show access denied errors if auth is enabled
|
||||||
description: "You don't have permission to perform this action.",
|
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") {
|
} 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user