feat(panel): add modular landing page builder, public landing page, and hosted launcher downloads

This commit is contained in:
SCOPEDD committed 2026-09-29 02:32:48 -04:00
1 parent 3043f3ab42
commit 4b79f69c20
10 files changed
+1413 -3

No files matched your search

+3
View File
@@ -47,6 +47,9 @@ impl Config {
pub fn textures_dir(&self) -> PathBuf {
self.data_dir.join("textures")
}
pub fn downloads_dir(&self) -> PathBuf {
self.data_dir.join("downloads")
}
pub fn signing_key_path(&self) -> PathBuf {
self.data_dir.join("yggdrasil-signing.pem")
}
+2
View File
@@ -94,9 +94,11 @@ pub fn app(state: AppState) -> Router {
Router::new()
.route("/healthz", axum::routing::get(routes::public::health))
.route("/download/launcher/{platform}", axum::routing::get(routes::landing::download_launcher))
.merge(routes::api(&state))
.nest_service("/files", ServeDir::new(state.cfg.files_dir()))
.nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())))
.nest_service("/downloads", ServeDir::new(state.cfg.downloads_dir()))
.fallback_service(spa)
// Lets authlib-injector users enter just the panel URL (API Location Indication).
.layer(SetResponseHeaderLayer::if_not_present(
+288
View File
@@ -0,0 +1,288 @@
//! 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>,
}
impl Default for LandingBlock {
fn default() -> Self {
Self {
id: "".into(),
block_type: "hero".into(),
enabled: true,
title: None,
subtitle: None,
}
}
}
#[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,
}
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 },
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()) },
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()) },
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()) },
LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()) },
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()) },
LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()) },
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()) },
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()) },
],
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(),
}
}
}
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>> {
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"))
}
+4
View File
@@ -1,6 +1,7 @@
pub mod account;
pub mod activity;
pub mod admin;
pub mod landing;
pub mod meta;
pub mod public;
pub mod servers;
@@ -30,6 +31,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/servers/public", get(servers::public_servers))
.route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard))
.route("/leaderboard", get(servers::global_leaderboard))
.route("/landing", get(landing::public_landing))
.route("/launcher/authlib-injector.json", get(account::authlib_index))
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
@@ -43,6 +45,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/groups/{id}", delete(admin::delete_group))
.route("/branding", get(admin::get_branding).put(admin::put_branding))
.route("/settings", get(admin::get_settings).put(admin::put_settings))
.route("/landing", get(landing::admin_get_landing).put(landing::admin_put_landing))
.route("/landing/upload-launcher", post(landing::upload_launcher))
.route("/instances", get(admin::list_instances).post(admin::create_instance))
.route("/instances/{id}", get(admin::get_instance).put(admin::update_instance).delete(admin::delete_instance))
.route("/instances/{id}/reset", post(admin::reset_source))
+30
View File
@@ -223,3 +223,33 @@ async fn settings_never_leak_curseforge_key() {
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"public_url": "ftp://nope"}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "public URL must be http(s)");
}
#[tokio::test]
async fn landing_page_config_and_permissions() {
let t = setup().await;
// 1. Public landing endpoint accessible without authentication
let (s, v) = t.call("GET", "/api/v1/landing", None, None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v["brand_name"], "SCOPENET");
assert_eq!(v["enabled"], true);
assert!(v["blocks"].as_array().unwrap().len() >= 5);
// 2. Unauthenticated user cannot modify landing config
let (s, _) = t.call("PUT", "/api/admin/landing", None, Some(json!({"brand_name": "Hacked"}))).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// 3. Admin can update landing config
let admin = t.login("admin", "supersecret").await;
let mut updated = v.clone();
updated["brand_name"] = json!("PixelCraft");
updated["hero_title"] = json!("Welcome to PixelCraft");
let (s, v2) = t.call("PUT", "/api/admin/landing", Some(&admin), Some(updated)).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v2["brand_name"], "PixelCraft");
assert_eq!(v2["hero_title"], "Welcome to PixelCraft");
// 4. Public endpoint reflects the updated config
let (s, v3) = t.call("GET", "/api/v1/landing", None, None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v3["brand_name"], "PixelCraft");
}