Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Stale Authentication Tokens, Update Gradient when Searching & Fallback URL for Invidious #153

Merged
merged 8 commits into from
Dec 15, 2024
3 changes: 2 additions & 1 deletion apps/web/app/(app)/search/SearchComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -119,7 +120,7 @@ export default function SearchComponent() {
<TopResultCardSkeleton />
</div>
) : (
results && results[0] && <TopResultsCard result={results[0]} />
results && results[0] && <><TopResultsCard result={results[0]} /> <PageGradient imageSrc={results[0].album_object.cover_url}/></>
)}
</div>
</div>
Expand Down
15 changes: 13 additions & 2 deletions apps/web/components/Layout/PageGradient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,26 @@
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()

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()
Expand Down
26 changes: 14 additions & 12 deletions apps/web/components/Layout/SplashScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,21 +22,18 @@ const SplashScreen: React.FC<SplashScreenProps> = ({ 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
Expand All @@ -54,6 +53,7 @@ const SplashScreen: React.FC<SplashScreenProps> = ({ children }) => {
}

if (session?.username) {
const currentPath = window.location.pathname;
if (
currentPath === "/" ||
currentPath === "/login" ||
Expand All @@ -64,9 +64,11 @@ const SplashScreen: React.FC<SplashScreenProps> = ({ children }) => {
return;
}

const currentPath = window.location.pathname;
if (currentPath !== "/login" && currentPath !== "/login/") {
push("/login");
}

} catch (error) {
console.error("Server check failed:", error);
push("/");
Expand Down
6 changes: 3 additions & 3 deletions crates/backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -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))
Expand Down
61 changes: 59 additions & 2 deletions crates/backend/src/routes/authentication.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand All @@ -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}};

Expand Down Expand Up @@ -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::<Claims>(&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<AuthData>) -> impl Responder {
use crate::utils::database::schema::user::dsl::*;
Expand Down
87 changes: 48 additions & 39 deletions crates/backend/src/routes/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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};
Expand All @@ -17,7 +18,7 @@
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;
Expand Down Expand Up @@ -547,22 +548,20 @@
let client = reqwest::Client::new();
let search_query = &query.q;

let response = match env::var("INVIDIOUS_URL") {
Ok(invidious_url) => {
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::<Vec<InvidiousVideo>>()
.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::<Vec<InvidiousVideo>>()
.await?;

let limited_results = response.into_iter()
.take(10)
Expand Down Expand Up @@ -634,7 +633,7 @@
}

#[derive(Default, Serialize, Debug)]
struct CommentResponse {

Check warning on line 636 in crates/backend/src/routes/search.rs

View workflow job for this annotation

GitHub Actions / cargo check (stable)

struct `CommentResponse` is never constructed

Check warning on line 636 in crates/backend/src/routes/search.rs

View workflow job for this annotation

GitHub Actions / cargo check (beta)

struct `CommentResponse` is never constructed

Check warning on line 636 in crates/backend/src/routes/search.rs

View workflow job for this annotation

GitHub Actions / cargo check (nightly)

struct `CommentResponse` is never constructed
author: String,
author_thumbnails: Vec<AuthorThumbnail>,
content: String,
Expand All @@ -652,35 +651,45 @@
continuation: Option<String>,
}

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<CommentsRequest>) -> Result<impl Responder, Box<dyn Error>> {
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::<CommentInfo>().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::<CommentInfo>().await?;
Ok(HttpResponse::Ok().json(comments))
}

Expand Down
23 changes: 23 additions & 0 deletions packages/music-sdk/src/lib/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,29 @@ export async function register(data: AuthData): Promise<ResponseAuthData> {
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<TokenValidationResponse>} - A promise that resolves to the validation response.
*/
export async function isValid(): Promise<TokenValidationResponse> {
const response = await axios.get('/auth/is-valid');
return response.data;
}

/**
* Login a user.
* @param {AuthData} data - The login data.
Expand Down
Loading
Loading