use actix_web::{ client::Client, dev::HttpResponseBuilder, http::{ header::{CacheControl, CacheDirective, ContentType, LastModified, LOCATION}, StatusCode, }, web, HttpRequest, HttpResponse, ResponseError, Scope, }; use sled::Db; use std::{io::Cursor, net::SocketAddr, time::SystemTime}; use structopt::StructOpt; use url::Url; use uuid::Uuid; include!(concat!(env!("OUT_DIR"), "/templates.rs")); const HOURS: u32 = 60 * 60; const DAYS: u32 = 24 * HOURS; mod connection; mod middleware; mod pict; mod store; mod ui; use self::{connection::Connection, middleware::ValidToken, store::Store}; #[derive(Clone, Debug, StructOpt)] pub struct Config { #[structopt( short, long, env = "PICTRS_AGGREGATOR_ADDR", default_value = "0.0.0.0:8082", help = "The address and port the server binds to" )] addr: SocketAddr, #[structopt( short, long, env = "PICTRS_AGGREGATOR_UPSTREAM", default_value = "http://localhost:8080", help = "The url of the upstream pict-rs server" )] upstream: Url, } pub fn accept() -> &'static str { "image/png,image/jpeg,image/webp,.jpg,.jpeg,.png,.webp" } impl Config { pub fn bind_address(&self) -> SocketAddr { self.addr } } #[derive(Clone)] pub struct State { upstream: Url, scope: String, store: Store, db: Db, startup: SystemTime, } impl State { fn scoped(&self, s: &str) -> String { if self.scope == "" && s == "" { "/".to_string() } else if s == "" { self.scope.clone() } else if self.scope == "" { format!("/{}", s) } else { format!("{}/{}", self.scope, s) } } fn create_collection_path(&self) -> String { self.scoped("") } fn edit_collection_path(&self, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{}?token={}", id, token.token)) } fn update_collection_path(&self, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{}?token={}", id, token.token)) } fn delete_collection_path(&self, id: Uuid, token: &ValidToken, confirmed: bool) -> String { if confirmed { self.scoped(&format!( "{}/delete?token={}&confirmed=true", id, token.token )) } else { self.scoped(&format!("{}/delete?token={}", id, token.token)) } } fn public_collection_path(&self, id: Uuid) -> String { self.scoped(&format!("{}", id)) } fn create_entry_path(&self, collection_id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{}/entry?token={}", collection_id, token.token)) } fn update_entry_path(&self, collection_id: Uuid, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!( "{}/entry/{}?token={}", collection_id, id, token.token )) } fn delete_entry_path( &self, collection_id: Uuid, id: Uuid, token: &ValidToken, confirmed: bool, ) -> String { if confirmed { self.scoped(&format!( "{}/entry/{}/delete?token={}&confirmed=true", collection_id, id, token.token )) } else { self.scoped(&format!( "{}/entry/{}/delete?token={}", collection_id, id, token.token )) } } fn statics_path(&self, file: &str) -> String { self.scoped(&format!("static/{}", file)) } fn thumbnail_path(&self, entry: &Entry, size: u16, extension: pict::Extension) -> String { self.scoped(&format!( "image/thumbnail.{}?src={}&size={}", extension, entry.filename, size )) } fn srcset(&self, entry: &Entry, extension: pict::Extension) -> String { connection::VALID_SIZES .iter() .map(|size| format!("{} {}w", self.thumbnail_path(entry, *size, extension), size,)) .collect::>() .join(", ") } fn image_path(&self, entry: &Entry) -> String { self.scoped(&format!("image/full/{}", entry.filename)) } } pub fn state(config: Config, scope: &str, db: Db) -> Result { Ok(State { upstream: config.upstream, scope: scope.to_string(), store: Store::new(&db)?, db, startup: SystemTime::now(), }) } pub fn service(client: Client, state: State) -> Scope { web::scope(&state.scoped("")) .data(Connection::new(state.upstream.clone(), client)) .data(state) .service(web::resource("/static/{filename}").route(web::get().to(static_files))) .service(web::resource("404").route(web::get().to(not_found))) .service( web::scope("image") .service(web::resource("/thumbnail.{extension}").route(web::get().to(thumbnail))) .service(web::resource("/full/{filename}").route(web::get().to(image))), ) .service( web::resource("") .route(web::get().to(index)) .route(web::post().to(create_collection)), ) .service( web::scope("/{collection}") .wrap(middleware::Verify) .service( web::resource("") .route(web::get().to(collection)) .route(web::post().to(update_collection)), ) .service(web::resource("/delete").route(web::get().to(delete_collection))) .service( web::scope("/entry") .service(web::resource("").route(web::post().to(upload))) .service( web::scope("/{entry}") .service(web::resource("").route(web::post().to(update_entry))) .service( web::resource("/delete").route(web::get().to(delete_entry)), ), ), ), ) } fn to_edit_page(id: Uuid, token: &ValidToken, state: &State) -> HttpResponse { HttpResponse::SeeOther() .insert_header((LOCATION, state.edit_collection_path(id, token))) .finish() } fn to_404(state: &State) -> HttpResponse { HttpResponse::MovedPermanently() .insert_header((LOCATION, state.create_collection_path())) .finish() } fn to_home(state: &State) -> HttpResponse { HttpResponse::SeeOther() .insert_header((LOCATION, state.create_collection_path())) .finish() } trait ResultExt { fn stateful(self, state: &web::Data) -> Result; } impl ResultExt for Result where Error: From, { fn stateful(self, state: &web::Data) -> Result { self.map_err(Error::from).map_err(|error| StateError { state: state.clone(), error, }) } } async fn static_files(filename: web::Path, state: web::Data) -> HttpResponse { let filename = filename.into_inner(); if let Some(data) = self::templates::statics::StaticFile::get(&filename) { return HttpResponse::Ok() .insert_header(LastModified(state.startup.into())) .insert_header(CacheControl(vec![ CacheDirective::Public, CacheDirective::MaxAge(365 * DAYS), CacheDirective::Extension("immutable".to_owned(), None), ])) .insert_header(ContentType(data.mime.clone())) .body(data.content); } to_404(&state) } async fn not_found(state: web::Data) -> Result { rendered( |cursor| self::templates::not_found(cursor, &state), HttpResponse::NotFound(), ) .stateful(&state) } struct StateError { state: web::Data, error: Error, } impl std::fmt::Debug for StateError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.debug_struct("StateError") .field("error", &self.error) .finish() } } impl std::fmt::Display for StateError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.error) } } impl ResponseError for StateError { fn status_code(&self) -> StatusCode { StatusCode::INTERNAL_SERVER_ERROR } fn error_response(&self) -> HttpResponse { match rendered( |cursor| self::templates::error(cursor, &self.error.to_string(), &self.state), HttpResponse::build(self.status_code()), ) { Ok(res) => res, Err(_) => HttpResponse::build(self.status_code()) .content_type(mime::TEXT_PLAIN.essence_str()) .body(self.error.to_string()), } } } #[derive(Debug, thiserror::Error)] enum Error { #[error("{0}")] Render(#[from] std::io::Error), #[error("{0}")] Store(#[from] self::store::Error), #[error("{0}")] Upload(#[from] self::connection::UploadError), #[error("{0}")] UploadString(String), #[error("Failed to minify html, {0}")] Minify(String), } impl From for Error { fn from(e: minify_html::Error) -> Self { Error::Minify(format!("{:?}", e)) } } #[derive(serde::Deserialize, serde::Serialize)] pub struct Collection { title: String, description: String, } #[derive(serde::Deserialize, serde::Serialize)] pub struct Entry { title: String, description: String, filename: String, delete_token: String, } #[derive(Clone, serde::Deserialize)] pub struct Token { token: Uuid, } impl Token { fn hash(&self) -> Result { use bcrypt::{hash, DEFAULT_COST}; Ok(TokenStorage { token: hash(self.token.as_bytes(), DEFAULT_COST)?, }) } } #[derive(serde::Deserialize, serde::Serialize)] struct TokenStorage { token: String, } impl TokenStorage { fn verify(&self, token: &Token) -> Result { bcrypt::verify(&token.token.as_bytes(), &self.token) } } #[derive(serde::Deserialize)] struct CollectionPath { collection: Uuid, } impl CollectionPath { fn key(&self) -> String { format!("{}", self.collection) } fn entry_range(&self) -> std::ops::Range> { let base = format!("{}/entry/", self.collection).as_bytes().to_vec(); let mut start = base.clone(); let mut end = base; start.push(0x0); end.push(0xff); start..end } fn token_key(&self) -> String { format!("{}/token", self.collection) } } #[derive(serde::Deserialize)] struct EntryPath { collection: Uuid, entry: Uuid, } impl EntryPath { fn key(&self) -> String { format!("{}/entry/{}", self.collection, self.entry) } } async fn upload( req: HttpRequest, pl: web::Payload, path: web::Path, token: ValidToken, conn: web::Data, state: web::Data, ) -> Result { let images = conn.upload(&req, pl).await.stateful(&state)?; if images.is_err() { return Err(Error::UploadString(images.message().to_owned())).stateful(&state); } let image = images .files() .next() .ok_or(Error::UploadString("Missing file".to_owned())) .stateful(&state)?; let entry = Entry { title: String::new(), description: String::new(), filename: image.file().to_owned(), delete_token: image.delete_token().to_owned(), }; let entry_path = EntryPath { collection: path.collection, entry: Uuid::new_v4(), }; store::CreateEntry { entry_path: &entry_path, entry: &entry, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page(path.collection, &token, &state)) } #[derive(serde::Deserialize)] struct ImagePath { filename: String, } async fn image( req: HttpRequest, path: web::Path, conn: web::Data, ) -> Result { conn.image(&path.filename, &req).await } #[derive(serde::Deserialize)] struct ThumbnailPath { extension: pict::Extension, } #[derive(serde::Deserialize)] struct ThumbnailQuery { src: String, size: u16, } async fn thumbnail( req: HttpRequest, path: web::Path, query: web::Query, conn: web::Data, ) -> Result { conn.thumbnail(query.size, &query.src, path.extension, &req) .await } async fn index(state: web::Data) -> Result { rendered( |cursor| self::templates::index(cursor, &state), HttpResponse::Ok(), ) .stateful(&state) } async fn collection( path: web::Path, token: Option, state: web::Data, ) -> Result { match token { Some(token) => edit_collection(path, token, state.clone()) .await .stateful(&state), None => view_collection(path, state.clone()).await.stateful(&state), } } async fn view_collection( path: web::Path, state: web::Data, ) -> Result { let collection = match state.store.collection(&path).await? { Some(collection) => collection, None => return Ok(to_404(&state)), }; let entries = state.store.entries(path.entry_range()).await?; rendered( |cursor| { self::templates::view_collection(cursor, path.collection, &collection, &entries, &state) }, HttpResponse::Ok(), ) } async fn edit_collection( path: web::Path, token: ValidToken, state: web::Data, ) -> Result { let collection = match state.store.collection(&path).await? { Some(collection) => collection, None => return Ok(to_404(&state)), }; let entries = state.store.entries(path.entry_range()).await?; rendered( |cursor| { self::templates::edit_collection( cursor, &collection, path.collection, &entries, &token, &state, ) }, HttpResponse::Ok(), ) } async fn create_collection( collection: web::Form, state: web::Data, ) -> Result { let collection_id = Uuid::new_v4(); let collection_path = CollectionPath { collection: collection_id, }; let token = Token { token: Uuid::new_v4(), }; store::CreateCollection { collection_path: &collection_path, collection: &collection, token: &token, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page( collection_path.collection, &ValidToken { token: token.token }, &state, )) } async fn update_collection( path: web::Path, form: web::Form, token: ValidToken, state: web::Data, ) -> Result { store::UpdateCollection { collection_path: &path, collection: &form, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page(path.collection, &token, &state)) } async fn update_entry( entry_path: web::Path, entry: web::Form, token: ValidToken, state: web::Data, ) -> Result { store::UpdateEntry { entry_path: &entry_path, entry: &entry, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page(entry_path.collection, &token, &state)) } #[derive(serde::Deserialize)] struct ConfirmQuery { confirmed: Option, } async fn delete_entry( entry_path: web::Path, query: web::Query, token: ValidToken, conn: web::Data, state: web::Data, ) -> Result { let res = state.store.entry(&entry_path).await.stateful(&state)?; let entry = match res { Some(entry) => entry, None => return Ok(to_404(&state)), }; if !query.confirmed.unwrap_or(false) { return rendered( |cursor| { self::templates::confirm_entry_delete( cursor, entry_path.collection, entry_path.entry, &entry, &token, &state, ) }, HttpResponse::Ok(), ) .stateful(&state); } conn.delete(&entry.filename, &entry.delete_token) .await .stateful(&state)?; store::DeleteEntry { entry_path: &entry_path, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page(entry_path.collection, &token, &state)) } async fn delete_collection( path: web::Path, query: web::Query, token: ValidToken, conn: web::Data, state: web::Data, ) -> Result { if !query.confirmed.unwrap_or(false) { let res = state.store.collection(&path).await.stateful(&state)?; let collection = match res { Some(collection) => collection, None => return Ok(to_404(&state)), }; return rendered( |cursor| { self::templates::confirm_delete( cursor, path.collection, &collection, &token, &state, ) }, HttpResponse::Ok(), ) .stateful(&state); } let entries = state .store .entries(path.entry_range()) .await .stateful(&state)?; let future_vec = entries .iter() .map(|(_, entry)| conn.delete(&entry.filename, &entry.delete_token)) .collect::>(); let results = futures::future::join_all(future_vec).await; // Only bail before deleting collection if all images failed deletion // It is possible that some images were already deleted if results.iter().all(|r| r.is_err()) { for result in results { result.stateful(&state)?; } } store::DeleteCollection { collection_path: &path, } .exec(&state.store) .await .stateful(&state)?; Ok(to_home(&state)) } fn rendered( f: impl FnOnce(&mut Cursor>) -> std::io::Result<()>, mut builder: HttpResponseBuilder, ) -> Result { let mut cursor = Cursor::new(vec![]); (f)(&mut cursor)?; let mut html = cursor.into_inner(); let len = minify_html::in_place( &mut html, &minify_html::Cfg { minify_js: true, minify_css: true, }, )?; html.truncate(len); Ok(builder .content_type(mime::TEXT_HTML.essence_str()) .body(html)) }