pict-rs-aggregator/src/lib.rs
asonix 4a9e1b2bc4
All checks were successful
continuous-integration/drone/push Build is passing
Enable serving over TLS, communicating to pict-rs over TLS
2024-02-01 18:14:29 -06:00

1379 lines
36 KiB
Rust

// 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<SocketAddr>,
#[arg(
long,
env = "PICTRS_AGGREGATOR_CONSOLE_EVENT_BUFFER_SIZE",
help = "The number of events to buffer in console"
)]
console_event_buffer_size: Option<usize>,
#[arg(
short,
long,
env = "PICTRS_AGGREGATOR_OPENTELEMETRY_URL",
help = "URL for the OpenTelemetry Colletor"
)]
opentelemetry_url: Option<Url>,
#[arg(
long,
env = "PICTRS_AGGREGATOR_CERTIFICATE",
help = "The CA Certificate used to verify pict-rs' TLS certificate"
)]
certificate: Option<PathBuf>,
#[arg(
long,
env = "PICTRS_AGGREGATOR_SERVER_CERTIFICATE",
help = "The Certificate used to serve pict-rs-aggregator over TLS"
)]
server_certificate: Option<PathBuf>,
#[arg(
long,
env = "PICTRS_AGGREGATOR_SERVER_PRIVATE_KEY",
help = "The Private Key used to serve pict-rs-aggregator over TLS"
)]
server_private_key: Option<PathBuf>,
}
pub struct Tls {
certificate: PathBuf,
private_key: PathBuf,
}
impl Tls {
pub fn from_config(config: &Config) -> Option<Self> {
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<CertifiedKey> {
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::<Result<Vec<_>, _>>()?;
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<usize> {
self.console_event_buffer_size
}
pub fn console_address(&self) -> Option<SocketAddr> {
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<ClientConfig> {
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<Uuid>,
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<State, sled::Error> {
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<Uuid>,
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<T> {
fn stateful(self, state: &web::Data<State>) -> Result<T, StateError>;
}
impl<T, E> ResultExt<T> for Result<T, E>
where
Error: From<E>,
{
#[track_caller]
fn stateful(self, state: &web::Data<State>) -> Result<T, StateError> {
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<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", skip_all)]
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>,
debug: Arc<str>,
display: Arc<str>,
}
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<T> From<T> for Error
where
ErrorKind: From<T>,
{
#[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<Dimensions>,
},
}
#[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<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 dimensions(&self) -> Option<Dimensions> {
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<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, token, conn, state))]
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(),
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<ImagePath>,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
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<ThumbnailPath>,
query: web::Query<ThumbnailQuery>,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
conn.thumbnail(query.size, &query.src, path.extension, &req)
.await
.stateful(&state)
}
#[tracing::instrument(name = "Index", skip_all)]
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, token, connection, state))]
async fn collection(
req: HttpRequest,
path: web::Path<CollectionPath>,
token: Option<ValidToken>,
connection: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
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<State>,
connection: web::Data<Connection>,
collection_id: Uuid,
entry_id: Uuid,
filename: String,
) -> Result<Entry, Error> {
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<State>,
connection: web::Data<Connection>,
collection_id: Uuid,
mut entries: Vec<(Uuid, Entry)>,
) -> Result<Vec<(Uuid, Entry)>, 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::<Vec<_>>();
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<CollectionPath>,
connection: web::Data<Connection>,
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?;
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<CollectionPath>,
token: ValidToken,
connection: web::Data<Connection>,
state: web::Data<State>,
) -> 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?;
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<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,
None,
&ValidToken { token: token.token },
&state,
))
}
#[tracing::instrument(name = "Update Collection", skip(token, state))]
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, None, &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,
Some(path.entry),
&token,
&state,
))
}
#[tracing::instrument(name = "Update Entry", skip(token, state))]
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,
Some(entry_path.entry),
&token,
&state,
))
}
#[derive(Debug, serde::Deserialize)]
struct ConfirmQuery {
confirmed: Option<bool>,
}
#[tracing::instrument(name = "Delete Entry", skip(token, conn, state))]
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, None, &token, &state))
}
fn qr(req: &HttpRequest, path: &web::Path<CollectionPath>, state: &web::Data<State>) -> 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 += "<?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", skip(token, conn, state))]
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::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<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))
}