use actix_web::{ client::Client, 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, } 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_aggregation_path(&self) -> String { self.scoped("") } fn edit_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{}?token={}", id, token.token)) } fn update_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{}?token={}", id, token.token)) } fn delete_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{}/delete?token={}", id, token.token)) } fn public_aggregation_path(&self, id: Uuid) -> String { self.scoped(&format!("{}", id)) } fn create_entry_path(&self, aggregation_id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{}/entry?token={}", aggregation_id, token.token)) } fn update_entry_path(&self, aggregation_id: Uuid, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!( "{}/entry/{}?token={}", aggregation_id, id, token.token )) } fn delete_entry_path(&self, aggregation_id: Uuid, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!( "{}/entry/{}/delete?token={}", aggregation_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, }) } 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_aggregation)), ) .service( web::scope("/{aggregation}") .wrap(middleware::Verify) .service( web::resource("") .route(web::get().to(aggregation)) .route(web::post().to(update_aggregation)), ) .service(web::resource("/delete").route(web::get().to(delete_aggregation))) .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() .header(LOCATION, state.edit_aggregation_path(id, token)) .finish() } fn to_404(state: &State) -> HttpResponse { HttpResponse::MovedPermanently() .header(LOCATION, state.create_aggregation_path()) .finish() } fn to_home(state: &State) -> HttpResponse { HttpResponse::SeeOther() .header(LOCATION, state.create_aggregation_path()) .finish() } 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() .set(LastModified(SystemTime::now().into())) .set(CacheControl(vec![ CacheDirective::Public, CacheDirective::MaxAge(365 * DAYS), CacheDirective::Extension("immutable".to_owned(), None), ])) .set(ContentType(data.mime.clone())) .body(data.content); } to_404(&state) } async fn not_found(state: web::Data) -> Result { let mut cursor = Cursor::new(vec![]); self::templates::not_found(&mut cursor, &state)?; Ok(HttpResponse::NotFound() .content_type(mime::TEXT_HTML.essence_str()) .body(cursor.into_inner())) } #[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), } impl ResponseError for Error { fn status_code(&self) -> StatusCode { StatusCode::INTERNAL_SERVER_ERROR } fn error_response(&self) -> HttpResponse { match self { Self::Store(self::store::Error::NotFound) => HttpResponse::MovedPermanently() .header(LOCATION, "/404") .finish(), _ => HttpResponse::build(self.status_code()) .content_type(mime::TEXT_PLAIN.essence_str()) .body(format!("{}", self)), } } } #[derive(serde::Deserialize, serde::Serialize)] pub struct Aggregation { 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 AggregationPath { aggregation: Uuid, } impl AggregationPath { fn key(&self) -> String { format!("{}", self.aggregation) } fn entry_range(&self) -> std::ops::Range> { let base = format!("{}/entry/", self.aggregation).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.aggregation) } } #[derive(serde::Deserialize)] struct EntryPath { aggregation: Uuid, entry: Uuid, } impl EntryPath { fn key(&self) -> String { format!("{}/entry/{}", self.aggregation, 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?; if images.is_err() { return Err(Error::UploadString(images.message().to_owned())); } let image = images .files() .next() .ok_or(Error::UploadString("Missing file".to_owned()))?; 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 { aggregation: path.aggregation, entry: Uuid::new_v4(), }; store::CreateEntry { entry_path: &entry_path, entry: &entry, } .exec(&state.store) .await?; Ok(to_edit_page(path.aggregation, &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 { let mut cursor = Cursor::new(vec![]); self::templates::index(&mut cursor, &state)?; Ok(HttpResponse::Ok() .content_type(mime::TEXT_HTML.essence_str()) .body(cursor.into_inner())) } async fn aggregation( path: web::Path, token: Option, state: web::Data, ) -> Result { match token { Some(token) => edit_aggregation(path, token, state).await, None => view_aggregation(path, state).await, } } async fn view_aggregation( path: web::Path, state: web::Data, ) -> Result { let aggregation = state.store.aggregation(&path).await?; let entries = state.store.entries(path.entry_range()).await?; let mut cursor = Cursor::new(vec![]); self::templates::view_aggregation(&mut cursor, &aggregation, &entries, &state)?; Ok(HttpResponse::Ok() .content_type(mime::TEXT_HTML.essence_str()) .body(cursor.into_inner())) } async fn edit_aggregation( path: web::Path, token: ValidToken, state: web::Data, ) -> Result { let aggregation = state.store.aggregation(&path).await?; let entries = state.store.entries(path.entry_range()).await?; let mut cursor = Cursor::new(vec![]); self::templates::edit_aggregation( &mut cursor, &aggregation, path.aggregation, &entries, &token, &state, )?; Ok(HttpResponse::Ok() .content_type(mime::TEXT_HTML.essence_str()) .body(cursor.into_inner())) } async fn create_aggregation( aggregation: web::Form, state: web::Data, ) -> Result { let aggregation_id = Uuid::new_v4(); let aggregation_path = AggregationPath { aggregation: aggregation_id, }; let token = Token { token: Uuid::new_v4(), }; store::CreateAggregation { aggregation_path: &aggregation_path, aggregation: &aggregation, token: &token, } .exec(&state.store) .await?; Ok(to_edit_page( aggregation_path.aggregation, &ValidToken { token: token.token }, &state, )) } async fn update_aggregation( path: web::Path, form: web::Form, token: ValidToken, state: web::Data, ) -> Result { store::UpdateAggregation { aggregation_path: &path, aggregation: &form, } .exec(&state.store) .await?; Ok(to_edit_page(path.aggregation, &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?; Ok(to_edit_page(entry_path.aggregation, &token, &state)) } async fn delete_entry( entry_path: web::Path, token: ValidToken, conn: web::Data, state: web::Data, ) -> Result { let entry = state.store.entry(&entry_path).await?; conn.delete(&entry.filename, &entry.delete_token).await?; store::DeleteEntry { entry_path: &entry_path, } .exec(&state.store) .await?; Ok(to_edit_page(entry_path.aggregation, &token, &state)) } async fn delete_aggregation( path: web::Path, _token: ValidToken, conn: web::Data, state: web::Data, ) -> Result { let entries = state.store.entries(path.entry_range()).await?; let future_vec = entries .iter() .map(|(_, entry)| conn.delete(&entry.filename, &entry.delete_token)) .collect::>(); futures::future::try_join_all(future_vec).await?; store::DeleteAggregation { aggregation_path: &path, } .exec(&state.store) .await?; Ok(to_home(&state)) }