pict-rs-aggregator/src/lib.rs
2020-12-08 16:03:18 -06:00

583 lines
15 KiB
Rust

use actix_web::{
client::Client,
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,
}
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_aggregation_path(&self) -> String {
self.scoped("")
}
fn edit_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}?token={}", id, token.token))
}
fn update_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}?token={}", id, token.token))
}
fn delete_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}/delete?token={}", id, token.token))
}
fn public_aggregation_path(&self, id: Uuid) -> String {
self.scoped(&format!("{}", id))
}
fn create_entry_path(&self, aggregation_id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}/entry?token={}", aggregation_id, token.token))
}
fn update_entry_path(&self, aggregation_id: Uuid, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!(
"{}/entry/{}?token={}",
aggregation_id, id, token.token
))
}
fn delete_entry_path(&self, aggregation_id: Uuid, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!(
"{}/entry/{}/delete?token={}",
aggregation_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,
})
}
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_aggregation)),
)
.service(
web::scope("/{aggregation}")
.wrap(middleware::Verify)
.service(
web::resource("")
.route(web::get().to(aggregation))
.route(web::post().to(update_aggregation)),
)
.service(web::resource("/delete").route(web::get().to(delete_aggregation)))
.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()
.header(LOCATION, state.edit_aggregation_path(id, token))
.finish()
}
fn to_404(state: &State) -> HttpResponse {
HttpResponse::MovedPermanently()
.header(LOCATION, state.create_aggregation_path())
.finish()
}
fn to_home(state: &State) -> HttpResponse {
HttpResponse::SeeOther()
.header(LOCATION, state.create_aggregation_path())
.finish()
}
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()
.set(LastModified(SystemTime::now().into()))
.set(CacheControl(vec![
CacheDirective::Public,
CacheDirective::MaxAge(365 * DAYS),
CacheDirective::Extension("immutable".to_owned(), None),
]))
.set(ContentType(data.mime.clone()))
.body(data.content);
}
to_404(&state)
}
async fn not_found(state: web::Data<State>) -> Result<HttpResponse, Error> {
let mut cursor = Cursor::new(vec![]);
self::templates::not_found(&mut cursor, &state)?;
Ok(HttpResponse::NotFound()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
#[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),
}
impl ResponseError for Error {
fn status_code(&self) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
fn error_response(&self) -> HttpResponse {
match self {
Self::Store(self::store::Error::NotFound) => HttpResponse::MovedPermanently()
.header(LOCATION, "/404")
.finish(),
_ => HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(format!("{}", self)),
}
}
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Aggregation {
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 AggregationPath {
aggregation: Uuid,
}
impl AggregationPath {
fn key(&self) -> String {
format!("{}", self.aggregation)
}
fn entry_range(&self) -> std::ops::Range<Vec<u8>> {
let base = format!("{}/entry/", self.aggregation).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.aggregation)
}
}
#[derive(serde::Deserialize)]
struct EntryPath {
aggregation: Uuid,
entry: Uuid,
}
impl EntryPath {
fn key(&self) -> String {
format!("{}/entry/{}", self.aggregation, self.entry)
}
}
async fn upload(
req: HttpRequest,
pl: web::Payload,
path: web::Path<AggregationPath>,
token: ValidToken,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let images = conn.upload(&req, pl).await?;
if images.is_err() {
return Err(Error::UploadString(images.message().to_owned()));
}
let image = images
.files()
.next()
.ok_or(Error::UploadString("Missing file".to_owned()))?;
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 {
aggregation: path.aggregation,
entry: Uuid::new_v4(),
};
store::CreateEntry {
entry_path: &entry_path,
entry: &entry,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(path.aggregation, &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, Error> {
let mut cursor = Cursor::new(vec![]);
self::templates::index(&mut cursor, &state)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn aggregation(
path: web::Path<AggregationPath>,
token: Option<ValidToken>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
match token {
Some(token) => edit_aggregation(path, token, state).await,
None => view_aggregation(path, state).await,
}
}
async fn view_aggregation(
path: web::Path<AggregationPath>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let aggregation = state.store.aggregation(&path).await?;
let entries = state.store.entries(path.entry_range()).await?;
let mut cursor = Cursor::new(vec![]);
self::templates::view_aggregation(&mut cursor, &aggregation, &entries, &state)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn edit_aggregation(
path: web::Path<AggregationPath>,
token: ValidToken,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let aggregation = state.store.aggregation(&path).await?;
let entries = state.store.entries(path.entry_range()).await?;
let mut cursor = Cursor::new(vec![]);
self::templates::edit_aggregation(
&mut cursor,
&aggregation,
path.aggregation,
&entries,
&token,
&state,
)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn create_aggregation(
aggregation: web::Form<Aggregation>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let aggregation_id = Uuid::new_v4();
let aggregation_path = AggregationPath {
aggregation: aggregation_id,
};
let token = Token {
token: Uuid::new_v4(),
};
store::CreateAggregation {
aggregation_path: &aggregation_path,
aggregation: &aggregation,
token: &token,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(
aggregation_path.aggregation,
&ValidToken { token: token.token },
&state,
))
}
async fn update_aggregation(
path: web::Path<AggregationPath>,
form: web::Form<Aggregation>,
token: ValidToken,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
store::UpdateAggregation {
aggregation_path: &path,
aggregation: &form,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(path.aggregation, &token, &state))
}
async fn update_entry(
entry_path: web::Path<EntryPath>,
entry: web::Form<Entry>,
token: ValidToken,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
store::UpdateEntry {
entry_path: &entry_path,
entry: &entry,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(entry_path.aggregation, &token, &state))
}
async fn delete_entry(
entry_path: web::Path<EntryPath>,
token: ValidToken,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let entry = state.store.entry(&entry_path).await?;
conn.delete(&entry.filename, &entry.delete_token).await?;
store::DeleteEntry {
entry_path: &entry_path,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(entry_path.aggregation, &token, &state))
}
async fn delete_aggregation(
path: web::Path<AggregationPath>,
_token: ValidToken,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let entries = state.store.entries(path.entry_range()).await?;
let future_vec = entries
.iter()
.map(|(_, entry)| conn.delete(&entry.filename, &entry.delete_token))
.collect::<Vec<_>>();
futures::future::try_join_all(future_vec).await?;
store::DeleteAggregation {
aggregation_path: &path,
}
.exec(&state.store)
.await?;
Ok(to_home(&state))
}