diff --git a/src/lib.rs b/src/lib.rs index d443fa9..f65f78f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ mod static_handlers; +mod maintenance; mod options; mod router; mod encryption; @@ -19,8 +20,9 @@ mod ios; use actix_web::{rt, App, HttpServer, web, dev::Service}; //use actix_cors::Cors; use std::time::Duration; -pub use options::get_args; +pub use options::{get_args, Commands}; use runtime::get_data_path; +pub use maintenance::run as run_maintenance; #[actix_web::main] pub async fn run_server(in_thread: bool) -> std::io::Result<()> { diff --git a/src/main.rs b/src/main.rs index cf3fd56..a02e248 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,9 @@ #[cfg(not(feature = "library"))] fn main() -> std::io::Result<()> { let args = ew::get_args(); + if let Some(ew::Commands::Maintenance { message }) = args.command { + return ew::run_maintenance(args.port, message); + } ew::runtime::update_data_path(&args.path); ew::runtime::update_masterdata_path(&args.masterdata); ew::run_server(false) diff --git a/src/maintenance.rs b/src/maintenance.rs new file mode 100644 index 0000000..fc4cb0a --- /dev/null +++ b/src/maintenance.rs @@ -0,0 +1,121 @@ +use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer}; +use jzon::object; + +#[actix_web::main] +pub async fn run(port: u16, message: String) -> std::io::Result<()> { + let message = web::Data::new(message); + let server = HttpServer::new(move || { + App::new() + .app_data(message.clone()) + .default_service(web::route().to(respond)) + }) + .bind(("0.0.0.0", port))? + .run(); + println!("Maintenance server listening on http://0.0.0.0:{port}"); + server.await +} + +async fn respond(req: HttpRequest, message: web::Data) -> HttpResponse { + let message = message.get_ref().as_str(); + if req.path() == "/maintenance/maintenance.json" { + // The title screen requires HTTP 200, server=false, and an active UTC window. + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .content_type("application/json") + .body(object! { + "opened_at": "1970-01-01 00:00:00", + "closed_at": "9999-01-01 00:00:00", + "message": message, + "server": false, + "gamelib": 0 + }.dump()); + } + + let webui = req.path() == "/api/webui" || req.path().starts_with("/api/webui/"); + let game_api = req.path() == "/api" || req.path().starts_with("/api/"); + let browser = req.headers().get("Accept").and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("text/html")); + if req.headers().contains_key("aoharu-asset-version") || (game_api && !webui && !browser) { + // Do not use global::send: its per-user clock can access the database. + let body = object! { + "code": 10, + "server_time": crate::router::global::timestamp(), + "message": message + }.dump(); + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .body(crate::encryption::encrypt_packet(&body).expect("packet encryption failed")); + } + + let escaped = message.replace('&', "&").replace('<', "<").replace('>', ">") + .replace('"', """).replace('\'', "'"); + HttpResponse::ServiceUnavailable() + .insert_header(("Cache-Control", "no-store")) + .content_type("text/html; charset=utf-8") + .body(include_str!("../web_assets/maintenance.html").replace("{{message}}", &escaped)) +} + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::{http::{Method, StatusCode}, test}; + + const MESSAGE: &str = "Updating \"songs\" & — またね\nPlease wait."; + + #[actix_web::test] + async fn title_check_is_active_and_preserves_message() { + let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string())) + .default_service(web::route().to(respond))).await; + let req = test::TestRequest::get().uri("/maintenance/maintenance.json?cache=1").to_request(); + let response = test::call_service(&app, req).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("Cache-Control").unwrap(), "no-store"); + let body = test::read_body(response).await; + let json = jzon::parse(std::str::from_utf8(&body).unwrap()).unwrap(); + assert_eq!(json["message"].as_str(), Some(MESSAGE)); + assert_eq!(json["server"].as_bool(), Some(false)); + let now = crate::router::global::format_datetime(crate::router::global::timestamp()); + assert!(json["opened_at"].as_str().unwrap() < now.as_str()); + assert!(json["closed_at"].as_str().unwrap() > now.as_str()); + } + + #[actix_web::test] + async fn game_requests_get_encrypted_maintenance_without_reading_the_body() { + let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string())) + .default_service(web::route().to(respond))).await; + for (path, header) in [("/api/start", false), ("/api/live/end", true), + ("/api/unknown", false), ("/v1.0/test", true), ("/anything", true)] { + let mut req = test::TestRequest::post().uri(path) + .insert_header(("aoharu-user-id", "12345")).set_payload("not an encrypted request"); + if header { req = req.insert_header(("aoharu-asset-version", "old")); } + let response = test::call_service(&app, req.to_request()).await; + assert_eq!(response.status(), StatusCode::OK, "{path}"); + let body = test::read_body(response).await; + let decoded = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap(); + let json = jzon::parse(&decoded).unwrap(); + assert_eq!(json["code"].as_i32(), Some(10)); + assert_eq!(json["message"].as_str(), Some(MESSAGE)); + } + } + + #[actix_web::test] + async fn all_other_routes_and_methods_are_maintenance_only() { + let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string())) + .default_service(web::route().to(respond))).await; + for path in ["/", "/maintenance.html", "/custom_song/upload", "/custom_card/delete", + "/custom_3dmv/upload", "/announcement/create", "/api/webui/login", + "/Android/hash/file", "/v1.0/test", "/api/user", "/unknown"] { + for method in [Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS] { + let response = test::call_service(&app, + test::TestRequest::default().method(method).uri(path) + .insert_header(("Accept", "text/html")).to_request()).await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}"); + let body = test::read_body(response).await; + let html = std::str::from_utf8(&body).unwrap(); + assert!(html.contains("<cards>")); + assert!(html.contains("またね")); + assert!(!html.contains("")); + } + } + } +} diff --git a/src/options.rs b/src/options.rs index d857930..456c68f 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,9 +1,12 @@ -use clap::Parser; +use clap::{Parser, Subcommand}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] pub struct Args { - #[arg(short, long, default_value_t = 8080, help = "Port to listen on")] + #[command(subcommand)] + pub command: Option, + + #[arg(short, long, global = true, default_value_t = 8080, help = "Port to listen on")] pub port: u16, #[arg(long, default_value = "./", help = "Path to store database files")] @@ -102,8 +105,38 @@ pub struct Args { pub masterdata: String } +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Start a maintenance-only server until stopped + Maintenance { + /// The message shown to the client + message: String, + }, +} + pub fn get_args() -> Args { let mut args = Args::parse(); crate::runtime::overlay_args(&mut args); args } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maintenance_command_and_port() { + for argv in [ + vec!["ew", "--port", "6017", "maintenance", "Updating songs"], + vec!["ew", "maintenance", "Updating songs", "--port", "6017"], + ] { + let args = Args::try_parse_from(argv).unwrap(); + assert_eq!(args.port, 6017); + assert!(matches!(args.command, Some(Commands::Maintenance { message }) if message == "Updating songs")); + } + assert!(Args::try_parse_from(["ew", "maintenance"]).is_err()); + let normal = Args::try_parse_from(["ew"]).unwrap(); + assert!(normal.command.is_none()); + assert_eq!(normal.port, 8080); + } +} diff --git a/web_assets/maintenance.html b/web_assets/maintenance.html new file mode 100644 index 0000000..85ea945 --- /dev/null +++ b/web_assets/maintenance.html @@ -0,0 +1,21 @@ + + + + + + Maintenance + + + +
+

The server is down for maintenance

+

{{message}}

+
+ +