use actix_rt::Arbiter; use actix_web::{ client::Client, dev::HttpResponseBuilder, http::header::{CacheControl, CacheDirective, ContentType, LastModified}, middleware::{Compress, Logger}, web, App, HttpResponse, HttpServer, }; use hyaenidae_accounts::Auth; use sled::Db; use std::{fmt, sync::Arc, time::SystemTime}; use structopt::StructOpt; mod accounts; mod admin; mod apub; mod browse; mod comments; mod error; mod extensions; mod feed; mod i18n; mod images; mod jobs; mod middleware; mod nav; mod notifications; mod pagination; mod profile_list; mod profiles; mod strings; mod submissions; mod views; mod webfinger; use error::{Error, OptionExt}; use i18n::ActixLoader; use middleware::{CurrentProfile, UserProfile}; use nav::NavState; use webfinger::HyaenidaeResolver; include!(concat!(env!("OUT_DIR"), "/templates.rs")); const HOURS: u32 = 60 * 60; const DAYS: u32 = 24 * HOURS; #[actix_web::main] async fn main() -> anyhow::Result<()> { let config = Config::from_args(); if std::env::var("RUST_LOG").is_err() { if config.debug { std::env::set_var("RUST_LOG", "hyaenidae_content=debug,hyaenidae_profiles=debug,hyaenidae_accounts=debug,hyaenidae_toolkit=debug,hyaenidae_server=debug,info"); } else { std::env::set_var("RUST_LOG", "info"); } } tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let secret_key = if let Some(secret_key) = &config.secret_key { secret_key.inner.to_owned() } else { use rand::{thread_rng, RngCore}; let mut bytes = [0u8; 64]; thread_rng().fill_bytes(&mut bytes); let secret_key: Vec = bytes.into(); let secret_key_str = base64::encode(&secret_key); log::warn!("No secret key set! Generated '{}'", secret_key_str); secret_key }; let accounts_config = hyaenidae_accounts::Config { key: secret_key, https: false, pages: std::sync::Arc::new(accounts::Pages), }; let db = sled::open(&config.sled_path)?; let state = jobs::build( config.base_url.clone(), config.pictrs_upstream.clone(), db.clone(), )?; let accounts_state = hyaenidae_accounts::state(&accounts_config, db.clone())?; let domain = config .base_url .domain() .expect("Invalid domain for base url") .to_owned(); state.profiles.create_server_actor(domain).await?; if let Some(user) = config.make_admin { let user = accounts_state.by_username(user).await?.req()?; state.admin.make_admin(user.id())?; return Ok(()); } HttpServer::new(move || { let state = state.clone(); let accounts_config = accounts_config.clone(); let accounts_state = accounts_state.clone(); let client = build_client(); App::new() .wrap(CurrentProfile(state.clone())) .data(client) .data(state.clone()) .wrap(Auth(accounts_state.clone())) .data(accounts_state) .data(SystemTime::now()) .wrap(hyaenidae_accounts::cookie_middleware(&accounts_config)) .wrap(Compress::default()) .wrap(Logger::default()) .route("/404", web::get().to(not_found)) .route("/500", web::get().to(serve_error)) .route("/", web::get().to(home)) .route("/browse", web::get().to(browse::index)) .route("/settings", web::get().to(settings)) .route("/toolkit/{folder}/{name}", web::get().to(toolkit)) .route("/static/{name}", web::get().to(statics)) .service(web::scope("/.well-known").route( "/webfinger", web::get().to(actix_webfinger::endpoint::), )) .service(profile_list::scope()) .service(feed::scope()) .service(accounts::scope()) .service(images::scope()) .service(apub::scope(state)) .service(profiles::scope()) .service(submissions::scope()) .service(comments::scope()) .service(admin::scope()) .service(notifications::scope()) .default_service(web::route().to(to_404)) }) .bind(config.bind_address)? .run() .await?; Ok(()) } #[derive(Clone, StructOpt)] struct Config { #[structopt(long, about = "Make the provided user an admin")] make_admin: Option, #[structopt( short, long, default_value = "0.0.0.0:8085", env = "HYAENIDAE_BIND_ADDR" )] bind_address: std::net::SocketAddr, #[structopt( short = "u", long, default_value = "http://localhost:8085", env = "HYAENIDAE_BASE_URL" )] base_url: url::Url, #[structopt( short, long, default_value = "http://localhost:8080", env = "HYAENIDAE_PICTRS_UPSTREAM" )] pictrs_upstream: url::Url, #[structopt( short, long, default_value = "./sled/db-0.34", env = "HYAENIDAE_SLED_PATH" )] sled_path: std::path::PathBuf, #[structopt(long, env = "HYAENIDAE_SECRET_KEY")] secret_key: Option, #[structopt( long, env = "HYAENIDAE_SKIP_SIGNATURE_VALIDATION", about = "don't validate HTTP Signatures on requests" )] skip_signature_validation: bool, #[structopt(short, long, about = "enable debug logging")] debug: bool, } #[derive(Clone)] struct SecretKey { inner: Vec, } impl std::str::FromStr for SecretKey { type Err = base64::DecodeError; fn from_str(s: &str) -> Result { Ok(SecretKey { inner: base64::decode(s)?, }) } } #[derive(Clone)] struct State { profiles: Arc, admin: admin::Store, spawn: jobs::Spawn, apub: apub::Apub, images: images::Images, domain: String, base_url: url::Url, db: Db, } impl State { fn new( spawn: jobs::Spawn, base_url: url::Url, pict_rs_upstream: url::Url, arbiter: Arbiter, db: &Db, ) -> Result { let images = images::Images::new(base_url.clone()); let apub = apub::Apub::build(base_url.clone(), db)?; let domain = base_url.domain().req()?.to_owned(); let admin = admin::Store::build(db)?; Ok(State { profiles: hyaenidae_profiles::State::build( pict_rs_upstream, images.clone(), apub.clone(), spawn.clone(), arbiter, db.clone(), )?, admin, spawn, apub, images, base_url, domain, db: db.clone(), }) } } fn build_client() -> Client { Client::builder() .header("User-Agent", "hyaenidae-0.1.0") .finish() } impl fmt::Debug for State { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.debug_struct("State").field("db", &"Db").finish() } } fn toolkit_path(name: &str) -> String { format!("/toolkit/static/{}", name) } fn statics_path(name: &str) -> String { format!("/static/{}", name) } async fn home(profile: Option) -> HttpResponse { if profile.is_some() { redirect("/feed") } else { redirect("/browse") } } async fn toolkit( path: web::Path<(String, String)>, startup: web::Data, ) -> HttpResponse { let (folder, path) = path.into_inner(); let path = if folder == "fonts" { format!("fonts/{}", path) } else { path }; if let Some(file) = hyaenidae_toolkit::templates::statics::StaticFile::get(&path) { return HttpResponse::Ok() .set(LastModified(SystemTime::clone(&startup).into())) .set(CacheControl(vec![ CacheDirective::Public, CacheDirective::MaxAge(365 * DAYS), CacheDirective::Extension("immutable".to_owned(), None), ])) .set(ContentType(file.mime.clone())) .body(file.content); } HttpResponse::NotFound().finish() } async fn statics(path: web::Path, startup: web::Data) -> HttpResponse { if let Some(file) = templates::statics::StaticFile::get(&path) { return HttpResponse::Ok() .set(LastModified(SystemTime::clone(&startup).into())) .set(CacheControl(vec![ CacheDirective::Public, CacheDirective::MaxAge(365 * DAYS), CacheDirective::Extension("immutable".to_owned(), None), ])) .set(ContentType(file.mime.clone())) .body(file.content); } HttpResponse::NotFound().finish() } fn body_class(dark: bool) -> &'static str { if dark { "toolkit-dark" } else { "" } } fn to_404() -> HttpResponse { redirect("/404") } fn to_500() -> HttpResponse { redirect("/500") } fn to_home() -> HttpResponse { redirect("/") } fn redirect(path: &str) -> HttpResponse { HttpResponse::SeeOther().header("Location", path).finish() } async fn not_found(loader: ActixLoader, nav_state: NavState) -> Result { rendered(HttpResponse::NotFound(), |cursor| { templates::not_found(cursor, loader, nav_state.dark()) }) } async fn serve_error(loader: ActixLoader, nav_state: NavState) -> Result { rendered(HttpResponse::InternalServerError(), |cursor| { templates::error(cursor, loader, nav_state.dark()) }) } async fn settings(loader: ActixLoader, nav_state: NavState) -> Result { rendered(HttpResponse::Ok(), |cursor| { templates::settings(cursor, &loader, &nav_state) }) } struct MinifyWriter { inner: html_minifier::HTMLMinifier, } impl MinifyWriter { fn new() -> Self { let mut minifier = html_minifier::HTMLMinifier::new(); minifier.set_remove_comments(true); minifier.set_minify_code(false); MinifyWriter { inner: minifier } } fn reset(&mut self) { self.inner.reset(); } fn get_html(&mut self) -> Vec { self.inner.get_html().to_vec() } } impl std::io::Write for MinifyWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { let len = buf.len(); self.inner .digest(buf) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) .map(|_| len) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } use once_cell::unsync::Lazy; use std::cell::RefCell; thread_local! { static MINIFIER: RefCell> = RefCell::new(Lazy::new(|| MinifyWriter::new())); } fn rendered( mut builder: HttpResponseBuilder, f: impl FnOnce(&mut MinifyWriter) -> std::io::Result<()>, ) -> Result { MINIFIER.with(|writer| { writer.borrow_mut().reset(); (f)(&mut *writer.borrow_mut()).map_err(Error::Render)?; Ok(builder .content_type(mime::TEXT_HTML.essence_str()) .body(writer.borrow_mut().get_html().to_vec())) }) }