Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/rest-api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
198 changes: 195 additions & 3 deletions backend/rest-api/src/services/clients/laby.rs
Original file line number Diff line number Diff line change
@@ -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<RawLabyServer>,
#[serde(skip_serializing_if = "Option::is_none")]
yt_trailer: Option<String>,
}

impl RawLabyServer {
Expand All @@ -32,7 +36,7 @@ impl RawLabyServer {
}
}

fn construct_laby_info(state: &AppState, server: Arc<RawLabyServer>) -> LabyClientInfo {
async fn construct_laby_info(state: &AppState, server: Arc<RawLabyServer>) -> LabyClientInfo {
let srv_part = state.laby_partner_cache.get(&server.direct_ip);
let is_partner = srv_part.map(|t| *t.value()).unwrap_or(false);

Expand All @@ -46,17 +50,29 @@ fn construct_laby_info(state: &AppState, server: Arc<RawLabyServer>) -> 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,
}
}

pub async fn get_laby_infos(state: &AppState, current_server: &Server) -> Option<LabyClientInfo> {
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);
}
}

Expand Down Expand Up @@ -220,3 +236,179 @@ pub struct Location {
pub country: String,
pub country_code: Option<String>,
}

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<String>,
}

fn normalize_trailer(value: Option<String>) -> Option<String> {
value.filter(|trailer| !trailer.trim().is_empty())
}

async fn fetch_manifest_trailer(state: &AppState, url: &str) -> Option<Option<String>> {
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::<RawLabyManifest>().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<String> = {
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<Attachment>) -> 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);
}
}
1 change: 1 addition & 0 deletions backend/rest-api/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub struct AppState {

pub laby_partner_cache: Arc<DashMap<String, bool>>,
pub laby_servers_cache: Arc<RwLock<Vec<Arc<RawLabyServer>>>>,
pub laby_manifest_cache: TtlCache<String, Option<String>>,

pub lunar_servers_cache: Arc<RwLock<Vec<Arc<RawLunarServer>>>>,
pub lunar_partner_cache: Arc<DashMap<String, RawLunarPartnerServer>>,
Expand Down
5 changes: 4 additions & 1 deletion backend/rest-api/src/worker/client_fetcher.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down
23 changes: 11 additions & 12 deletions frontend/app/pages/server-detail/components/ServerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { icon: React.ElementType, colorClass: string }> = {
web: { icon: Globe, colorClass: "text-blue-500" },
Expand All @@ -27,10 +27,9 @@ const SOCIAL_ICONS: Record<string, { icon: React.ElementType, colorClass: string

interface ServerSidebarProps {
client_infos?: ClientInfos;
labyManifest?: LabyManifest;
}

export function ServerSidebar({ client_infos, labyManifest }: ServerSidebarProps) {
export function ServerSidebar({ client_infos }: ServerSidebarProps) {
const labyServerInfo = client_infos?.laby?.raw;
const lunarServerInfo = client_infos?.lunar?.raw_partner || client_infos?.lunar?.raw;
const [searchQuery, setSearchQuery] = useState("");
Expand All @@ -49,8 +48,8 @@ export function ServerSidebar({ client_infos, labyManifest }: ServerSidebarProps
labyScore += Object.entries(social).filter(([, v]) => !!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;
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -292,11 +291,11 @@ export function ServerSidebar({ client_infos, labyManifest }: ServerSidebarProps
</div>
)}

{labyManifest?.supported_languages && labyManifest.supported_languages.length > 0 && (
{labyServerInfo?.supported_languages && labyServerInfo.supported_languages.length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold text-muted-foreground">{t("serverDetail.sidebar.languages")}</h3>
<div className="flex flex-wrap gap-2">
{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',
Expand All @@ -322,12 +321,12 @@ export function ServerSidebar({ client_infos, labyManifest }: ServerSidebarProps
</div>
)}

{labyManifest?.yt_trailer && (
{client_infos?.laby?.yt_trailer && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold text-muted-foreground">{t("serverDetail.sidebar.trailer")}</h3>
<div className="aspect-video w-full rounded-md overflow-hidden border">
<iframe
src={`https://www.youtube.com/embed/${labyManifest.yt_trailer}`}
<iframe
src={`https://www.youtube.com/embed/${client_infos.laby.yt_trailer}`}
title={t("serverDetail.sidebar.trailer")}
className="w-full h-full"
allowFullScreen
Expand Down
Loading
Loading