// need this for ructe #![allow(clippy::needless_borrow)] use actix_web::{ http::{ header::{CacheControl, CacheDirective, ContentType, LastModified, LOCATION}, StatusCode, }, web, HttpRequest, HttpResponse, HttpResponseBuilder, ResponseError, }; use awc::Client; use clap::Parser; use sled::Db; use std::{ io::Cursor, net::SocketAddr, path::{Path, PathBuf}, time::SystemTime, }; use tracing_error::SpanTrace; 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 optional; mod pict; mod store; mod ui; use self::{connection::Connection, middleware::ValidToken, optional::Optional, store::Store}; /// A simple image collection service backed by pict-rs #[derive(Clone, Debug, Parser)] #[command(author, version, about, long_about = None)] pub struct Config { #[arg( short, long, env = "PICTRS_AGGREGATOR_ADDR", default_value = "0.0.0.0:8082", help = "The address and port the server binds to" )] addr: SocketAddr, #[arg( short, long, env = "PICTRS_AGGREGATOR_UPSTREAM", default_value = "http://localhost:8080", help = "The url of the upstream pict-rs server" )] upstream: Url, #[arg( short, long, env = "PICTRS_AGGREGATOR_DATABASE", default_value = "sled/db-0-34", help = "The path to the database" )] database_path: PathBuf, #[arg( short, long, env = "PICTRS_AGGREGATOR_SLED_CACHE_CAPACITY", default_value = "67108864", help = "The amount of RAM, in bytes, that sled is allowed to consume. Increasing this value can improve performance" )] sled_cache_capacity: u64, #[arg( short, long, env = "PICTRS_AGGREGATOR_CONSOLE_EVENT_BUFFER_SIZE", help = "The number of events to buffer in console. When unset, console is disabled" )] console_event_buffer_size: Option, #[arg( short, long, env = "PICTRS_AGGREGATOR_OPENTELEMETRY_URL", help = "URL for the OpenTelemetry Colletor" )] opentelemetry_url: Option, } pub fn accept() -> &'static str { "image/apng,image/avif,image/gif,image/png,image/jpeg,image/jxl,image/webp,.apng,.avif,.gif,.jpg,.jpeg,.jxl,.png,.webp" } impl Config { pub fn db_path(&self) -> &Path { &self.database_path } pub fn sled_cache_capacity(&self) -> u64 { self.sled_cache_capacity } pub fn console_event_buffer_size(&self) -> Option { self.console_event_buffer_size } pub fn bind_address(&self) -> SocketAddr { self.addr } pub fn opentelemetry_url(&self) -> Option<&Url> { self.opentelemetry_url.as_ref() } } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub enum Direction { #[serde(rename = "up")] Up, #[serde(rename = "down")] Down, } impl std::fmt::Display for Direction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Up => write!(f, "up"), Self::Down => write!(f, "down"), } } } #[derive(Clone)] pub struct State { upstream: Url, scope: String, store: Store, startup: SystemTime, } impl std::fmt::Debug for State { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("State") .field("upstream", &self.upstream.as_str()) .field("scope", &self.scope) .field("store", &self.store) .field("db", &"Db") .field("startup", &self.startup) .finish() } } impl State { fn scoped(&self, s: &str) -> String { if self.scope.is_empty() && s.is_empty() { "/".to_string() } else if s.is_empty() { self.scope.clone() } else if self.scope.is_empty() { format!("/{s}") } else { format!("{}/{s}", self.scope) } } fn create_collection_path(&self) -> String { self.scoped("") } fn edit_collection_path( &self, collection_id: Uuid, entry_id: Option, token: &ValidToken, ) -> String { if let Some(entry_id) = entry_id { self.scoped(&format!("{collection_id}?token={}#{entry_id}", token.token)) } else { self.scoped(&format!("{collection_id}?token={}", token.token)) } } fn update_collection_path(&self, collection_id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{collection_id}?token={}", token.token)) } fn delete_collection_path(&self, id: Uuid, token: &ValidToken, confirmed: bool) -> String { if confirmed { self.scoped(&format!("{id}/delete?token={}&confirmed=true", token.token)) } else { self.scoped(&format!("{id}/delete?token={}", 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!("{collection_id}/entry?token={}", token.token)) } fn update_entry_path(&self, collection_id: Uuid, id: Uuid, token: &ValidToken) -> String { self.scoped(&format!("{collection_id}/entry/{id}?token={}", token.token)) } fn move_entry_path( &self, collection_id: Uuid, id: Uuid, token: &ValidToken, direction: Direction, ) -> String { self.scoped(&format!( "{collection_id}/entry/{id}/move/{direction}?token={}", token.token )) } fn delete_entry_path( &self, collection_id: Uuid, id: Uuid, token: &ValidToken, confirmed: bool, ) -> String { if confirmed { self.scoped(&format!( "{collection_id}/entry/{id}/delete?token={}&confirmed=true", token.token )) } else { self.scoped(&format!( "{collection_id}/entry/{id}/delete?token={}", token.token )) } } fn statics_path(&self, file: &str) -> String { self.scoped(&format!("static/{file}")) } fn thumbnail_path(&self, filename: &str, size: u16, extension: pict::Extension) -> String { self.scoped(&format!( "image/thumbnail.{extension}?src={filename}&size={size}", )) } fn srcset(&self, filename: &str, extension: pict::Extension) -> String { let mut sizes = Vec::new(); for size in connection::VALID_SIZES { sizes.push(format!( "{} {size}w", self.thumbnail_path(filename, *size, extension), )) } sizes.join(", ") } fn image_path(&self, filename: &str) -> String { self.scoped(&format!("image/full/{filename}")) } } pub fn state(config: Config, scope: &str, db: Db) -> Result { Ok(State { upstream: config.upstream, scope: scope.to_string(), store: Store::new(&db)?, startup: SystemTime::now(), }) } pub fn configure(cfg: &mut web::ServiceConfig, state: State, client: Client) { cfg.app_data(web::Data::new(Connection::new( state.upstream.clone(), client, ))) .app_data(web::Data::new(state)) .route("/healthz", web::get().to(healthz)) .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("/move/{direction}").route(web::get().to(move_entry)), ) .service(web::resource("/delete").route(web::get().to(delete_entry))), ), ), ); } fn to_edit_page( collection_id: Uuid, entry_id: Option, token: &ValidToken, state: &State, ) -> HttpResponse { HttpResponse::SeeOther() .insert_header(( LOCATION, state.edit_collection_path(collection_id, entry_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 healthz(state: web::Data) -> Result { state.store.check_health().await.stateful(&state)?; Ok(HttpResponse::Ok().finish()) } #[tracing::instrument(name = "Static files")] 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) } #[tracing::instrument(name = "Not found")] async fn not_found(state: web::Data) -> Result { rendered( |cursor| self::templates::not_found_html(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_html(cursor, &self.error.kind.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.kind.to_string()), } } } #[derive(Debug)] struct Error { context: SpanTrace, kind: ErrorKind, } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "{}", self.kind)?; std::fmt::Display::fmt(&self.context, f) } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.kind.source() } } impl From for Error where ErrorKind: From, { fn from(error: T) -> Self { Error { context: SpanTrace::capture(), kind: error.into(), } } } #[derive(Debug, thiserror::Error)] enum ErrorKind { #[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), } #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct Collection { title: String, description: String, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(untagged)] pub enum EntryKind { Pending { upload_id: String, }, Ready { filename: String, delete_token: String, }, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] pub struct Entry { title: Optional, description: Optional, link: Optional, #[serde(flatten)] file_info: EntryKind, } #[derive(Clone, Debug, 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(Debug, 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(Debug, serde::Deserialize)] struct CollectionPath { collection: Uuid, } impl CollectionPath { fn key(&self) -> String { format!("{}", self.collection) } fn order_key(&self) -> String { format!("{}/order", self.collection) } fn entry_range(&self) -> std::ops::RangeInclusive> { 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(Clone, Debug, serde::Deserialize)] struct EntryPath { collection: Uuid, entry: Uuid, } #[derive(Debug, serde::Deserialize)] struct MoveEntryPath { collection: Uuid, entry: Uuid, direction: Direction, } impl Entry { pub(crate) fn filename(&self) -> Option<&str> { if let EntryKind::Ready { filename, .. } = &self.file_info { Some(&filename) } else { None } } pub(crate) fn file_parts(&self) -> Option<(&str, &str)> { if let EntryKind::Ready { filename, delete_token, } = &self.file_info { Some((&filename, &delete_token)) } else { None } } pub(crate) fn upload_id(&self) -> Option<&str> { if let EntryKind::Pending { upload_id } = &self.file_info { Some(&upload_id) } else { None } } } impl EntryPath { fn key(&self) -> String { format!("{}/entry/{}", self.collection, self.entry) } } impl MoveEntryPath { fn order_key(&self) -> String { format!("{}/order", self.collection) } fn entry_range(&self) -> std::ops::RangeInclusive> { 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 } } #[tracing::instrument(name = "Upload image", skip(req, pl))] async fn upload( req: HttpRequest, pl: web::Payload, path: web::Path, token: ValidToken, conn: web::Data, state: web::Data, ) -> Result { let uploads = conn.upload(&req, pl).await.stateful(&state)?; if uploads.is_err() { return Err(ErrorKind::UploadString(uploads.message().to_owned())).stateful(&state); } let upload = uploads .files() .next() .ok_or_else(|| ErrorKind::UploadString("Missing file".to_owned())) .stateful(&state)?; let entry = Entry { title: None.into(), description: None.into(), link: None.into(), file_info: EntryKind::Pending { upload_id: upload.id().to_owned(), }, }; let entry_path = EntryPath { collection: path.collection, entry: Uuid::new_v4(), }; let entry_path2 = entry_path.clone(); let state2 = state.clone(); let upload = upload.clone(); actix_rt::spawn(async move { match conn.claim(upload).await { Ok(images) => { if let Some(image) = images.files().next() { let res = store::GetEntry { entry_path: &entry_path2, } .exec(&state2.store) .await; if let Ok(mut entry) = res { entry.file_info = EntryKind::Ready { filename: image.file().to_owned(), delete_token: image.delete_token().to_owned(), }; let _ = store::UpdateEntry { entry_path: &entry_path2, entry: &entry, } .exec(&state2.store) .await; } } } Err(e) => { tracing::warn!("{e}"); let _ = store::DeleteEntry { entry_path: &entry_path2, } .exec(&state2.store) .await; } } }); store::CreateEntry { entry_path: &entry_path, entry: &entry, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page( path.collection, Some(entry_path.entry), &token, &state, )) } #[derive(Debug, serde::Deserialize)] struct ImagePath { filename: String, } #[tracing::instrument(name = "Serve image", skip(req))] async fn image( req: HttpRequest, path: web::Path, conn: web::Data, ) -> Result { conn.image(&path.filename, &req).await } #[derive(Debug, serde::Deserialize)] struct ThumbnailPath { extension: pict::Extension, } #[derive(Debug, serde::Deserialize)] struct ThumbnailQuery { src: String, size: u16, } #[tracing::instrument(name = "Serve thumbnail", skip(req))] 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 } #[tracing::instrument(name = "Index")] async fn index(state: web::Data) -> Result { rendered( |cursor| self::templates::index_html(cursor, &state), HttpResponse::Ok(), ) .stateful(&state) } #[tracing::instrument(name = "Collection", skip(req))] async fn collection( path: web::Path, token: Option, state: web::Data, req: HttpRequest, ) -> Result { match token { Some(token) => edit_collection(path, token, state.clone(), req) .await .stateful(&state), None => view_collection(path, state.clone()).await.stateful(&state), } } #[tracing::instrument(name = "View Collection")] 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.order_key(), path.entry_range()) .await?; rendered( |cursor| { self::templates::view_collection_html( cursor, path.collection, &collection, &entries, &state, ) }, HttpResponse::Ok(), ) } #[tracing::instrument(name = "Edit Collection", skip(req))] async fn edit_collection( path: web::Path, token: ValidToken, state: web::Data, req: HttpRequest, ) -> Result { let qr = qr(&req, &path, &state); let collection = match state.store.collection(&path).await? { Some(collection) => collection, None => return Ok(to_404(&state)), }; let entries = state .store .entries(path.order_key(), path.entry_range()) .await?; rendered( |cursor| { self::templates::edit_collection_html( cursor, &collection, path.collection, &entries, &token, &state, &qr, ) }, HttpResponse::Ok(), ) } #[tracing::instrument(name = "Create Collection")] 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, None, &ValidToken { token: token.token }, &state, )) } #[tracing::instrument(name = "Update Collection")] 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, None, &token, &state)) } async fn move_entry( path: web::Path, token: ValidToken, state: web::Data, ) -> Result { store::MoveEntry { move_entry_path: &path, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page( path.collection, Some(path.entry), &token, &state, )) } #[tracing::instrument(name = "Update Entry")] 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, Some(entry_path.entry), &token, &state, )) } #[derive(Debug, serde::Deserialize)] struct ConfirmQuery { confirmed: Option, } #[tracing::instrument(name = "Delete Entry")] 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_html( cursor, entry_path.collection, entry_path.entry, &entry, &token, &state, ) }, HttpResponse::Ok(), ) .stateful(&state); } if let EntryKind::Ready { filename, delete_token, } = &entry.file_info { conn.delete(filename, delete_token).await.stateful(&state)?; } store::DeleteEntry { entry_path: &entry_path, } .exec(&state.store) .await .stateful(&state)?; Ok(to_edit_page(entry_path.collection, None, &token, &state)) } fn qr(req: &HttpRequest, path: &web::Path, state: &web::Data) -> String { let host = req.head().headers().get("host").unwrap(); let url = format!( "https://{}{}", host.to_str().unwrap(), state.public_collection_path(path.collection) ); let code = qrcodegen::QrCode::encode_text(&url, qrcodegen::QrCodeEcc::Low).unwrap(); to_svg_string(&code, 4) } fn to_svg_string(qr: &qrcodegen::QrCode, border: i32) -> String { assert!(border >= 0, "Border must be non-negative"); let mut result = String::new(); // result += "\n"; // result += "\n"; let dimension = qr .size() .checked_add(border.checked_mul(2).unwrap()) .unwrap(); result += &format!( "\n"); result += "\t\n"; result += "\t\n"; result += "\n"; result } #[tracing::instrument(name = "Delete Collection")] 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_html( cursor, path.collection, &collection, &token, &state, ) }, HttpResponse::Ok(), ) .stateful(&state); } let entries = state .store .entries(path.order_key(), path.entry_range()) .await .stateful(&state)?; let future_vec = entries .iter() .map(|(_, entry)| { let (tx, rx) = tokio::sync::oneshot::channel(); if let EntryKind::Ready { filename, delete_token, } = entry.file_info.clone() { let conn = conn.clone(); actix_rt::spawn(async move { let _ = tx.send(conn.delete(&filename, &delete_token).await); }); } rx }) .collect::>(); let mut results = Vec::new(); for rx in future_vec { results.push( rx.await .map_err(|_| ErrorKind::UploadString("Canceled".to_string())) .stateful(&state)?, ); } // 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 html = cursor.into_inner(); let output = minify_html::minify(&html, &minify_html::Cfg::spec_compliant()); drop(html); Ok(builder .content_type(mime::TEXT_HTML.essence_str()) .body(output)) }