// SPDX-License-Identifier: AGPL-3.0-or-later
// There is NO WARRANTY.
+#![feature(lint_reasons)]
#![feature(proc_macro_hygiene, decl_macro)]
use otter::imports::thiserror;
pub mod session;
pub mod sse;
-pub use rocket::http::Status;
-pub use rocket::http::{ContentType, RawStr};
-pub use rocket::request::Request;
-pub use rocket::request::{FromFormValue, FromParam, FromRequest, LenientForm};
-pub use rocket::response;
-pub use rocket::response::NamedFile;
-pub use rocket::response::{Responder, Response};
-pub use rocket::{get, post, routes};
-pub use rocket::{Rocket, State};
-pub use rocket_contrib::helmet::*;
-pub use rocket_contrib::json::Json;
-pub use rocket_contrib::templates::tera::{self, Value};
-pub use rocket_contrib::templates::Engines;
-pub use rocket_contrib::templates::Template;
+pub use std::pin::Pin;
pub use crate::api::InstanceAccess;
pub use crate::api::{FatalErrorResponse};
pub type FER = FatalErrorResponse;
-use rocket::fairing;
-use rocket::response::Content;
-use rocket_contrib::serve::StaticFiles;
+use actix_web::{route, post, HttpServer, Responder};
+//App,
+use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError};
+use actix_web::{HttpRequest, FromRequest};
+use actix_web::services;
+use actix_web::dev::HttpServiceFactory;
+use actix_web::web::{self, Bytes, Data, Json, Path, Query};
+use actix_web::body::BoxBody;
+use actix_web::http::header;
+use actix_web::http::{Method, StatusCode};
+use actix_web::middleware;
+use actix_files::NamedFile;
+use actix_cors::Cors;
use otter::prelude::*;
+use otter::imports::tera_standalone as tera;
+use tera::Tera;
+
+const CT_JAVASCRIPT: mime::Mime = mime::APPLICATION_JAVASCRIPT_UTF_8;
+const CT_TEXT: mime::Mime = mime::TEXT_PLAIN_UTF_8;
+const CT_HTML: mime::Mime = mime::TEXT_HTML_UTF_8;
+const CT_ZIP: &'static str = "application/zip";
+const CT_WASM: &'static str = "application/wasm";
+
+trait IntoMime: Debug {
+ fn into_mime(&self) -> mime::Mime;
+}
+impl IntoMime for &str {
+ fn into_mime(&self) -> mime::Mime { self.parse().expect(self) }
+}
+impl IntoMime for mime::Mime {
+ fn into_mime(&self) -> mime::Mime { self.clone() }
+}
+type ConstContentType = &'static dyn IntoMime;
#[derive(Serialize,Debug)]
struct FrontPageRenderContext {
debug_js_inject: Arc<String>,
}
+pub type Template = HttpResponse;
+
+pub struct Templates {
+ tera: tera::Tera,
+}
+
+impl Templates {
+ #[throws(StartupError)]
+ pub fn new(template_dir: &str) -> Self {
+ let tera = Tera::new(&format!("{}/*.tera", template_dir))
+ .context("initialise templates")?;
+ Templates { tera }
+ }
+
+ #[throws(InternalError)]
+ pub fn render<C: Serialize>(&self, template: &str, c: C) -> Template {
+ #[throws(InternalError)]
+ fn inner(tmpls: &Templates, template: &str, c: tera::Result<tera::Context>)
+ -> Template {
+ let s = tmpls.tera.render(&format!("{}.tera", template), &c?)?;
+ HttpResponseBuilder::new(StatusCode::OK)
+ .content_type(CT_HTML)
+ .body(s)
+ }
+
+ let c = tera::Context::from_serialize(c);
+ inner(self, template, c)?
+ }
+}
+
#[derive(Copy,Clone,Debug)]
enum ResourceLocation { Main, Wasm(&'static str), }
type RL = ResourceLocation;
-const RESOURCES: &[(&'static str, ResourceLocation, ContentType)] = &[
- ("script.js", RL::Main, ContentType::JavaScript),
- ("LICENCE", RL::Main, ContentType::Plain),
- ("libre", RL::Main, ContentType::HTML),
- ("shapelib.html", RL::Main, ContentType::HTML),
- ("AGPLv3", RL::Main, ContentType::Plain),
- ("CC-BY-SA-3.0", RL::Main, ContentType::Plain),
- ("CC-BY-SA-4.0", RL::Main, ContentType::Plain),
- ("wasm.wasm", RL::Wasm("otter_wasm_bg.wasm"), ContentType::WASM),
- ("wasm.js", RL::Wasm("otter_wasm.js"), ContentType::JavaScript),
+const RESOURCES: &[(&'static str, ResourceLocation, ConstContentType)] = &[
+ ("script.js", RL::Main, &CT_JAVASCRIPT),
+ ("LICENCE", RL::Main, &CT_TEXT),
+ ("libre", RL::Main, &CT_HTML),
+ ("shapelib.html", RL::Main, &CT_HTML),
+ ("AGPLv3", RL::Main, &CT_TEXT),
+ ("CC-BY-SA-3.0", RL::Main, &CT_TEXT),
+ ("CC-BY-SA-4.0", RL::Main, &CT_TEXT),
+ ("wasm.wasm", RL::Wasm("otter_wasm_bg.wasm"), &CT_WASM),
+ ("wasm.js", RL::Wasm("otter_wasm.js"), &CT_JAVASCRIPT),
];
#[derive(Debug)]
struct CheckedResourceLeaf {
safe_leaf: &'static str,
locn: ResourceLocation,
- ctype: ContentType,
+ ctype: ConstContentType,
}
#[derive(Error,Debug)]
#[error("not a valid resource path")]
struct UnknownResource{}
-impl<'r> FromParam<'r> for CheckedResourceLeaf {
- type Error = UnknownResource;
- fn from_param(param: &'r RawStr) -> Result<Self, Self::Error> {
+impl FromStr for CheckedResourceLeaf {
+ type Err = UnknownResource;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
for &(safe_leaf, locn, ref ctype) in RESOURCES {
- if safe_leaf == param.as_str() {
+ if safe_leaf == s {
return Ok(CheckedResourceLeaf {
safe_leaf, locn,
ctype: ctype.clone(),
}
}
-type PlayerQueryString<'r> = WholeQueryString<InstanceAccess<'r, PlayerId>>;
+type PlayerQueryString = WholeQueryString<
+ InstanceAccess<PlayerId>,
+ FER,
+ >;
#[derive(Serialize,Debug)]
struct LoadingRenderContext<'r> {
movehist_len_i: usize,
movehist_len_max: usize,
}
-#[get("/")]
+#[route("/", method="GET", method="HEAD")]
#[throws(FER)]
-fn loading_p(ia: PlayerQueryString) -> Template {
- loading(None, ia)?
+async fn loading_p(ia: PlayerQueryString,
+ templates: Data<Templates>) -> Template {
+ loading(None, ia, templates)?
}
-#[get("/<layout>")]
+#[route("/{layout}", method="GET", method="HEAD")]
#[throws(FER)]
-fn loading_l(layout: Parse<AbbrevPresentationLayout>, ia: PlayerQueryString)
+async fn loading_l(layout: Path<Parse<AbbrevPresentationLayout>>,
+ ia: PlayerQueryString,
+ templates: Data<Templates>)
-> Template {
- loading(Some((layout.0).0), ia)?
+ loading(Some((layout.0).0), ia, templates)?
}
#[throws(Fatal)]
-fn loading(layout: Option<PresentationLayout>, ia: PlayerQueryString)
+fn loading(layout: Option<PresentationLayout>, ia: PlayerQueryString,
+ templates: Data<Templates>)
-> Template
{
if let Some(ia) = ia.0 {
let c = LoadingRenderContext {
nick: gpl.nick.clone(),
game: g.name.to_string(),
- ptoken: &ia.raw_token,
+ ptoken: ia.raw_token.borrow(),
debug_js_inject: config().debug_js_inject.clone(),
movehist_lens: JsonString(movehist::LENS),
movehist_len_i: movehist::LEN_DEF_I,
movehist_len_max: movehist::LEN_MAX,
layout,
};
- Template::render("loading", &c)
+ templates.render("loading", &c)?
} else {
let c = FrontPageRenderContext {
debug_js_inject: config().debug_js_inject.clone(),
};
- Template::render("front", &c)
+ templates.render("front", &c)?
}
}
-struct WholeQueryString<T>(pub Option<T>);
+struct WholeQueryString<T,E>(pub Option<T>, PhantomData<E>);
+impl<T,E> From<Option<T>> for WholeQueryString<T,E> {
+ fn from(v: Option<T>) -> Self { Self(v, default()) }
+}
-impl<'a,'r,T> FromRequest<'a,'r> for WholeQueryString<T>
- where T: 'a + FromFormValue<'a>,
- T::Error: Debug,
- for <'x> &'x T::Error: Into<rocket::http::Status>,
+impl<T,E> FromRequest for WholeQueryString<T,E>
+where T: FromStr,
+ E: ResponseError + 'static,
+ T::Err: Into<E>
+// T::Error: Debug,
{
- type Error = <T as FromFormValue<'a>>::Error;
- fn from_request(r: &'a rocket::Request<'r>)
- -> rocket::Outcome<Self, (rocket::http::Status, Self::Error), ()>
- {
- eprintln!("REQUEST uri={:?}", &r.uri());
- match r.uri().query().map(|s| {
- let s = RawStr::from_str(s);
- FromFormValue::from_form_value(s)
- }).transpose() {
- Ok(v) => rocket::Outcome::Success(WholeQueryString(v)),
- Err(e) => rocket::Outcome::Failure(((&e).into(), e)),
- }
+ type Future = futures::future::Ready<Result<WholeQueryString<T,E>, E>>;
+ type Error = E;
+ fn from_request(req: &HttpRequest, _: &mut actix_web::dev::Payload)
+ -> Self::Future {
+ futures::future::ready(
+ req.uri().query()
+ .map(|s| s.parse())
+ .transpose()
+ .map_err(Into::into)
+ .map(WholeQueryString::from)
+ )
}
}
#[derive(Debug)]
pub struct Parse<T: FromStr>(pub T);
-impl<'r, T> FromParam<'r> for Parse<T>
- where T: FromStr,
- <T as FromStr>::Err: Debug,
-// where : Into<OE>
+impl<'de,T> Deserialize<'de> for Parse<T>
+where T: FromStr,
+ T::Err: std::error::Error,
{
- type Error = <T as FromStr>::Err;
- #[throws(Self::Error)]
- fn from_param(param: &'r RawStr) -> Parse<T> {
- Parse(param.as_str().parse()?)
+ #[throws(D::Error)]
+ fn deserialize<D: Deserializer<'de>>(d: D) -> Self {
+ struct ParseVisitor;
+ impl<'vde> serde::de::Visitor<'vde> for ParseVisitor {
+ type Value = Cow<'vde, str>;
+ #[throws(E)]
+ fn visit_str<E:Error>(self, v: &str) -> Self::Value {
+ v.to_owned().into()
+ }
+ #[throws(E)]
+ fn visit_borrowed_str<E:Error>(self, v: &'vde str) -> Self::Value {
+ v.into()
+ }
+ #[throws(fmt::Error)]
+ fn expecting(&self, f: &mut Formatter<'_>) {
+ write!(f, "string, from URL path")?;
+ }
+ }
+
+ let s = d.deserialize_str(ParseVisitor)?;
+ let v = s.parse().map_err(|e: T::Err| D::Error::custom(e.to_string()))?;
+ Parse(v)
}
}
-pub struct BundleToken(pub AssetUrlToken);
+//pub struct BundleToken(pub AssetUrlToken);
+/*
impl<'r> FromFormValue<'r> for BundleToken {
type Error = BundleDownloadError;
#[throws(BundleDownloadError)]
BundleToken(param.as_str().parse()?)
}
}
+*/
+
+fn updates_cors() -> Cors {
+
+ Cors::default()
+ .allowed_methods([Method::GET])
+}
+
+#[derive(Debug, Deserialize)]
+struct UpdatesParams {
+ ctoken: Parse<InstanceAccess<ClientId>>,
+ gen: u64,
+}
-#[get("/_/updates?<ctoken>&<gen>")]
+#[route("/_/updates", method="GET", wrap="updates_cors()")]
#[throws(FER)]
-fn updates<'r>(ctoken: InstanceAccess<ClientId>, gen: u64,
- cors: rocket_cors::Guard<'r>)
- -> impl response::Responder<'r> {
+async fn updates_route(query: Query<UpdatesParams>) -> impl Responder {
+ let UpdatesParams { ctoken, gen } = query.into_inner();
let gen = Generation(gen);
- let iad = ctoken.i;
+ let iad = ctoken.0.i;
debug!("starting update stream {:?}", &iad);
- let client = iad.ident;
let content = sse::content(iad, gen)?;
- let content = DebugReader(content, client);
- let content = response::Stream::chunked(content, 4096);
- const CTYPE: &str = "text/event-stream; charset=utf-8";
- let ctype = ContentType::parse_flexible(CTYPE).unwrap();
- cors.responder(response::content::Content(ctype, content))
+ HttpResponse::build(StatusCode::OK)
+ .content_type("text/event-stream; charset=utf-8")
+ .streaming(content)
}
-#[get("/_/<leaf>")]
+#[route("/_/{leaf}", method="GET", method="HEAD")]
#[throws(io::Error)]
-fn resource<'r>(leaf: CheckedResourceLeaf) -> impl Responder<'r> {
+async fn resource(leaf: Path<Parse<CheckedResourceLeaf>>) -> impl Responder {
+ let leaf = leaf.into_inner().0;
let path = match leaf.locn {
RL::Main => format!("{}/{}", config().template_dir, leaf.safe_leaf),
RL::Wasm(s) => format!("{}/{}", config().wasm_dir, s),
};
- Content(leaf.ctype, NamedFile::open(path)?)
+ NamedFile::open(path)?
+ .disable_content_disposition()
+ .prefer_utf8(true)
+ .set_content_type(leaf.ctype.into_mime())
}
#[derive(Error,Debug)]
display_as_debug!{BundleDownloadError}
use BundleDownloadError as BDE;
-impl<'r> Responder<'r> for BundleDownloadError {
- fn respond_to(self, _: &rocket::Request)
- -> Result<rocket::Response<'r>, rocket::http::Status> {
- Err((&self).into())
- }
-}
-
-impl From<&BundleDownloadError> for rocket::http::Status {
- fn from(e: &BundleDownloadError) -> rocket::http::Status {
- use rocket::http::Status as S;
- match e {
- BDE::NotFound => S::NotFound,
- BDE::BadAssetUrlToken(_) => S::Forbidden,
- BDE::IE(_) => S::InternalServerError,
+impl ResponseError for BundleDownloadError {
+ fn status_code(&self) -> StatusCode {
+ match self {
+ BDE::NotFound => StatusCode::NOT_FOUND,
+ BDE::BadAssetUrlToken(_) => StatusCode::FORBIDDEN,
+ BDE::IE(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
-#[get("/_/bundle/<instance>/<id>")]
+#[route("/_/bundle/{instance}/{id}", method="GET", method="HEAD")]
#[throws(BundleDownloadError)]
-fn bundle<'r>(instance: Parse<InstanceName>,
- id: Parse<bundles::Id>,
- token: WholeQueryString<BundleToken>)
- -> impl Responder<'r>
-{
- if_let!{ Some(BundleToken(token)) = token.0; else throw!(BadAssetUrlToken) };
+async fn bundle_route(path: Path<(
+ Parse<InstanceName>,
+ Parse<bundles::Id>,
+)>, token: WholeQueryString<AssetUrlToken, BundleDownloadError>
+) -> impl Responder {
+ let (instance, id) = path.into_inner();
+ if_let!{ Some(token) = token.0; else throw!(BadAssetUrlToken) };
let instance = &instance.0;
let id = id.0;
let gref = Instance::lookup_by_name_unauth(instance)
ig.asset_url_key.check("bundle", &(instance, id), &token)?
}.map(|(_,id)| id);
let path = id.path(&ig, auth);
- let f = match rocket::response::NamedFile::open(&path) {
+ let f = match NamedFile::open(&path) {
Err(e) if e.kind() == ErrorKind::NotFound => throw!(BDE::NotFound),
Err(e) => throw!(IE::from(AE::from(e).context(path).context("bundle"))),
Ok(y) => y,
};
drop(ig);
let ctype = match id.kind {
- bundles::Kind::Zip => ContentType::ZIP,
+ bundles::Kind::Zip => CT_ZIP,
};
- Content(ctype, f)
+ f
+ .disable_content_disposition()
+ .set_content_type(ctype.into_mime())
}
+/*
#[derive(Debug,Copy,Clone)]
struct ContentTypeFixup;
impl fairing::Fairing for ContentTypeFixup {
}
}
}
+*/
-#[derive(Debug,Copy,Clone)]
-struct ReportStartup;
-impl fairing::Fairing for ReportStartup {
- fn info(&self) -> fairing::Info {
- fairing::Info {
- name: "ReportStartup",
- kind: fairing::Kind::Launch,
- }
+fn on_launch() {
+ println!("{}", DAEMON_STARTUP_REPORT);
+ std::io::stdout().flush().unwrap_or_else(
+ |e| warn!("failed to report started: {:?}", &e)
+ );
+}
+
+#[ext]
+impl StatusCode {
+ fn respond_text(self, t: &dyn Display) -> HttpResponse {
+ HttpResponse::build(self)
+ .content_type(CT_TEXT)
+ .body(t.to_string())
}
- fn on_launch(&self, _rocket: &Rocket) {
- println!("{}", DAEMON_STARTUP_REPORT);
- std::io::stdout().flush().unwrap_or_else(
- |e| warn!("failed to report started: {:?}", &e)
- );
+}
+
+async fn not_found_handler(method: Method) -> impl Responder {
+ match method {
+ Method::GET | Method::HEAD | Method::POST =>
+ StatusCode::NOT_FOUND.respond_text(&"404 Not found."),
+ _ =>
+ StatusCode::METHOD_NOT_ALLOWED.respond_text(&"Unsupported HTTP method"),
}
}
-#[throws(StartupError)]
-fn main() {
+#[actix_web::main] // not compatible with fehler
+async fn main() -> Result<(),StartupError> {
use structopt::StructOpt;
#[derive(StructOpt)]
struct Opts {
ServerConfig::read(opts.config_filename.as_ref().map(String::as_str),
PathResolveMethod::Chdir)?;
- std::env::set_var("ROCKET_CLI_COLORS", "off");
-
let c = config();
flexi_logger::Logger::with(log_config().clone()).start()?;
shapelib::load_global_libs(&config().shapelibs)?;
c.lock_save_area()?;
+ let templates = Templates::new(&c.template_dir)?;
load_accounts()?;
load_games(&mut AccountsGuard::lock(), &mut games_lock())?;
let cl = CommandListener::new()?;
cl.spawn()?;
- let helmet = SpaceHelmet::default()
- .enable(NoSniff::Enable)
- .enable(Frame::Deny)
- .enable(Referrer::NoReferrer);
-
- let mut cbuilder = rocket::config::Config::build(
- if c.debug {
- rocket::config::Environment::Development
- } else {
- info!("requesting Production");
- rocket::config::Environment::Production
- }
- );
+// let updates = updates.wrap(
+
+ let c = Arc::new(c);
+ let templates = Data::new(templates);
+
+ let http = HttpServer::new({
+ let c = c.clone();
+ move || {
+
+ let json_config = actix_web::web::JsonConfig::default()
+ .content_type(|ctype| ctype == mime::APPLICATION_JSON)
+ .content_type_required(true);
+
+ let src_service = actix_files::Files::new("/_/src", &c.bundled_sources)
+ .show_files_listing()
+ .redirect_to_slash_directory()
+ .index_file("index.html")
+ .disable_content_disposition();
+
+ let app = actix_web::App::new()
+ .service(services![
+ loading_l,
+ loading_p,
+ bundle_route,
+ updates_route,
+ resource,
+ session::routes(),
+ api::routes(),
+ ])
+ .wrap(middleware::DefaultHeaders::new()
+ .add((header::X_CONTENT_TYPE_OPTIONS, "nosniff"))
+ .add((header::X_FRAME_OPTIONS, "DENY"))
+ .add((header::REFERRER_POLICY, "no-referrer"))
+ )
+ .app_data(json_config)
+ .app_data(templates.clone())
+ .service(src_service)
+ .default_service(web::to(not_found_handler));
+
+ app
+
+ }});
+
+ let mut http = http
+ .disable_signals();
+
+ let (addrs, def_port): (&[&dyn IpAddress], _) = if c.debug {
+ (&[&Ipv6Addr::LOCALHOST, &Ipv4Addr::LOCALHOST ], 8000)
+ } else {
+ (&[&Ipv6Addr::UNSPECIFIED, &Ipv4Addr::UNSPECIFIED], 80)
+ };
+ let port = c.http_port.unwrap_or(def_port);
- if c.debug {
- cbuilder = cbuilder.address("127.0.0.1");
- }
- cbuilder = cbuilder.workers(c.rocket_workers);
- if let Some(port) = c.http_port {
- cbuilder = cbuilder.port(port);
+ for addr in addrs {
+ let addr = addr.with_port(port);
+ http = http.bind(addr)
+ .with_context(|| format!("bind {:?}", addr))?;
}
- cbuilder.extras.insert("template_dir".to_owned(),
- c.template_dir.clone().into());
thread::spawn(game_flush_task);
- let cors_state = {
- use rocket_cors::*;
- let opts = CorsOptions::default()
- .allowed_origins(AllowedOrigins::all())
- .allowed_methods(iter::once(rocket::http::Method::Get.into()).collect());
- opts.validate().expect("cors options");
- opts.to_cors().expect("cors")
- };
-
- let rconfig = cbuilder.finalize()?;
-
- let mut r = rocket::custom(rconfig)
- .attach(ContentTypeFixup)
- .attach(helmet)
- .attach(Template::fairing())
- .manage(cors_state)
- .mount("/", routes![
- loading_l,
- loading_p,
- bundle,
- resource,
- updates,
- ])
- .mount("/_/src", StaticFiles::from(&c.bundled_sources))
- ;
+ thread::spawn(client_periodic_expiry);
+ thread::spawn(logs_periodic_expiry);
if opts.report_startup {
- r = r.attach(ReportStartup);
+ on_launch();
}
- let r = crate::session::mount(r);
- let r = crate::api::mount(r);
-
- thread::spawn(client_periodic_expiry);
- thread::spawn(logs_periodic_expiry);
+ http.run().await.context("after startup")?;
- r.launch();
+ Ok(())
}