feat(panel): add modular landing page builder, public landing page, and hosted launcher downloads
This commit is contained in:
1 parent
3043f3ab42
commit
4b79f69c20
10 files changed
+1413
-3
No files matched your search
@@ -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"))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user