//! Public landing page & admin landing builder API. use crate::auth::AdminUser; use crate::error::{AppError, AppResult}; use crate::state::AppState; use crate::store; use axum::body::Body; use axum::extract::{Multipart, Path, State}; use axum::http::header; use axum::response::{IntoResponse, Redirect, Response}; use axum::Json; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct HostedDownload { pub platform: String, // "windows", "mac", "linux" pub label: String, pub filename: String, pub file_url: String, pub size: i64, pub uploaded_at: String, } impl Default for HostedDownload { fn default() -> Self { Self { platform: "windows".into(), label: "Windows".into(), filename: "".into(), file_url: "".into(), size: 0, uploaded_at: "".into(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct FaqItem { pub id: String, pub question: String, pub answer: String, } impl Default for FaqItem { fn default() -> Self { Self { id: "".into(), question: "".into(), answer: "".into() } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct LandingBlock { pub id: String, #[serde(rename = "type")] pub block_type: String, // "hero", "download", "servers", "leaderboard", "stats", "instances", "news", "faq", "socials" pub enabled: bool, pub title: Option, pub subtitle: Option, pub options: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct LandingTheme { pub background: String, pub surface: String, pub accent: String, pub text: String, pub muted: String, pub max_width: u16, pub radius: u8, pub font: String, pub footer_text: String, } impl Default for LandingTheme { fn default() -> Self { Self { background: "#090a0f".into(), surface: "#151620".into(), accent: "#6d6af5".into(), text: "#f1f2f6".into(), muted: "#8b8f9a".into(), max_width: 1140, radius: 14, font: "Inter".into(), footer_text: "Your worlds, one click away.".into(), } } } impl Default for LandingBlock { fn default() -> Self { Self { id: "".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None, options: serde_json::json!({}) } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct LandingConfig { pub enabled: bool, pub brand_name: String, pub logo_url: Option, pub tagline: String, pub hero_title: String, pub hero_subtitle: String, pub hero_cta_text: String, pub hero_bg_type: String, // "gradient", "image", "video" pub hero_bg_url: Option, pub server_ip: String, pub server_port: u16, pub hosted_downloads: Vec, pub external_download_url: Option, pub blocks: Vec, pub faqs: Vec, pub custom_css: String, pub theme: LandingTheme, } impl Default for LandingConfig { fn default() -> Self { Self { enabled: true, brand_name: "SCOPENET".into(), logo_url: None, tagline: "Your worlds, one click away.".into(), hero_title: "The Ultimate Minecraft Experience".into(), hero_subtitle: "Custom instances, automated modpack sync, zero hassle. Download the launcher and dive straight into our servers.".into(), hero_cta_text: "Download Launcher".into(), hero_bg_type: "gradient".into(), hero_bg_url: None, server_ip: "".into(), server_port: 25565, hosted_downloads: vec![], external_download_url: None, blocks: vec![ LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None, options: serde_json::json!({}) }, LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()), options: serde_json::json!({}) }, LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()), options: serde_json::json!({}) }, LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()), options: serde_json::json!({}) }, LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()), options: serde_json::json!({}) }, LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()), options: serde_json::json!({}) }, LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()), options: serde_json::json!({}) }, LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()), options: serde_json::json!({}) }, LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()), options: serde_json::json!({}) }, ], faqs: vec![ FaqItem { id: "faq_1".into(), question: "How do I start playing?".into(), answer: "Download our custom launcher, sign in or create an account, select an instance and hit Play! All mods and Java runtimes download automatically.".into(), }, FaqItem { id: "faq_2".into(), question: "Do I need to install Java manually?".into(), answer: "No. The SCOPENET launcher automatically detects, downloads, and isolates the exact Java runtime required for each Minecraft version.".into(), }, FaqItem { id: "faq_3".into(), question: "Can I customize my skin and cape?".into(), answer: "Yes! In the launcher settings, you can upload HD skins, choose arm styles, pick your earned capes, and save multiple wardrobe presets to swap anytime.".into(), }, ], custom_css: "".into(), theme: LandingTheme::default(), } } } pub async fn get_config(state: &AppState) -> AppResult { store::kv_get(state, "landing_page").await } pub async fn public_landing(State(state): State) -> AppResult> { let mut cfg = get_config(&state).await?; // If branding has been configured, adopt brand name & tagline if default if let Ok(branding) = store::branding(&state).await { if cfg.brand_name == "SCOPENET" && !branding.name.is_empty() { cfg.brand_name = branding.name; } if cfg.tagline == "Your worlds, one click away." && !branding.tagline.is_empty() { cfg.tagline = branding.tagline; } } // If server ip is empty, try to populate from first server if cfg.server_ip.is_empty() { if let Ok(Some(first_server)) = sqlx::query_scalar::<_, String>("SELECT address FROM game_servers ORDER BY id ASC LIMIT 1").fetch_optional(&state.db).await { cfg.server_ip = first_server; } } Ok(Json(cfg)) } pub async fn admin_get_landing(_: AdminUser, State(state): State) -> AppResult> { Ok(Json(get_config(&state).await?)) } pub async fn admin_put_landing( _: AdminUser, State(state): State, Json(cfg): Json, ) -> AppResult> { if cfg.blocks.len() > 100 || cfg.custom_css.len() > 100_000 { return Err(AppError::bad_request("landing page exceeds the editor limits")); } let mut ids = std::collections::HashSet::new(); for block in &cfg.blocks { if block.id.is_empty() || !ids.insert(&block.id) { return Err(AppError::bad_request("landing section IDs must be unique")); } } store::kv_set(&state, "landing_page", &cfg).await?; Ok(Json(cfg)) } pub async fn upload_launcher(_: AdminUser, State(state): State, mut form: Multipart) -> AppResult> { let mut platform = String::from("windows"); let mut label = String::from("Windows Installer"); let mut filename = String::new(); let mut file_bytes = Vec::new(); while let Some(field) = form.next_field().await? { match field.name().unwrap_or_default() { "platform" => platform = field.text().await?, "label" => label = field.text().await?, "file" => { if let Some(f_name) = field.file_name() { filename = f_name.to_string(); } file_bytes = field.bytes().await?.to_vec(); } _ => {} } } if file_bytes.is_empty() { return Err(AppError::bad_request("no launcher file was uploaded")); } if filename.is_empty() { filename = format!("scopenet-launcher-{platform}"); } // Sanitize filename to prevent directory traversal let safe_name = PathBuf::from(&filename).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| format!("launcher-{platform}")); let dir = state.cfg.downloads_dir(); tokio::fs::create_dir_all(&dir).await?; let target = dir.join(&safe_name); tokio::fs::write(&target, &file_bytes).await?; let size = file_bytes.len() as i64; let file_url = format!("/download/launcher/{platform}"); let mut cfg = get_config(&state).await?; let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let download_entry = HostedDownload { platform: platform.clone(), label, filename: safe_name, file_url, size, uploaded_at: ts }; if let Some(idx) = cfg.hosted_downloads.iter().position(|d| d.platform == platform) { cfg.hosted_downloads[idx] = download_entry; } else { cfg.hosted_downloads.push(download_entry); } store::kv_set(&state, "landing_page", &cfg).await?; Ok(Json(cfg)) } pub async fn download_launcher(State(state): State, Path(platform): Path) -> AppResult { let cfg = get_config(&state).await?; let entry = cfg.hosted_downloads.iter().find(|d| d.platform == platform || d.platform == "all"); if let Some(d) = entry { let file_path = state.cfg.downloads_dir().join(&d.filename); if file_path.exists() { let bytes = tokio::fs::read(&file_path).await?; let cd = format!("attachment; filename=\"{}\"", d.filename); let mime = if d.filename.ends_with(".exe") || d.filename.ends_with(".msi") { "application/x-msdownload" } else if d.filename.ends_with(".dmg") { "application/x-apple-diskimage" } else if d.filename.ends_with(".deb") { "application/vnd.debian.binary-package" } else if d.filename.ends_with(".AppImage") { "application/x-executable" } else { "application/octet-stream" }; return Ok(([(header::CONTENT_TYPE, mime), (header::CONTENT_DISPOSITION, cd.as_str())], Body::from(bytes)).into_response()); } } if let Some(ext) = cfg.external_download_url.filter(|u| !u.is_empty()) { return Ok(Redirect::temporary(&ext).into_response()); } Err(AppError::not_found("no launcher download available for this platform yet")) }