Server integrations / Paper and protocol tests (push) Failing after 20s
Server integrations / fabric / 1.20.1 (push) Failing after 11s
Server integrations / fabric / 1.21.1 (push) Failing after 14s
Server integrations / fabric / 26.1.2 (push) Failing after 17s
Server integrations / fabric / 26.2 (push) Failing after 12s
Server integrations / fabric / 26.3 (push) Failing after 6s
Server integrations / forge / 1.20.1 (push) Failing after 6s
Server integrations / forge / 1.21.1 (push) Failing after 8s
Server integrations / forge / 26.1.2 (push) Failing after 8s
Server integrations / forge / 26.2 (push) Failing after 8s
Server integrations / forge / 26.3 (push) Failing after 7s
CI / Web UIs (type-check + build) (panel/web) (push) Canceled after 0s
CI / Launcher (Windows build check) (push) Canceled after 2m53s
CI / Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) (push) Canceled after 0s
CI / Docker image builds (push) Canceled after 0s
CI / Rust (fmt, clippy, tests) (push) Canceled after 1m22s
CI / Web UIs (type-check + build) (launcher) (push) Canceled after 1m27s
305 lines
12 KiB
Rust
305 lines
12 KiB
Rust
//! 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<String>,
|
|
pub subtitle: Option<String>,
|
|
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<String>,
|
|
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<String>,
|
|
pub server_ip: String,
|
|
pub server_port: u16,
|
|
pub hosted_downloads: Vec<HostedDownload>,
|
|
pub external_download_url: Option<String>,
|
|
pub blocks: Vec<LandingBlock>,
|
|
pub faqs: Vec<FaqItem>,
|
|
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<LandingConfig> {
|
|
store::kv_get(state, "landing_page").await
|
|
}
|
|
|
|
pub async fn public_landing(State(state): State<AppState>) -> AppResult<Json<LandingConfig>> {
|
|
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<AppState>) -> AppResult<Json<LandingConfig>> {
|
|
Ok(Json(get_config(&state).await?))
|
|
}
|
|
|
|
pub async fn admin_put_landing(
|
|
_: AdminUser,
|
|
State(state): State<AppState>,
|
|
Json(cfg): Json<LandingConfig>,
|
|
) -> AppResult<Json<LandingConfig>> {
|
|
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<AppState>, mut form: Multipart) -> AppResult<Json<LandingConfig>> {
|
|
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<AppState>, Path(platform): Path<String>) -> AppResult<Response> {
|
|
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"))
|
|
}
|