diff --git a/apps/web/app/(app)/search/SearchComponent.tsx b/apps/web/app/(app)/search/SearchComponent.tsx index e63c5721..1fe73d28 100644 --- a/apps/web/app/(app)/search/SearchComponent.tsx +++ b/apps/web/app/(app)/search/SearchComponent.tsx @@ -12,6 +12,7 @@ import { DialogTrigger, DialogContent } from "@radix-ui/react-dialog"; import ReactPlayer from "react-player"; import Image from "next/image"; import { YoutubeIcon } from "lucide-react"; +import PageGradient from "@/components/Layout/PageGradient"; interface YouTubeVideo { id: string; @@ -119,7 +120,7 @@ export default function SearchComponent() { ) : ( - results && results[0] && + results && results[0] && <> )} diff --git a/apps/web/components/Layout/PageGradient.tsx b/apps/web/components/Layout/PageGradient.tsx index 0080dc35..476ab550 100644 --- a/apps/web/components/Layout/PageGradient.tsx +++ b/apps/web/components/Layout/PageGradient.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useGradientHover } from "../Providers/GradientHoverProvider"; import { FastAverageColor } from "fast-average-color"; +import getBaseURL from "@/lib/Server/getBaseURL"; export default function PageGradient({ imageSrc }: { imageSrc: string }) { const { setGradient } = useGradientHover() @@ -10,8 +11,18 @@ export default function PageGradient({ imageSrc }: { imageSrc: string }) { useEffect(() => { const fac = new FastAverageColor(); const getColor = async () => { - const color = await fac.getColorAsync(imageSrc) - setGradient(color.hex) + const processedImageSrc = !imageSrc.startsWith("http://") && !imageSrc.startsWith("https://") + ? `${getBaseURL()}/image/${encodeURIComponent(imageSrc)}?raw=true` + : imageSrc; + + + try { + const color = await fac.getColorAsync(processedImageSrc) + setGradient(color.hex) + } catch (error) { + console.error('Error getting color:', error) + setGradient('#000000') + } } getColor() diff --git a/apps/web/components/Layout/SplashScreen.tsx b/apps/web/components/Layout/SplashScreen.tsx index 66699c5e..41aa4b2d 100644 --- a/apps/web/components/Layout/SplashScreen.tsx +++ b/apps/web/components/Layout/SplashScreen.tsx @@ -6,6 +6,8 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; import { useSession } from "../Providers/AuthProvider"; +import { isValid } from "@music/sdk"; +import { deleteCookie } from "cookies-next"; interface SplashScreenProps { children: React.ReactNode; @@ -20,21 +22,18 @@ const SplashScreen: React.FC = ({ children }) => { const checkServerUrl = async () => { if (isLoading) return; - const currentPath = window.location.pathname; - if ( - session?.username && - currentPath !== "/" && - currentPath !== "/login" && - currentPath !== "/login/" && - currentPath.startsWith("/home") - ) { - setLoading(false); - return; - } - try { setLoading(true); + const validationResult = await isValid(); + + if (!validationResult.status) { + deleteCookie('plm_accessToken'); + deleteCookie('plm_refreshToken'); + push('/login'); + return; + } + const storedServer = localStorage.getItem("server"); const serverUrl = storedServer ? JSON.parse(storedServer).local_address @@ -54,6 +53,7 @@ const SplashScreen: React.FC = ({ children }) => { } if (session?.username) { + const currentPath = window.location.pathname; if ( currentPath === "/" || currentPath === "/login" || @@ -64,9 +64,11 @@ const SplashScreen: React.FC = ({ children }) => { return; } + const currentPath = window.location.pathname; if (currentPath !== "/login" && currentPath !== "/login/") { push("/login"); } + } catch (error) { console.error("Server check failed:", error); push("/"); diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs index a41be930..3cfcafa0 100644 --- a/crates/backend/src/main.rs +++ b/crates/backend/src/main.rs @@ -10,7 +10,7 @@ use actix_cors::Cors; use actix_web::HttpResponse; use actix_web::{middleware, web, App, HttpServer}; use actix_web_httpauth::middleware::HttpAuthentication; -use routes::authentication::{admin_guard, refresh}; +use routes::authentication::{admin_guard, is_valid, refresh}; use tokio::task; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; @@ -21,8 +21,7 @@ use routes::authentication::{login, register, validator}; use routes::filesystem; use routes::image::image; use routes::music::{ - format_contributing_artists_route, index_library_no_cover_url, process_library, songs_list, - stream_song, test, + index_library_no_cover_url, process_library, }; use routes::playlist; use routes::search::{self, populate_search_data}; @@ -188,6 +187,7 @@ async fn main() -> std::io::Result<()> { .service(login) .service(register) .service(refresh) + .service(is_valid) ) .service(image) .route("/ws", web::get().to(ws)) diff --git a/crates/backend/src/routes/authentication.rs b/crates/backend/src/routes/authentication.rs index ff486497..bd944ffc 100644 --- a/crates/backend/src/routes/authentication.rs +++ b/crates/backend/src/routes/authentication.rs @@ -1,6 +1,6 @@ -use std::env; +use std::{env, time::{SystemTime, UNIX_EPOCH}}; -use actix_web::{cookie::{Cookie, SameSite}, dev::ServiceRequest, http::header, post, web, HttpRequest, HttpResponse, Responder}; +use actix_web::{cookie::{Cookie, SameSite}, dev::ServiceRequest, get, http::header, post, web, HttpRequest, HttpResponse, Responder}; use actix_web_httpauth::extractors::bearer::BearerAuth; use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, @@ -12,6 +12,7 @@ use dotenvy::dotenv; use futures::future::{ready, Ready}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::utils::{config::get_jwt_secret, database::{database::establish_connection, models::NewUser}}; @@ -80,6 +81,62 @@ fn generate_refresh_token(user_id: i32, username: &str, role: &String) -> String encode(&header, &claims, &EncodingKey::from_secret(secret.as_ref())).expect("Token encoding failed") } +#[get("/is-valid")] +pub async fn is_valid(req: HttpRequest) -> impl Responder { + let token = if let Some(cookie_header) = req.headers().get(header::COOKIE) { + if let Ok(cookie_str) = cookie_header.to_str() { + cookie_str.split(';') + .filter_map(|cookie| Cookie::parse_encoded(cookie.trim()).ok()) + .find(|cookie| cookie.name() == "plm_accessToken") + .map(|cookie| cookie.value().to_string()) + } else { + None + } + } else { + None + }; + + let token = match token { + Some(t) => t, + None => return HttpResponse::Unauthorized().json(json!({ + "status": false, + "message": "No token found in cookies" + })) + }; + + let secret = get_jwt_secret(); + let mut validation = Validation::new(Algorithm::HS256); + validation.leeway = 60; + validation.validate_exp = true; + + match decode::(&token, &DecodingKey::from_secret(secret.as_ref()), &validation) { + Ok(token_data) => { + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as usize; + + if token_data.claims.exp < current_time { + return HttpResponse::Unauthorized().json(json!({ + "status": false, + "message": "Token expired", + "token_type": token_data.claims.token_type + })); + } + + HttpResponse::Ok().json(json!({ + "status": true, + "token_type": token_data.claims.token_type, + "claims": token_data.claims + })) + }, + Err(e) => HttpResponse::Unauthorized().json(json!({ + "status": false, + "message": format!("Invalid token: {}", e) + })) + } +} + #[post("/login")] pub async fn login(form: web::Json) -> impl Responder { use crate::utils::database::schema::user::dsl::*; diff --git a/crates/backend/src/routes/search.rs b/crates/backend/src/routes/search.rs index 88a9aecf..14681578 100644 --- a/crates/backend/src/routes/search.rs +++ b/crates/backend/src/routes/search.rs @@ -9,6 +9,7 @@ use chrono::NaiveDateTime; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; use dirs; use lazy_static::lazy_static; +use rand::seq::SliceRandom; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::{self, from_str, json}; @@ -17,7 +18,7 @@ use tantivy::query::{FuzzyTermQuery, QueryParser}; use tantivy::schema::{*, Term}; use tantivy::{doc, Index, IndexWriter, ReloadPolicy}; use tokio::task; -use tracing::error; +use tracing::{error, info}; use crate::routes::album::fetch_album_info; use crate::routes::artist::fetch_artist_info; @@ -547,22 +548,20 @@ async fn search_youtube(query: web::Query) -> Result { - client - .get(format!( - "{}/api/v1/search?q={}&type=video&page=1", - invidious_url.trim_end_matches('/'), - search_query - )) - .header("Accept", "application/json") - .send() - .await? - .json::>() - .await? - }, - Err(_) => { Vec::new() } - }; + let invidious_url = env::var("INVIDIOUS_URL") + .unwrap_or_else(|_| get_random_invidious_instance()); + + let response = client + .get(format!( + "{}/api/v1/search?q={}&type=video&page=1", + invidious_url.trim_end_matches('/'), + search_query + )) + .header("Accept", "application/json") + .send() + .await? + .json::>() + .await?; let limited_results = response.into_iter() .take(10) @@ -652,35 +651,45 @@ struct Replies { continuation: Option, } +fn get_random_invidious_instance() -> String { + let instances = vec![ + "https://invidious.nerdvpn.de", + "https://inv.nadeko.net", + "https://invidious.jing.rocks" + ]; + instances.choose(&mut rand::thread_rng()) + .unwrap_or(&"https://invidious.snopyta.org") + .to_string() +} + #[get("/youtube/comments")] async fn get_youtube_comments(query: web::Query) -> Result> { let client = reqwest::Client::new(); let video_id = &query.video_id; - let comments = match env::var("INVIDIOUS_URL") { - Ok(invidious_url) => { - let response = client - .get(format!( - "{}/api/v1/comments/{}", - invidious_url.trim_end_matches('/'), - video_id - )) - .header("Accept", "application/json") - .send() - .await?; - - if !response.status().is_success() { - return Ok(HttpResponse::InternalServerError().json(json!({ - "error": "Failed to fetch comments" - }))); - } + let invidious_url = env::var("INVIDIOUS_URL") + .unwrap_or_else(|_| get_random_invidious_instance()); - let root = response.json::().await?; - root - }, - Err(_) => CommentInfo::default() - }; + info!("Using Invidious instance: {}", invidious_url); + + let response = client + .get(format!( + "{}/api/v1/comments/{}", + invidious_url.trim_end_matches('/'), + video_id + )) + .header("Accept", "application/json") + .send() + .await?; + + if !response.status().is_success() { + error!("Failed to fetch comments: {}", response.status()); + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": "Failed to fetch comments" + }))); + } + let comments = response.json::().await?; Ok(HttpResponse::Ok().json(comments)) } diff --git a/packages/music-sdk/src/lib/authentication.ts b/packages/music-sdk/src/lib/authentication.ts index 3a8c81c8..36ad27aa 100644 --- a/packages/music-sdk/src/lib/authentication.ts +++ b/packages/music-sdk/src/lib/authentication.ts @@ -32,6 +32,29 @@ export async function register(data: AuthData): Promise { return response.data; } +interface TokenValidationResponse { + status: boolean; + token_type?: string; + claims?: { + sub: string; + exp: number; + username: string; + bitrate: number; + token_type: string; + role: string; + }; + message?: string; +} + +/** + * Check if the current token is valid. + * @returns {Promise} - A promise that resolves to the validation response. + */ +export async function isValid(): Promise { + const response = await axios.get('/auth/is-valid'); + return response.data; +} + /** * Login a user. * @param {AuthData} data - The login data. diff --git a/packages/music-sdk/src/lib/axios.ts b/packages/music-sdk/src/lib/axios.ts index 5beb6251..1aebbf8e 100644 --- a/packages/music-sdk/src/lib/axios.ts +++ b/packages/music-sdk/src/lib/axios.ts @@ -1,5 +1,5 @@ import axios, { AxiosError, AxiosHeaders, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; -import { deleteCookie, getCookie } from 'cookies-next'; +import { deleteCookie, getCookie, setCookie } from 'cookies-next'; import { refreshToken } from './authentication'; let localAddress = ''; @@ -15,9 +15,13 @@ const axiosInstance = axios.create({ interface CustomAxiosRequestConfig extends AxiosRequestConfig { _retry?: boolean; - _retryCount?: number; + _retryAttempt?: number; + _retryDelay?: number; } +const MAX_RETRY_ATTEMPTS = 3; +const INITIAL_RETRY_DELAY = 1000; + const setupInterceptors = (instance: AxiosInstance): void => { instance.interceptors.request.use((config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { const token = getCookie('plm_accessToken'); @@ -35,30 +39,36 @@ const setupInterceptors = (instance: AxiosInstance): void => { async (error: AxiosError): Promise => { const originalRequest = error.config as CustomAxiosRequestConfig; - if (error.response && error.response.status === 401 && originalRequest) { - if (!originalRequest._retry) { - originalRequest._retry = true; - originalRequest._retryCount = 0; - } - - if (originalRequest._retryCount! < 10) { - originalRequest._retryCount! += 1; - - try { - const response = await refreshToken(); + if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + originalRequest._retry = true; + originalRequest._retryAttempt = 0; + originalRequest._retryDelay = INITIAL_RETRY_DELAY; + try { + const response = await refreshToken(); + + if (response?.token) { + setCookie('plm_accessToken', response.token); if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${response.token}`; } - return axiosInstance(originalRequest); - } catch (refreshError) { - deleteCookie("plm_accessToken"); - return Promise.reject(refreshError); } - } else { - deleteCookie("plm_accessToken"); - return Promise.reject(error); + + throw new Error('No token received from refresh attempt'); + } catch (refreshError) { + console.error('Token refresh failed:', refreshError); + deleteCookie('plm_accessToken'); + + if (originalRequest._retryAttempt! < MAX_RETRY_ATTEMPTS) { + originalRequest._retryAttempt! += 1; + const delay = originalRequest._retryDelay! * Math.pow(2, originalRequest._retryAttempt!); + + await new Promise(resolve => setTimeout(resolve, delay)); + return axiosInstance(originalRequest); + } + + throw error; } }