pict-rs-aggregator 0.2.0-beta.2

A simple image aggregation service for pict-rs
Documentation
// 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<usize>,

    #[arg(
        short,
        long,
        env = "PICTRS_AGGREGATOR_OPENTELEMETRY_URL",
        help = "URL for the OpenTelemetry Colletor"
    )]
    opentelemetry_url: Option<Url>,
}

pub fn accept() -> &'static str {
    "image/png,image/jpeg,image/webp,.jpg,.jpeg,.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<usize> {
        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, id: Uuid, token: &ValidToken) -> String {
        self.scoped(&format!("{id}?token={}", token.token))
    }

    fn update_collection_path(&self, id: Uuid, token: &ValidToken) -> String {
        self.scoped(&format!("{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<State, sled::Error> {
    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(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<T> {
    fn stateful(self, state: &web::Data<State>) -> Result<T, StateError>;
}

impl<T, E> ResultExt<T> for Result<T, E>
where
    Error: From<E>,
{
    fn stateful(self, state: &web::Data<State>) -> Result<T, StateError> {
        self.map_err(Error::from).map_err(|error| StateError {
            state: state.clone(),
            error,
        })
    }
}

async fn healthz(state: web::Data<State>) -> Result<HttpResponse, StateError> {
    state.store.check_health().await.stateful(&state)?;
    Ok(HttpResponse::Ok().finish())
}

#[tracing::instrument(name = "Static files")]
async fn static_files(filename: web::Path<String>, state: web::Data<State>) -> 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<State>) -> Result<HttpResponse, StateError> {
    rendered(
        |cursor| self::templates::not_found_html(cursor, &state),
        HttpResponse::NotFound(),
    )
    .stateful(&state)
}

struct StateError {
    state: web::Data<State>,
    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<T> From<T> for Error
where
    ErrorKind: From<T>,
{
    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<String>,
    description: Optional<String>,
    link: Optional<Url>,

    #[serde(flatten)]
    file_info: EntryKind,
}

#[derive(Clone, Debug, serde::Deserialize)]
pub struct Token {
    token: Uuid,
}

impl Token {
    fn hash(&self) -> Result<TokenStorage, bcrypt::BcryptError> {
        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<bool, bcrypt::BcryptError> {
        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<Vec<u8>> {
        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<Vec<u8>> {
        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<CollectionPath>,
    token: ValidToken,
    conn: web::Data<Connection>,
    state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
    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, &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<ImagePath>,
    conn: web::Data<Connection>,
) -> Result<HttpResponse, actix_web::Error> {
    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<ThumbnailPath>,
    query: web::Query<ThumbnailQuery>,
    conn: web::Data<Connection>,
) -> Result<HttpResponse, actix_web::Error> {
    conn.thumbnail(query.size, &query.src, path.extension, &req)
        .await
}

#[tracing::instrument(name = "Index")]
async fn index(state: web::Data<State>) -> Result<HttpResponse, StateError> {
    rendered(
        |cursor| self::templates::index_html(cursor, &state),
        HttpResponse::Ok(),
    )
    .stateful(&state)
}

#[tracing::instrument(name = "Collection", skip(req))]
async fn collection(
    path: web::Path<CollectionPath>,
    token: Option<ValidToken>,
    state: web::Data<State>,
    req: HttpRequest,
) -> Result<HttpResponse, StateError> {
    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<CollectionPath>,
    state: web::Data<State>,
) -> Result<HttpResponse, Error> {
    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")]
async fn edit_collection(
    path: web::Path<CollectionPath>,
    token: ValidToken,
    state: web::Data<State>,
    req: HttpRequest,
) -> Result<HttpResponse, Error> {
    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<Collection>,
    state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
    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,
    ))
}

#[tracing::instrument(name = "Update Collection")]
async fn update_collection(
    path: web::Path<CollectionPath>,
    form: web::Form<Collection>,
    token: ValidToken,
    state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
    store::UpdateCollection {
        collection_path: &path,
        collection: &form,
    }
    .exec(&state.store)
    .await
    .stateful(&state)?;

    Ok(to_edit_page(path.collection, &token, &state))
}

async fn move_entry(
    path: web::Path<MoveEntryPath>,
    token: ValidToken,
    state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
    store::MoveEntry {
        move_entry_path: &path,
    }
    .exec(&state.store)
    .await
    .stateful(&state)?;

    Ok(to_edit_page(path.collection, &token, &state))
}

#[tracing::instrument(name = "Update Entry")]
async fn update_entry(
    entry_path: web::Path<EntryPath>,
    entry: web::Form<Entry>,
    token: ValidToken,
    state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
    store::UpdateEntry {
        entry_path: &entry_path,
        entry: &entry,
    }
    .exec(&state.store)
    .await
    .stateful(&state)?;

    Ok(to_edit_page(entry_path.collection, &token, &state))
}

#[derive(Debug, serde::Deserialize)]
struct ConfirmQuery {
    confirmed: Option<bool>,
}

#[tracing::instrument(name = "Delete Entry")]
async fn delete_entry(
    entry_path: web::Path<EntryPath>,
    query: web::Query<ConfirmQuery>,
    token: ValidToken,
    conn: web::Data<Connection>,
    state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
    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, &token, &state))
}

fn qr(req: &HttpRequest, path: &web::Path<CollectionPath>, state: &web::Data<State>) -> 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 += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    // result += "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n";
    let dimension = qr
        .size()
        .checked_add(border.checked_mul(2).unwrap())
        .unwrap();
    result += &format!(
		"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 {dimension} {dimension}\" stroke=\"none\">\n");
    result += "\t<rect width=\"100%\" height=\"100%\" fill=\"#FFFFFF\"/>\n";
    result += "\t<path d=\"";
    for y in 0..qr.size() {
        for x in 0..qr.size() {
            if qr.get_module(x, y) {
                if x != 0 || y != 0 {
                    result += " ";
                }
                result += &format!("M{},{}h1v1h-1z", x + border, y + border);
            }
        }
    }
    result += "\" fill=\"#000000\"/>\n";
    result += "</svg>\n";
    result
}

#[tracing::instrument(name = "Delete Collection")]
async fn delete_collection(
    path: web::Path<CollectionPath>,
    query: web::Query<ConfirmQuery>,
    token: ValidToken,
    conn: web::Data<Connection>,
    state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
    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::<Vec<_>>();

    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<Vec<u8>>) -> std::io::Result<()>,
    mut builder: HttpResponseBuilder,
) -> Result<HttpResponse, Error> {
    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))
}