Aode (lion)
e2495c3c02
All checks were successful
continuous-integration/drone/push Build is passing
1088 lines
28 KiB
Rust
1088 lines
28 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 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 pict;
|
|
mod store;
|
|
mod ui;
|
|
|
|
use self::{connection::Connection, middleware::ValidToken, store::Store};
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
pub struct Config {
|
|
#[clap(
|
|
short,
|
|
long,
|
|
env = "PICTRS_AGGREGATOR_ADDR",
|
|
default_value = "0.0.0.0:8082",
|
|
help = "The address and port the server binds to"
|
|
)]
|
|
addr: SocketAddr,
|
|
|
|
#[clap(
|
|
short,
|
|
long,
|
|
env = "PICTRS_AGGREGATOR_UPSTREAM",
|
|
default_value = "http://localhost:8080",
|
|
help = "The url of the upstream pict-rs server"
|
|
)]
|
|
upstream: Url,
|
|
|
|
#[clap(
|
|
short,
|
|
long,
|
|
env = "PICTRS_AGGREGATOR_DATABASE",
|
|
default_value = "sled/db-0-34",
|
|
help = "The path to the database"
|
|
)]
|
|
database_path: PathBuf,
|
|
|
|
#[clap(
|
|
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,
|
|
|
|
#[clap(
|
|
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>,
|
|
|
|
#[clap(
|
|
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, 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!("{}/{}", 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 move_entry_path(
|
|
&self,
|
|
collection_id: Uuid,
|
|
id: Uuid,
|
|
token: &ValidToken,
|
|
direction: Direction,
|
|
) -> String {
|
|
self.scoped(&format!(
|
|
"{}/entry/{}/move/{}?token={}",
|
|
collection_id, id, direction, 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, filename: &str, size: u16, extension: pict::Extension) -> String {
|
|
self.scoped(&format!(
|
|
"image/thumbnail.{}?src={}&size={}",
|
|
extension, filename, size
|
|
))
|
|
}
|
|
|
|
fn srcset(&self, filename: &str, extension: pict::Extension) -> String {
|
|
let mut sizes = Vec::new();
|
|
|
|
for size in connection::VALID_SIZES {
|
|
sizes.push(format!(
|
|
"{} {}w",
|
|
self.thumbnail_path(filename, *size, extension),
|
|
size,
|
|
))
|
|
}
|
|
|
|
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))
|
|
.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,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[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(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.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: String,
|
|
description: String,
|
|
|
|
#[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: String::new(),
|
|
description: String::new(),
|
|
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(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(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(
|
|
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(
|
|
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 {0} {0}\" stroke=\"none\">\n", dimension);
|
|
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(
|
|
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))
|
|
}
|