mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-10-12 01:24:27 +08:00
Add maintenance subcommand
This commit is contained in:
+3
-1
@@ -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<()> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('&', "&").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\" & <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("<cards>"));
|
||||
assert!(html.contains("またね"));
|
||||
assert!(!html.contains("<cards>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Maintenance</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
background: #0b0e1a; color: #e8ebf5; font-family: system-ui, sans-serif; }
|
||||
main { max-width: 32rem; padding: 2rem; text-align: center; }
|
||||
h1 { font-size: 1.6rem; }
|
||||
p { color: #c3c9dd; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>The server is down for maintenance</h1>
|
||||
<p>{{message}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user