Add maintenance subcommand

This commit is contained in:
Ethan O'Brien
2026-09-23 13:12:07 -05:00
parent 3e58691c34
commit e258a8ca42
5 changed files with 183 additions and 3 deletions
+3 -1
View File
@@ -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<()> {
+3
View File
@@ -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)
+121
View File
@@ -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<String>) -> 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
.replace('"', "&quot;").replace('\'', "&#39;");
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\" & <cards> — またね\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("&lt;cards&gt;"));
assert!(html.contains("またね"));
assert!(!html.contains("<cards>"));
}
}
}
}
+35 -2
View File
@@ -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<Commands>,
#[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);
}
}