pict-rs-aggregator/src/lib.rs

747 lines
19 KiB
Rust

use actix_web::{
client::Client,
dev::HttpResponseBuilder,
http::{
header::{CacheControl, CacheDirective, ContentType, LastModified, LOCATION},
StatusCode,
},
web, HttpRequest, HttpResponse, ResponseError, Scope,
};
use sled::Db;
use std::{io::Cursor, net::SocketAddr, time::SystemTime};
use structopt::StructOpt;
use url::Url;
use uuid::Uuid;
include!(concat!(env!("OUT_DIR"), "/templates.rs"));
const HOURS: u32 = 60 * 60;
const DAYS: u32 = 24 * HOURS;
mod connection;
mod middleware;
mod pict;
mod store;
mod ui;
use self::{connection::Connection, middleware::ValidToken, store::Store};
#[derive(Clone, Debug, StructOpt)]
pub struct Config {
#[structopt(
short,
long,
env = "PICTRS_AGGREGATOR_ADDR",
default_value = "0.0.0.0:8082",
help = "The address and port the server binds to"
)]
addr: SocketAddr,
#[structopt(
short,
long,
env = "PICTRS_AGGREGATOR_UPSTREAM",
default_value = "http://localhost:8080",
help = "The url of the upstream pict-rs server"
)]
upstream: Url,
}
pub fn accept() -> &'static str {
"image/png,image/jpeg,image/webp,.jpg,.jpeg,.png,.webp"
}
impl Config {
pub fn bind_address(&self) -> SocketAddr {
self.addr
}
}
#[derive(Clone)]
pub struct State {
upstream: Url,
scope: String,
store: Store,
db: Db,
startup: SystemTime,
}
impl State {
fn scoped(&self, s: &str) -> String {
if self.scope == "" && s == "" {
"/".to_string()
} else if s == "" {
self.scope.clone()
} else if self.scope == "" {
format!("/{}", s)
} else {
format!("{}/{}", self.scope, s)
}
}
fn create_collection_path(&self) -> String {
self.scoped("")
}
fn edit_collection_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}?token={}", id, token.token))
}
fn update_collection_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}?token={}", id, token.token))
}
fn delete_collection_path(&self, id: Uuid, token: &ValidToken, confirmed: bool) -> String {
if confirmed {
self.scoped(&format!(
"{}/delete?token={}&confirmed=true",
id, token.token
))
} else {
self.scoped(&format!("{}/delete?token={}", id, 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!("{}/entry?token={}", collection_id, token.token))
}
fn update_entry_path(&self, collection_id: Uuid, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!(
"{}/entry/{}?token={}",
collection_id, id, token.token
))
}
fn delete_entry_path(
&self,
collection_id: Uuid,
id: Uuid,
token: &ValidToken,
confirmed: bool,
) -> String {
if confirmed {
self.scoped(&format!(
"{}/entry/{}/delete?token={}&confirmed=true",
collection_id, id, token.token
))
} else {
self.scoped(&format!(
"{}/entry/{}/delete?token={}",
collection_id, id, token.token
))
}
}
fn statics_path(&self, file: &str) -> String {
self.scoped(&format!("static/{}", file))
}
fn thumbnail_path(&self, entry: &Entry, size: u16, extension: pict::Extension) -> String {
self.scoped(&format!(
"image/thumbnail.{}?src={}&size={}",
extension, entry.filename, size
))
}
fn srcset(&self, entry: &Entry, extension: pict::Extension) -> String {
connection::VALID_SIZES
.iter()
.map(|size| format!("{} {}w", self.thumbnail_path(entry, *size, extension), size,))
.collect::<Vec<String>>()
.join(", ")
}
fn image_path(&self, entry: &Entry) -> String {
self.scoped(&format!("image/full/{}", entry.filename))
}
}
pub fn state(config: Config, scope: &str, db: Db) -> Result<State, sled::Error> {
Ok(State {
upstream: config.upstream,
scope: scope.to_string(),
store: Store::new(&db)?,
db,
startup: SystemTime::now(),
})
}
pub fn service(client: Client, state: State) -> Scope {
web::scope(&state.scoped(""))
.data(Connection::new(state.upstream.clone(), client))
.data(state)
.service(web::resource("/static/{filename}").route(web::get().to(static_files)))
.service(web::resource("404").route(web::get().to(not_found)))
.service(
web::scope("image")
.service(web::resource("/thumbnail.{extension}").route(web::get().to(thumbnail)))
.service(web::resource("/full/{filename}").route(web::get().to(image))),
)
.service(
web::resource("")
.route(web::get().to(index))
.route(web::post().to(create_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("/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 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)
}
async fn not_found(state: web::Data<State>) -> Result<HttpResponse, StateError> {
rendered(
|cursor| self::templates::not_found(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(cursor, &self.error.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.to_string()),
}
}
}
#[derive(Debug, thiserror::Error)]
enum Error {
#[error("{0}")]
Render(#[from] std::io::Error),
#[error("{0}")]
Store(#[from] self::store::Error),
#[error("{0}")]
Upload(#[from] self::connection::UploadError),
#[error("{0}")]
UploadString(String),
#[error("Failed to minify html, {0}")]
Minify(String),
}
impl From<minify_html::Error> for Error {
fn from(e: minify_html::Error) -> Self {
Error::Minify(format!("{:?}", e))
}
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Collection {
title: String,
description: String,
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Entry {
title: String,
description: String,
filename: String,
delete_token: String,
}
#[derive(Clone, serde::Deserialize)]
pub struct Token {
token: Uuid,
}
impl Token {
fn hash(&self) -> Result<TokenStorage, bcrypt::BcryptError> {
use bcrypt::{hash, DEFAULT_COST};
Ok(TokenStorage {
token: hash(self.token.as_bytes(), DEFAULT_COST)?,
})
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct TokenStorage {
token: String,
}
impl TokenStorage {
fn verify(&self, token: &Token) -> Result<bool, bcrypt::BcryptError> {
bcrypt::verify(&token.token.as_bytes(), &self.token)
}
}
#[derive(serde::Deserialize)]
struct CollectionPath {
collection: Uuid,
}
impl CollectionPath {
fn key(&self) -> String {
format!("{}", self.collection)
}
fn entry_range(&self) -> std::ops::Range<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(serde::Deserialize)]
struct EntryPath {
collection: Uuid,
entry: Uuid,
}
impl EntryPath {
fn key(&self) -> String {
format!("{}/entry/{}", self.collection, self.entry)
}
}
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 images = conn.upload(&req, pl).await.stateful(&state)?;
if images.is_err() {
return Err(Error::UploadString(images.message().to_owned())).stateful(&state);
}
let image = images
.files()
.next()
.ok_or(Error::UploadString("Missing file".to_owned()))
.stateful(&state)?;
let entry = Entry {
title: String::new(),
description: String::new(),
filename: image.file().to_owned(),
delete_token: image.delete_token().to_owned(),
};
let entry_path = EntryPath {
collection: path.collection,
entry: Uuid::new_v4(),
};
store::CreateEntry {
entry_path: &entry_path,
entry: &entry,
}
.exec(&state.store)
.await
.stateful(&state)?;
Ok(to_edit_page(path.collection, &token, &state))
}
#[derive(serde::Deserialize)]
struct ImagePath {
filename: String,
}
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(serde::Deserialize)]
struct ThumbnailPath {
extension: pict::Extension,
}
#[derive(serde::Deserialize)]
struct ThumbnailQuery {
src: String,
size: u16,
}
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
}
async fn index(state: web::Data<State>) -> Result<HttpResponse, StateError> {
rendered(
|cursor| self::templates::index(cursor, &state),
HttpResponse::Ok(),
)
.stateful(&state)
}
async fn collection(
path: web::Path<CollectionPath>,
token: Option<ValidToken>,
state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
match token {
Some(token) => edit_collection(path, token, state.clone())
.await
.stateful(&state),
None => view_collection(path, state.clone()).await.stateful(&state),
}
}
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.entry_range()).await?;
rendered(
|cursor| {
self::templates::view_collection(cursor, path.collection, &collection, &entries, &state)
},
HttpResponse::Ok(),
)
}
async fn edit_collection(
path: web::Path<CollectionPath>,
token: ValidToken,
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.entry_range()).await?;
rendered(
|cursor| {
self::templates::edit_collection(
cursor,
&collection,
path.collection,
&entries,
&token,
&state,
)
},
HttpResponse::Ok(),
)
}
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,
))
}
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 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(serde::Deserialize)]
struct ConfirmQuery {
confirmed: Option<bool>,
}
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(
cursor,
entry_path.collection,
entry_path.entry,
&entry,
&token,
&state,
)
},
HttpResponse::Ok(),
)
.stateful(&state);
}
conn.delete(&entry.filename, &entry.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))
}
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(
cursor,
path.collection,
&collection,
&token,
&state,
)
},
HttpResponse::Ok(),
)
.stateful(&state);
}
let entries = state
.store
.entries(path.entry_range())
.await
.stateful(&state)?;
let future_vec = entries
.iter()
.map(|(_, entry)| conn.delete(&entry.filename, &entry.delete_token))
.collect::<Vec<_>>();
let results = futures::future::join_all(future_vec).await;
// 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 mut html = cursor.into_inner();
let len = minify_html::in_place(
&mut html,
&minify_html::Cfg {
minify_js: true,
minify_css: true,
},
)?;
html.truncate(len);
Ok(builder
.content_type(mime::TEXT_HTML.essence_str())
.body(html))
}