583 lines
15 KiB
Rust
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))
|
|
}
|