hyaenidae/src/main.rs
2021-04-02 12:07:19 -05:00

537 lines
14 KiB
Rust

use actix_rt::ArbiterHandle;
use actix_web::{
dev::HttpResponseBuilder,
http::header::{CacheControl, CacheDirective, ContentType, LastModified},
middleware::{Compress, Logger},
web, App, HttpResponse, HttpServer,
};
use awc::Client;
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=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<u8> = 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(),
config.content(),
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::<HyaenidaeResolver>),
))
.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<String>,
#[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<SecretKey>,
#[structopt(
long,
env = "HYAENIDAE_SKIP_SIGNATURE_VALIDATION",
about = "don't validate HTTP Signatures on requests"
)]
skip_signature_validation: bool,
#[structopt(
long,
env = "HYAENIDAE_MAX_BIO_LENGTH",
about = "Maximum allowed characters in a profile bio",
default_value = "500"
)]
max_bio: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_DISPLAY_NAME_LENGTH",
about = "Maximum allowed characters in a profile display name",
default_value = "20"
)]
max_display_name: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_HANDLE_LENGTH",
about = "Maximum allowed characters in a profile handle",
default_value = "20"
)]
max_handle: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_SUBMISSION_TITLE_LENGTH",
about = "Maximum allowed characters in a submission title",
default_value = "50"
)]
max_submission_title: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_SUBMISSION_BODY_LENGTH",
about = "Maximum allowed characters in a submission body",
default_value = "1000"
)]
max_submission_body: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_POST_TITLE_LENGTH",
about = "Maximum allowed characters in a post title",
default_value = "50"
)]
max_post_title: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_POST_BODY_LENGTH",
about = "Maximum allowed characters in a post body",
default_value = "1000"
)]
max_post_body: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_COMMENT_LENGTH",
about = "Maximum allowed characters in a comment",
default_value = "500"
)]
max_comment: usize,
#[structopt(short, long, about = "enable debug logging")]
debug: bool,
}
impl Config {
fn content(&self) -> hyaenidae_profiles::ContentConfig {
hyaenidae_profiles::ContentConfig {
max_bio_length: self.max_bio,
max_display_name_length: self.max_display_name,
max_handle_length: self.max_handle,
max_domain_length: 256,
max_submission_title_length: self.max_submission_title,
max_submission_body_length: self.max_submission_body,
max_post_title_length: self.max_post_title,
max_post_body_length: self.max_post_body,
max_comment_length: self.max_comment,
max_server_title_length: 50,
max_server_body_length: 1000,
}
}
}
#[derive(Clone)]
struct SecretKey {
inner: Vec<u8>,
}
impl std::str::FromStr for SecretKey {
type Err = base64::DecodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(SecretKey {
inner: base64::decode(s)?,
})
}
}
pub struct Urls;
impl hyaenidae_profiles::UrlFor for Urls {
fn profile(&self, profile: &hyaenidae_profiles::store::Profile) -> String {
format!("/profiles/id/{}", profile.id())
}
fn icon(&self, file: &hyaenidae_profiles::store::File) -> String {
file.pictrs_key()
.map(|key| images::largest_icon(key, images::ImageType::Png))
.unwrap_or("/404".to_owned())
}
}
#[derive(Clone)]
struct State {
profiles: Arc<hyaenidae_profiles::State>,
admin: admin::Store,
spawn: jobs::Spawn,
apub: apub::Apub,
images: images::Images,
settings: profiles::SettingStore,
domain: String,
base_url: url::Url,
db: Db,
}
impl State {
fn new(
spawn: jobs::Spawn,
base_url: url::Url,
pict_rs_upstream: url::Url,
content_config: hyaenidae_profiles::ContentConfig,
arbiter: ArbiterHandle,
db: &Db,
) -> Result<Self, Error> {
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)?;
let settings = profiles::SettingStore::build(db)?;
Ok(State {
profiles: hyaenidae_profiles::State::build(
pict_rs_upstream,
images.clone(),
apub.clone(),
spawn.clone(),
Urls,
content_config,
arbiter,
db.clone(),
)?,
admin,
spawn,
apub,
images,
settings,
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<UserProfile>) -> HttpResponse {
if profile.is_some() {
redirect("/feed")
} else {
redirect("/browse")
}
}
async fn toolkit(
path: web::Path<(String, String)>,
startup: web::Data<SystemTime>,
) -> 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()
.insert_header(LastModified(SystemTime::clone(&startup).into()))
.insert_header(CacheControl(vec![
CacheDirective::Public,
CacheDirective::MaxAge(365 * DAYS),
CacheDirective::Extension("immutable".to_owned(), None),
]))
.insert_header(ContentType(file.mime.clone()))
.body(file.content);
}
HttpResponse::NotFound().finish()
}
async fn statics(path: web::Path<String>, startup: web::Data<SystemTime>) -> HttpResponse {
if let Some(file) = templates::statics::StaticFile::get(&path) {
return HttpResponse::Ok()
.insert_header(LastModified(SystemTime::clone(&startup).into()))
.insert_header(CacheControl(vec![
CacheDirective::Public,
CacheDirective::MaxAge(365 * DAYS),
CacheDirective::Extension("immutable".to_owned(), None),
]))
.insert_header(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()
.insert_header(("Location", path))
.finish()
}
async fn not_found(loader: ActixLoader, nav_state: NavState) -> Result<HttpResponse, Error> {
rendered(HttpResponse::NotFound(), |cursor| {
templates::not_found(cursor, loader, nav_state.dark())
})
}
async fn serve_error(loader: ActixLoader, nav_state: NavState) -> Result<HttpResponse, Error> {
rendered(HttpResponse::InternalServerError(), |cursor| {
templates::error(cursor, loader, nav_state.dark())
})
}
async fn settings(loader: ActixLoader, nav_state: NavState) -> Result<HttpResponse, Error> {
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<u8> {
self.inner.get_html().to_vec()
}
}
impl std::io::Write for MinifyWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<Lazy<MinifyWriter>> = RefCell::new(Lazy::new(|| MinifyWriter::new()));
}
fn rendered(
mut builder: HttpResponseBuilder,
f: impl FnOnce(&mut MinifyWriter) -> std::io::Result<()>,
) -> Result<HttpResponse, Error> {
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()))
})
}