diff --git a/backend/rest-api/src/main.rs b/backend/rest-api/src/main.rs index 48abe8d..4ab37f6 100644 --- a/backend/rest-api/src/main.rs +++ b/backend/rest-api/src/main.rs @@ -120,6 +120,7 @@ async fn main() { laby_partner_cache: Arc::new(DashMap::new()), laby_servers_cache: Arc::new(RwLock::new(Vec::new())), + laby_manifest_cache: TtlCache::new(), lunar_partner_cache: Arc::new(DashMap::new()), lunar_servers_cache: Arc::new(RwLock::new(Vec::new())), diff --git a/backend/rest-api/src/services/clients/laby.rs b/backend/rest-api/src/services/clients/laby.rs index 7fbb814..4f8dcb3 100644 --- a/backend/rest-api/src/services/clients/laby.rs +++ b/backend/rest-api/src/services/clients/laby.rs @@ -1,17 +1,21 @@ use crate::services::clients::BaseClientInfo; use crate::state::AppState; +use futures::stream::{self, StreamExt}; use log::error; use regex::Regex; use repository::models::server::Server; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; #[derive(Serialize)] pub struct LabyClientInfo { #[serde(flatten)] base: BaseClientInfo, raw: Arc, + #[serde(skip_serializing_if = "Option::is_none")] + yt_trailer: Option, } impl RawLabyServer { @@ -32,7 +36,7 @@ impl RawLabyServer { } } -fn construct_laby_info(state: &AppState, server: Arc) -> LabyClientInfo { +async fn construct_laby_info(state: &AppState, server: Arc) -> LabyClientInfo { let srv_part = state.laby_partner_cache.get(&server.direct_ip); let is_partner = srv_part.map(|t| *t.value()).unwrap_or(false); @@ -46,9 +50,21 @@ fn construct_laby_info(state: &AppState, server: Arc) -> LabyClie .next(), }; + let yt_trailer = match manifest_url(&server) { + Some(url) if is_allowed_manifest_url(url) => { + state + .laby_manifest_cache + .get(&url.to_string()) + .await + .flatten() + } + _ => None, + }; + LabyClientInfo { base, raw: server.clone(), + yt_trailer, } } @@ -56,7 +72,7 @@ pub async fn get_laby_infos(state: &AppState, current_server: &Server) -> Option let guard = state.laby_servers_cache.read().await; for server in guard.iter() { if server.matches(current_server.ip.as_str()) { - return Some(construct_laby_info(state, server.clone())); + return Some(construct_laby_info(state, server.clone()).await); } } @@ -220,3 +236,179 @@ pub struct Location { pub country: String, pub country_code: Option, } + +const MANIFEST_CACHE_TTL: Duration = Duration::from_hours(24); +const ALLOWED_MANIFEST_ORIGINS: [&str; 2] = + ["https://dl.labymod.net", "https://resource.laby.net"]; + +fn manifest_url(server: &RawLabyServer) -> Option<&str> { + server + .attachments + .iter() + .find(|a| a.file_name == "manifest.json") + .map(|a| a.url.as_str()) +} + +fn is_allowed_manifest_url(url: &str) -> bool { + let parsed = match reqwest::Url::parse(url) { + Ok(parsed) => parsed, + Err(_) => return false, + }; + if parsed.scheme() != "https" || !parsed.username().is_empty() || parsed.password().is_some() { + return false; + } + ALLOWED_MANIFEST_ORIGINS + .iter() + .any(|origin| parsed.origin().ascii_serialization() == *origin) +} + +#[derive(Deserialize)] +struct RawLabyManifest { + #[serde(default)] + yt_trailer: Option, +} + +fn normalize_trailer(value: Option) -> Option { + value.filter(|trailer| !trailer.trim().is_empty()) +} + +async fn fetch_manifest_trailer(state: &AppState, url: &str) -> Option> { + let response = match state.http_client.get(url).send().await { + Ok(response) => response, + Err(err) => { + error!("Could not fetch laby manifest url={url} {err}"); + return None; + } + }; + if !response.status().is_success() { + error!( + "Could not fetch laby manifest url={url} status={}", + response.status() + ); + return None; + } + match response.json::().await { + Ok(manifest) => Some(normalize_trailer(manifest.yt_trailer)), + Err(err) => { + error!("Could not parse laby manifest url={url} {err}"); + None + } + } +} + +pub async fn refresh_laby_manifests(state: &AppState) { + let urls: HashSet = { + let guard = state.laby_servers_cache.read().await; + guard + .iter() + .filter_map(|server| manifest_url(server)) + .filter(|url| is_allowed_manifest_url(url)) + .map(str::to_string) + .collect() + }; + + stream::iter(urls) + .for_each_concurrent(8, |url| async { + if state.laby_manifest_cache.get(&url).await.is_some() { + return; + } + // Failures stay uncached so the next cycle retries them. + if let Some(trailer) = fetch_manifest_trailer(state, &url).await { + state + .laby_manifest_cache + .insert(url, trailer, MANIFEST_CACHE_TTL) + .await; + } + }) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server_with_attachments(attachments: Vec) -> RawLabyServer { + RawLabyServer { + server_name: String::new(), + nice_name: String::new(), + direct_ip: String::new(), + wildcards: Vec::new(), + compiled_regexes: Vec::new(), + attachments, + addons: None, + social: None, + gamemodes: HashMap::new(), + user_stats: None, + chat: None, + supported_languages: None, + brand: None, + location: None, + command_delay: None, + } + } + + fn attachment(file_name: &str, url: &str) -> Attachment { + Attachment { + file_name: file_name.to_string(), + url: url.to_string(), + hash: String::new(), + } + } + + #[test] + fn manifest_url_finds_manifest_attachment() { + let server = server_with_attachments(vec![ + attachment("background.webp", "https://dl.labymod.net/bg.webp"), + attachment( + "manifest.json", + "https://dl.labymod.net/manifest.json", + ), + ]); + assert_eq!( + manifest_url(&server), + Some("https://dl.labymod.net/manifest.json") + ); + } + + #[test] + fn manifest_url_returns_none_without_manifest() { + let server = server_with_attachments(vec![attachment( + "background.webp", + "https://dl.labymod.net/bg.webp", + )]); + assert_eq!(manifest_url(&server), None); + } + + #[test] + fn only_allowlists_laby_cdn_manifest_urls() { + assert!(is_allowed_manifest_url( + "https://dl.labymod.net/manifest.json" + )); + assert!(is_allowed_manifest_url( + "https://resource.laby.net/abc/manifest.json" + )); + assert!(!is_allowed_manifest_url("http://dl.labymod.net/manifest.json")); + assert!(!is_allowed_manifest_url( + "https://dl.labymod.net.evil.com/manifest.json" + )); + assert!(!is_allowed_manifest_url( + "https://evil.com/https://dl.labymod.net/manifest.json" + )); + assert!(!is_allowed_manifest_url( + "https://dl.labymod.net@evil.com/manifest.json" + )); + assert!(!is_allowed_manifest_url("not a url")); + } + + #[test] + fn parses_manifest_trailer() { + let manifest: RawLabyManifest = + serde_json::from_str(r#"{"yt_trailer": "dQw4w9WgXcQ"}"#).unwrap(); + assert_eq!(normalize_trailer(manifest.yt_trailer), Some("dQw4w9WgXcQ".to_string())); + + let manifest: RawLabyManifest = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(normalize_trailer(manifest.yt_trailer), None); + + assert_eq!(normalize_trailer(Some(" ".to_string())), None); + } +} diff --git a/backend/rest-api/src/state.rs b/backend/rest-api/src/state.rs index 9146f96..2f0729c 100644 --- a/backend/rest-api/src/state.rs +++ b/backend/rest-api/src/state.rs @@ -27,6 +27,7 @@ pub struct AppState { pub laby_partner_cache: Arc>, pub laby_servers_cache: Arc>>>, + pub laby_manifest_cache: TtlCache>, pub lunar_servers_cache: Arc>>>, pub lunar_partner_cache: Arc>, diff --git a/backend/rest-api/src/worker/client_fetcher.rs b/backend/rest-api/src/worker/client_fetcher.rs index 63adc0f..c519dd1 100644 --- a/backend/rest-api/src/worker/client_fetcher.rs +++ b/backend/rest-api/src/worker/client_fetcher.rs @@ -1,4 +1,6 @@ -use crate::services::clients::laby::{refresh_laby_infos, refresh_laby_partner_infos}; +use crate::services::clients::laby::{ + refresh_laby_infos, refresh_laby_manifests, refresh_laby_partner_infos, +}; use crate::services::clients::lunar::{refresh_lunar_infos, refresh_lunar_partner_infos}; use crate::state::AppState; use std::time::Duration; @@ -13,6 +15,7 @@ pub fn start(state: AppState) { refresh_lunar_partner_infos(&state).await; refresh_laby_infos(&state).await; + refresh_laby_manifests(&state).await; refresh_laby_partner_infos(&state).await; sleep(FETCH_DELAY).await; diff --git a/frontend/app/pages/server-detail/components/ServerSidebar.tsx b/frontend/app/pages/server-detail/components/ServerSidebar.tsx index 8c8732d..ee40785 100644 --- a/frontend/app/pages/server-detail/components/ServerSidebar.tsx +++ b/frontend/app/pages/server-detail/components/ServerSidebar.tsx @@ -9,7 +9,7 @@ import { cn } from "@/core/lib/utils" import { lunarLogoClass } from "@/core/lib/theme-colors" import { LunarLogo } from "@/ui/components/LunarLogo" import { LabyLogo } from "@/ui/components/LabyLogo" -import type { ClientInfos, LabyManifest } from "@/pages/server-detail/types" +import type { ClientInfos } from "@/pages/server-detail/types" const SOCIAL_ICONS: Record = { web: { icon: Globe, colorClass: "text-blue-500" }, @@ -27,10 +27,9 @@ const SOCIAL_ICONS: Record !!v).length; if (user_stats) labyScore += 1; labyScore += Object.keys(gamemodes).length; - if (labyManifest?.supported_languages) labyScore += labyManifest.supported_languages.length; - if (labyManifest?.yt_trailer) labyScore += 1; + if (labyServerInfo?.supported_languages) labyScore += labyServerInfo.supported_languages.length; + if (client_infos?.laby?.yt_trailer) labyScore += 1; if (client_infos?.laby?.partner) labyScore += 1; } @@ -186,8 +185,8 @@ export function ServerSidebar({ client_infos, labyManifest }: ServerSidebarProps if (!labyServerInfo) return null; const hasContent = client_infos?.laby?.partner || hasSocials || user_stats || hasGamemodes - || (labyManifest?.supported_languages?.length > 0) - || labyManifest?.yt_trailer; + || (labyServerInfo?.supported_languages?.length ?? 0) > 0 + || client_infos?.laby?.yt_trailer; if (!hasContent) { return ( @@ -292,11 +291,11 @@ export function ServerSidebar({ client_infos, labyManifest }: ServerSidebarProps )} - {labyManifest?.supported_languages && labyManifest.supported_languages.length > 0 && ( + {labyServerInfo?.supported_languages && labyServerInfo.supported_languages.length > 0 && (

{t("serverDetail.sidebar.languages")}

- {labyManifest.supported_languages.map((lang: string) => { + {labyServerInfo.supported_languages.map((lang: string) => { const code = lang.toLowerCase(); const flagCode = { en: 'gb', ja: 'jp', zh: 'cn', ko: 'kr', @@ -322,12 +321,12 @@ export function ServerSidebar({ client_infos, labyManifest }: ServerSidebarProps
)} - {labyManifest?.yt_trailer && ( + {client_infos?.laby?.yt_trailer && (

{t("serverDetail.sidebar.trailer")}

-