// 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 rustls::{ sign::CertifiedKey, Certificate, ClientConfig, OwnedTrustAnchor, PrivateKey, RootCertStore, }; use sled::Db; use std::{ io::Cursor, net::SocketAddr, path::{Path, PathBuf}, sync::Arc, time::SystemTime, }; 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 = "[::]: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 = 'H', long, env = "PICTRS_AGGREGATOR_HOST", default_value = "localhost:8082", help = "The host at which pict-rs-aggregator is accessible" )] host: String, #[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_ADDRESS", help = "The address at which to bind the tokio-console exporter" )] console_address: Option, #[arg( long, env = "PICTRS_AGGREGATOR_CONSOLE_EVENT_BUFFER_SIZE", help = "The number of events to buffer in console" )] console_event_buffer_size: Option, #[arg( short, long, env = "PICTRS_AGGREGATOR_OPENTELEMETRY_URL", help = "URL for the OpenTelemetry Colletor" )] opentelemetry_url: Option, #[arg( long, env = "PICTRS_AGGREGATOR_CERTIFICATE", help = "The CA Certificate used to verify pict-rs' TLS certificate" )] certificate: Option, #[arg( long, env = "PICTRS_AGGREGATOR_SERVER_CERTIFICATE", help = "The Certificate used to serve pict-rs-aggregator over TLS" )] server_certificate: Option, #[arg( long, env = "PICTRS_AGGREGATOR_SERVER_PRIVATE_KEY", help = "The Private Key used to serve pict-rs-aggregator over TLS" )] server_private_key: Option, } pub struct Tls { certificate: PathBuf, private_key: PathBuf, } impl Tls { pub fn from_config(config: &Config) -> Option { config .server_certificate .as_ref() .zip(config.server_private_key.as_ref()) .map(|(cert, key)| Tls { certificate: cert.clone(), private_key: key.clone(), }) } pub async fn open_keys(&self) -> color_eyre::Result { let cert_bytes = tokio::fs::read(&self.certificate).await?; let key_bytes = tokio::fs::read(&self.private_key).await?; let certs = rustls_pemfile::certs(&mut cert_bytes.as_slice()) .map(|res| res.map(|c| Certificate(c.to_vec()))) .collect::, _>>()?; let key = rustls_pemfile::private_key(&mut key_bytes.as_slice())? .ok_or_else(|| color_eyre::eyre::eyre!("No key in keyfile"))?; let signing_key = rustls::sign::any_supported_type(&PrivateKey(Vec::from(key.secret_der())))?; Ok(CertifiedKey::new(certs, signing_key)) } } 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 console_address(&self) -> Option { self.console_address } pub fn bind_address(&self) -> SocketAddr { self.addr } pub fn opentelemetry_url(&self) -> Option<&Url> { self.opentelemetry_url.as_ref() } pub async fn build_rustls_client_config(&self) -> color_eyre::Result { let mut root_store = RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS .iter() .map(|root| { OwnedTrustAnchor::from_subject_spki_name_constraints( root.subject.to_vec(), root.subject_public_key_info.to_vec(), root.name_constraints.as_ref().map(|n| n.to_vec()), ) }) .collect(), }; if let Some(certificate) = &self.certificate { let bytes = tokio::fs::read(certificate).await?; for res in rustls_pemfile::certs(&mut bytes.as_slice()) { root_store.add(&Certificate(res?.to_vec()))?; } } Ok(ClientConfig::builder() .with_safe_defaults() .with_root_certificates(root_store) .with_no_client_auth()) } } #[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, host: String, 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, host: config.host, 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, { #[track_caller] fn stateful(self, state: &web::Data) -> Result { self.map_err(Error::from).map_err(|error| StateError { state: state.clone(), debug: Arc::from(format!("{error:?}")), display: Arc::from(format!("{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", skip_all)] 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, debug: Arc, display: Arc, } impl std::fmt::Debug for StateError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str(&self.debug) } } impl std::fmt::Display for StateError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str(&self.display) } } 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.display, &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.display.to_string()), } } } struct Error { context: color_eyre::Report, } impl std::fmt::Debug for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.context.fmt(f) } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.context.fmt(f) } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.context.source() } } impl From for Error where ErrorKind: From, { #[track_caller] fn from(error: T) -> Self { Error { context: color_eyre::Report::from(ErrorKind::from(error)), } } } #[derive(Debug, thiserror::Error)] enum ErrorKind { #[error("Error in IO")] Render(#[from] std::io::Error), #[error("Error in store")] Store(#[from] self::store::Error), #[error("Error in pict-rs connection")] Upload(#[from] self::connection::UploadError), #[error("{0}")] UploadString(String), #[error("Operation canceled")] Canceled, } #[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, dimensions: Option, }, } #[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] pub struct Dimensions { width: u32, height: u32, } #[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 dimensions(&self) -> Option { if let EntryKind::Ready { dimensions: Some(dimensions), .. } = &self.file_info { Some(*dimensions) } 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, token, conn, state))] 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(), dimensions: Some(Dimensions { width: image.width(), height: image.height(), }), }; 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, conn, state))] async fn image( req: HttpRequest, path: web::Path, conn: web::Data, state: web::Data, ) -> Result { conn.image(&path.filename, &req).await.stateful(&state) } #[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, conn, state))] async fn thumbnail( req: HttpRequest, path: web::Path, query: web::Query, conn: web::Data, state: web::Data, ) -> Result { conn.thumbnail(query.size, &query.src, path.extension, &req) .await .stateful(&state) } #[tracing::instrument(name = "Index", skip_all)] 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, token, connection, state))] async fn collection( req: HttpRequest, path: web::Path, token: Option, connection: web::Data, state: web::Data, ) -> Result { match token { Some(token) => edit_collection(req, path, token, connection, state.clone()) .await .stateful(&state), None => view_collection(path, connection, state.clone()) .await .stateful(&state), } } async fn save_dimensions( state: web::Data, connection: web::Data, collection_id: Uuid, entry_id: Uuid, filename: String, ) -> Result { let pict::Details { width, height } = connection.details(&filename).await?; let entry_path = EntryPath { collection: collection_id, entry: entry_id, }; let entry = store::GetEntry { entry_path: &entry_path, } .exec(&state.store) .await?; if entry.dimensions().is_none() { let entry = Entry { file_info: if let EntryKind::Ready { filename, delete_token, .. } = entry.file_info { EntryKind::Ready { filename, delete_token, dimensions: Some(Dimensions { width, height }), } } else { entry.file_info }, ..entry }; store::UpdateEntry { entry_path: &entry_path, entry: &entry, } .exec(&state.store) .await?; Ok(entry) } else { Ok(entry) } } async fn ensure_dimensions( state: web::Data, connection: web::Data, collection_id: Uuid, mut entries: Vec<(Uuid, Entry)>, ) -> Result, Error> { let updates = entries .iter() .map(|(entry_id, entry)| { if entry.dimensions().is_none() { if let Some(filename) = entry.filename() { let filename = filename.to_string(); let state = state.clone(); let connection = connection.clone(); Some(actix_rt::spawn(save_dimensions( state, connection, collection_id, *entry_id, filename, ))) } else { None } } else { None } }) .collect::>(); for ((_, entry), update) in entries.iter_mut().zip(updates) { if let Some(handle) = update { if let Ok(Ok(updated_entry)) = handle.await { *entry = updated_entry; } } } Ok(entries) } #[tracing::instrument(name = "View Collection", skip(connection, state))] async fn view_collection( path: web::Path, connection: web::Data, 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?; let entries = ensure_dimensions(state.clone(), connection, path.collection, entries).await?; rendered( |cursor| { self::templates::view_collection_html( cursor, path.collection, &collection, &entries, &state, ) }, HttpResponse::Ok(), ) } #[tracing::instrument(name = "Edit Collection", skip(req, connection, state))] async fn edit_collection( req: HttpRequest, path: web::Path, token: ValidToken, connection: web::Data, state: web::Data, ) -> 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?; let entries = ensure_dimensions(state.clone(), connection, path.collection, entries).await?; rendered( |cursor| { self::templates::edit_collection_html( cursor, &collection, path.collection, &entries, &token, &state, &qr, ) }, HttpResponse::Ok(), ) } #[tracing::instrument(name = "Create Collection", skip_all)] 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", skip(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, 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", skip(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, Some(entry_path.entry), &token, &state, )) } #[derive(Debug, serde::Deserialize)] struct ConfirmQuery { confirmed: Option, } #[tracing::instrument(name = "Delete Entry", skip(token, conn, state))] 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") .and_then(|h| h.to_str().ok()) .unwrap_or(&state.host); let url = format!( "https://{host}{}", 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", skip(token, conn, 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_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::Canceled).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)) }