Compare commits

...

4 commits

Author SHA1 Message Date
asonix caf5ca1dfd Update dependencies (minor & point)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-11-13 17:12:20 -06:00
asonix ec1d1d7ba4 bump version 2023-11-13 17:10:43 -06:00
asonix 53f95a6206 Improve error messages, don't log as much stuff 2023-11-13 17:07:05 -06:00
asonix 532c88da82 Backfill dimensions onto entry records to help browser layout 2023-11-13 15:38:00 -06:00
14 changed files with 363 additions and 151 deletions

58
Cargo.lock generated
View file

@ -575,6 +575,33 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
[[package]]
name = "color-eyre"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ba75b3d9449ecdccb27ecbc479fdc0b87fa2dd43d2f8298f9bf0e59aacc8dce"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.0" version = "1.0.0"
@ -754,6 +781,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "eyre"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2b6b5a29c02cdc822728b7d7b8ae1bab3e3b05d44522770ddd49722eeac7eb"
dependencies = [
"indenter",
"once_cell",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.0.1" version = "2.0.1"
@ -1043,6 +1080,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206ca75c9c03ba3d4ace2460e57b189f39f43de612c2f85836e65c929701bb2d" checksum = "206ca75c9c03ba3d4ace2460e57b189f39f43de612c2f85836e65c929701bb2d"
[[package]]
name = "indenter"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@ -1399,9 +1442,9 @@ dependencies = [
[[package]] [[package]]
name = "opentelemetry_sdk" name = "opentelemetry_sdk"
version = "0.21.0" version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5b3ce3f5705e2ae493be467a0b23be4bc563c193cdb7713e55372c89a906b34" checksum = "968ba3f2ca03e90e5187f5e4f46c791ef7f2c163ae87789c8ce5f5ca3b7b7de5"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"crossbeam-channel", "crossbeam-channel",
@ -1434,6 +1477,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "owo-colors"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.11.2" version = "0.11.2"
@ -1507,13 +1556,14 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]] [[package]]
name = "pict-rs-aggregator" name = "pict-rs-aggregator"
version = "0.5.0-beta.1" version = "0.5.0-beta.2"
dependencies = [ dependencies = [
"actix-rt", "actix-rt",
"actix-web", "actix-web",
"awc", "awc",
"bcrypt", "bcrypt",
"clap", "clap",
"color-eyre",
"console-subscriber", "console-subscriber",
"dotenv", "dotenv",
"mime", "mime",
@ -1633,7 +1683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "265baba7fabd416cf5078179f7d2cbeca4ce7a9041111900675ea7c4cb8a4c32" checksum = "265baba7fabd416cf5078179f7d2cbeca4ce7a9041111900675ea7c4cb8a4c32"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools 0.10.5", "itertools 0.11.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.39", "syn 2.0.39",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "pict-rs-aggregator" name = "pict-rs-aggregator"
description = "A simple image aggregation service for pict-rs" description = "A simple image aggregation service for pict-rs"
version = "0.5.0-beta.1" version = "0.5.0-beta.2"
authors = ["asonix <asonix@asonix.dog>"] authors = ["asonix <asonix@asonix.dog>"]
license = "AGPL-3.0" license = "AGPL-3.0"
readme = "README.md" readme = "README.md"
@ -44,6 +44,7 @@ tracing-subscriber = { version = "0.3", features = [
] } ] }
url = { version = "2.2", features = ["serde"] } url = { version = "2.2", features = ["serde"] }
uuid = { version = "1", features = ["serde", "v4"] } uuid = { version = "1", features = ["serde", "v4"] }
color-eyre = "0.6.2"
[dependencies.tracing-actix-web] [dependencies.tracing-actix-web]
@ -54,7 +55,7 @@ features = ["emit_event_on_error", "opentelemetry_0_21"]
[dependencies.tracing-awc] [dependencies.tracing-awc]
version = "0.1.8" version = "0.1.8"
default-features = false default-features = false
features = ["emit_event_on_error", "opentelemetry_0_21"] features = ["opentelemetry_0_21"]
[dev-dependencies] [dev-dependencies]
ructe = "0.17.0" ructe = "0.17.0"

View file

@ -6,13 +6,10 @@
rustPlatform.buildRustPackage { rustPlatform.buildRustPackage {
pname = "pict-rs-aggregator"; pname = "pict-rs-aggregator";
version = "0.5.0-alpha.1"; version = "0.5.0-beta.2";
src = ./.; src = ./.;
cargoLock.lockFile = ./Cargo.lock; cargoLock.lockFile = ./Cargo.lock;
PROTOC = "${protobuf}/bin/protoc";
PROTOC_INCLUDE = "${protobuf}/include";
nativeBuildInputs = [ ]; nativeBuildInputs = [ ];
passthru.tests = { inherit (nixosTests) pict-rs-aggregator; }; passthru.tests = { inherit (nixosTests) pict-rs-aggregator; };

View file

@ -219,6 +219,7 @@ ul {
img { img {
display: block; display: block;
width: 100%; width: 100%;
height: auto;
border-radius: 3px; border-radius: 3px;
} }
} }

View file

@ -1,9 +1,10 @@
use std::time::Duration; use std::{sync::Arc, time::Duration};
use crate::pict::{Extension, Images, Upload, Uploads}; use crate::{
use actix_web::{ pict::{Details, Extension, Images, Upload, Uploads},
body::BodyStream, http::StatusCode, web, HttpRequest, HttpResponse, ResponseError, Error,
}; };
use actix_web::{body::BodyStream, http::StatusCode, web, HttpRequest, HttpResponse};
use awc::Client; use awc::Client;
use url::Url; use url::Url;
@ -29,7 +30,7 @@ impl Connection {
} }
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub(crate) async fn claim(&self, upload: Upload) -> Result<Images, UploadError> { pub(crate) async fn claim(&self, upload: Upload) -> Result<Images, Error> {
let mut attempts = 0; let mut attempts = 0;
const CLAIM_ATTEMPT_LIMIT: usize = 10; const CLAIM_ATTEMPT_LIMIT: usize = 10;
loop { loop {
@ -37,21 +38,22 @@ impl Connection {
Ok(mut res) => { Ok(mut res) => {
match res.status() { match res.status() {
StatusCode::OK => { StatusCode::OK => {
return res.json::<Images>().await.map_err(|_| UploadError::Json); return res
} .json::<Images>()
StatusCode::UNPROCESSABLE_ENTITY => { .await
let images = .map_err(|_| UploadError::Json.into());
res.json::<Images>().await.map_err(|_| UploadError::Json)?;
tracing::warn!("{}", images.msg());
return Err(UploadError::Status);
} }
StatusCode::NO_CONTENT => { StatusCode::NO_CONTENT => {
// continue // continue
} }
_ => { _ => {
return Err(UploadError::Status); let images =
res.json::<Images>().await.map_err(|_| UploadError::Json)?;
let code = images.code().unwrap_or("uknown-error").to_string();
let msg = images.msg().to_string();
return Err(UploadError::UploadFailure(code, msg).into());
} }
} }
} }
@ -59,7 +61,7 @@ impl Connection {
attempts += 1; attempts += 1;
if attempts > CLAIM_ATTEMPT_LIMIT { if attempts > CLAIM_ATTEMPT_LIMIT {
return Err(UploadError::Status); return Err(UploadError::Status.into());
} }
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
@ -69,34 +71,49 @@ impl Connection {
} }
} }
#[tracing::instrument(skip_all)]
pub(crate) async fn thumbnail( pub(crate) async fn thumbnail(
&self, &self,
size: u16, size: u16,
file: &str, file: &str,
extension: Extension, extension: Extension,
req: &HttpRequest, req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, Error> {
if !VALID_SIZES.contains(&size) { if !VALID_SIZES.contains(&size) {
return Err(SizeError(size).into()); return Err(UploadError::Size(size).into());
} }
self.proxy(self.thumbnail_url(size, file, extension), req) self.proxy(self.thumbnail_url(size, file, extension), req)
.await .await
} }
pub(crate) async fn image( #[tracing::instrument(skip_all)]
&self, pub(crate) async fn image(&self, file: &str, req: &HttpRequest) -> Result<HttpResponse, Error> {
file: &str,
req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
self.proxy(self.image_url(file), req).await self.proxy(self.image_url(file), req).await
} }
#[tracing::instrument(skip_all)]
pub(crate) async fn details(&self, file: &str) -> Result<Details, Error> {
let mut response = self
.client
.get(self.details_url(file))
.send()
.await
.map_err(|e| UploadError::Request(Arc::from(e.to_string())))?;
if !response.status().is_success() {
return Err(UploadError::Status.into());
}
response.json().await.map_err(|_| UploadError::Json.into())
}
#[tracing::instrument(skip_all)]
pub(crate) async fn upload( pub(crate) async fn upload(
&self, &self,
req: &HttpRequest, req: &HttpRequest,
body: web::Payload, body: web::Payload,
) -> Result<Uploads, UploadError> { ) -> Result<Uploads, Error> {
let client_request = self.client.request_from(self.upload_url(), req.head()); let client_request = self.client.request_from(self.upload_url(), req.head());
let mut client_request = if let Some(addr) = req.head().peer_addr { let mut client_request = if let Some(addr) = req.head().peer_addr {
@ -110,23 +127,24 @@ impl Connection {
let mut res = client_request let mut res = client_request
.send_stream(body) .send_stream(body)
.await .await
.map_err(|_| UploadError::Request)?; .map_err(|e| UploadError::Request(Arc::from(e.to_string())))?;
let uploads = res.json::<Uploads>().await.map_err(|_| UploadError::Json)?; let uploads = res.json::<Uploads>().await.map_err(|_| UploadError::Json)?;
Ok(uploads) Ok(uploads)
} }
pub(crate) async fn delete(&self, file: &str, token: &str) -> Result<(), UploadError> { #[tracing::instrument(skip_all)]
pub(crate) async fn delete(&self, file: &str, token: &str) -> Result<(), Error> {
let res = self let res = self
.client .client
.delete(self.delete_url(file, token)) .delete(self.delete_url(file, token))
.send() .send()
.await .await
.map_err(|_| UploadError::Request)?; .map_err(|e| UploadError::Request(Arc::from(e.to_string())))?;
if !res.status().is_success() { if !res.status().is_success() {
return Err(UploadError::Status); return Err(UploadError::Status.into());
} }
Ok(()) Ok(())
@ -162,6 +180,13 @@ impl Connection {
url.to_string() url.to_string()
} }
fn details_url(&self, file: &str) -> String {
let mut url = self.upstream.clone();
url.set_path(&format!("/image/details/original/{file}"));
url.to_string()
}
fn delete_url(&self, file: &str, token: &str) -> String { fn delete_url(&self, file: &str, token: &str) -> String {
let mut url = self.upstream.clone(); let mut url = self.upstream.clone();
url.set_path(&format!("/image/delete/{token}/{file}")); url.set_path(&format!("/image/delete/{token}/{file}"));
@ -169,11 +194,8 @@ impl Connection {
url.to_string() url.to_string()
} }
async fn proxy( #[tracing::instrument(skip_all)]
&self, async fn proxy(&self, url: String, req: &HttpRequest) -> Result<HttpResponse, Error> {
url: String,
req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
let client_request = self.client.request_from(url, req.head()); let client_request = self.client.request_from(url, req.head());
let client_request = if let Some(addr) = req.head().peer_addr { let client_request = if let Some(addr) = req.head().peer_addr {
client_request.append_header(("X-Forwarded-For", addr.to_string())) client_request.append_header(("X-Forwarded-For", addr.to_string()))
@ -185,7 +207,7 @@ impl Connection {
.no_decompress() .no_decompress()
.send() .send()
.await .await
.map_err(|_| UploadError::Request)?; .map_err(|e| UploadError::Request(Arc::from(e.to_string())))?;
let mut client_res = HttpResponse::build(res.status()); let mut client_res = HttpResponse::build(res.status());
@ -199,40 +221,18 @@ impl Connection {
#[derive(Clone, Debug, thiserror::Error)] #[derive(Clone, Debug, thiserror::Error)]
pub(crate) enum UploadError { pub(crate) enum UploadError {
#[error("There was an error uploading the image")] #[error("There was an error making the upstream request: {0}")]
Request, Request(Arc<str>),
#[error("There was an error parsing the image response")] #[error("There was an error parsing the upstream response")]
Json, Json,
#[error("Request returned bad HTTP status")] #[error("Request returned bad HTTP status")]
Status, Status,
}
#[error("Request failed with {0}: {1}")]
impl ResponseError for UploadError { UploadFailure(String, String),
fn status_code(&self) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR #[error("Requested size {0} is invalid")]
} Size(u16),
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(self.to_string())
}
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("The requested size is invalid, {0}")]
struct SizeError(u16);
impl ResponseError for SizeError {
fn status_code(&self) -> StatusCode {
StatusCode::BAD_REQUEST
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(self.to_string())
}
} }

View file

@ -15,9 +15,9 @@ use std::{
io::Cursor, io::Cursor,
net::SocketAddr, net::SocketAddr,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc,
time::SystemTime, time::SystemTime,
}; };
use tracing_error::SpanTrace;
use url::Url; use url::Url;
use uuid::Uuid; use uuid::Uuid;
@ -357,10 +357,12 @@ impl<T, E> ResultExt<T> for Result<T, E>
where where
Error: From<E>, Error: From<E>,
{ {
#[track_caller]
fn stateful(self, state: &web::Data<State>) -> Result<T, StateError> { fn stateful(self, state: &web::Data<State>) -> Result<T, StateError> {
self.map_err(Error::from).map_err(|error| StateError { self.map_err(Error::from).map_err(|error| StateError {
state: state.clone(), state: state.clone(),
error, debug: Arc::from(format!("{error:?}")),
display: Arc::from(format!("{error}")),
}) })
} }
} }
@ -389,7 +391,7 @@ async fn static_files(filename: web::Path<String>, state: web::Data<State>) -> H
to_404(&state) to_404(&state)
} }
#[tracing::instrument(name = "Not found")] #[tracing::instrument(name = "Not found", skip_all)]
async fn not_found(state: web::Data<State>) -> Result<HttpResponse, StateError> { async fn not_found(state: web::Data<State>) -> Result<HttpResponse, StateError> {
rendered( rendered(
|cursor| self::templates::not_found_html(cursor, &state), |cursor| self::templates::not_found_html(cursor, &state),
@ -400,20 +402,19 @@ async fn not_found(state: web::Data<State>) -> Result<HttpResponse, StateError>
struct StateError { struct StateError {
state: web::Data<State>, state: web::Data<State>,
error: Error, debug: Arc<str>,
display: Arc<str>,
} }
impl std::fmt::Debug for StateError { impl std::fmt::Debug for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.debug_struct("StateError") f.write_str(&self.debug)
.field("error", &self.error)
.finish()
} }
} }
impl std::fmt::Display for StateError { impl std::fmt::Display for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.error) f.write_str(&self.display)
} }
} }
@ -424,33 +425,36 @@ impl ResponseError for StateError {
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
match rendered( match rendered(
|cursor| self::templates::error_html(cursor, &self.error.kind.to_string(), &self.state), |cursor| self::templates::error_html(cursor, &self.display, &self.state),
HttpResponse::build(self.status_code()), HttpResponse::build(self.status_code()),
) { ) {
Ok(res) => res, Ok(res) => res,
Err(_) => HttpResponse::build(self.status_code()) Err(_) => HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str()) .content_type(mime::TEXT_PLAIN.essence_str())
.body(self.error.kind.to_string()), .body(self.display.to_string()),
} }
} }
} }
#[derive(Debug)]
struct Error { struct Error {
context: SpanTrace, context: color_eyre::Report,
kind: ErrorKind, }
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 { impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "{}", self.kind)?; self.context.fmt(f)
std::fmt::Display::fmt(&self.context, f)
} }
} }
impl std::error::Error for Error { impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.kind.source() self.context.source()
} }
} }
@ -458,27 +462,30 @@ impl<T> From<T> for Error
where where
ErrorKind: From<T>, ErrorKind: From<T>,
{ {
#[track_caller]
fn from(error: T) -> Self { fn from(error: T) -> Self {
Error { Error {
context: SpanTrace::capture(), context: color_eyre::Report::from(ErrorKind::from(error)),
kind: error.into(),
} }
} }
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
enum ErrorKind { enum ErrorKind {
#[error("{0}")] #[error("Error in IO")]
Render(#[from] std::io::Error), Render(#[from] std::io::Error),
#[error("{0}")] #[error("Error in store")]
Store(#[from] self::store::Error), Store(#[from] self::store::Error),
#[error("{0}")] #[error("Error in pict-rs connection")]
Upload(#[from] self::connection::UploadError), Upload(#[from] self::connection::UploadError),
#[error("{0}")] #[error("{0}")]
UploadString(String), UploadString(String),
#[error("Operation canceled")]
Canceled,
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
@ -496,9 +503,16 @@ pub enum EntryKind {
Ready { Ready {
filename: String, filename: String,
delete_token: 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)] #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct Entry { pub struct Entry {
title: Optional<String>, title: Optional<String>,
@ -591,6 +605,7 @@ impl Entry {
if let EntryKind::Ready { if let EntryKind::Ready {
filename, filename,
delete_token, delete_token,
..
} = &self.file_info } = &self.file_info
{ {
Some((&filename, &delete_token)) Some((&filename, &delete_token))
@ -599,6 +614,18 @@ impl Entry {
} }
} }
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> { pub(crate) fn upload_id(&self) -> Option<&str> {
if let EntryKind::Pending { upload_id } = &self.file_info { if let EntryKind::Pending { upload_id } = &self.file_info {
Some(&upload_id) Some(&upload_id)
@ -631,7 +658,7 @@ impl MoveEntryPath {
} }
} }
#[tracing::instrument(name = "Upload image", skip(req, pl))] #[tracing::instrument(name = "Upload image", skip(req, pl, token, conn, state))]
async fn upload( async fn upload(
req: HttpRequest, req: HttpRequest,
pl: web::Payload, pl: web::Payload,
@ -683,6 +710,10 @@ async fn upload(
entry.file_info = EntryKind::Ready { entry.file_info = EntryKind::Ready {
filename: image.file().to_owned(), filename: image.file().to_owned(),
delete_token: image.delete_token().to_owned(), delete_token: image.delete_token().to_owned(),
dimensions: Some(Dimensions {
width: image.width(),
height: image.height(),
}),
}; };
let _ = store::UpdateEntry { let _ = store::UpdateEntry {
@ -726,13 +757,14 @@ struct ImagePath {
filename: String, filename: String,
} }
#[tracing::instrument(name = "Serve image", skip(req))] #[tracing::instrument(name = "Serve image", skip(req, conn, state))]
async fn image( async fn image(
req: HttpRequest, req: HttpRequest,
path: web::Path<ImagePath>, path: web::Path<ImagePath>,
conn: web::Data<Connection>, conn: web::Data<Connection>,
) -> Result<HttpResponse, actix_web::Error> { state: web::Data<State>,
conn.image(&path.filename, &req).await ) -> Result<HttpResponse, StateError> {
conn.image(&path.filename, &req).await.stateful(&state)
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
@ -746,18 +778,20 @@ struct ThumbnailQuery {
size: u16, size: u16,
} }
#[tracing::instrument(name = "Serve thumbnail", skip(req))] #[tracing::instrument(name = "Serve thumbnail", skip(req, conn, state))]
async fn thumbnail( async fn thumbnail(
req: HttpRequest, req: HttpRequest,
path: web::Path<ThumbnailPath>, path: web::Path<ThumbnailPath>,
query: web::Query<ThumbnailQuery>, query: web::Query<ThumbnailQuery>,
conn: web::Data<Connection>, conn: web::Data<Connection>,
) -> Result<HttpResponse, actix_web::Error> { state: web::Data<State>,
) -> Result<HttpResponse, StateError> {
conn.thumbnail(query.size, &query.src, path.extension, &req) conn.thumbnail(query.size, &query.src, path.extension, &req)
.await .await
.stateful(&state)
} }
#[tracing::instrument(name = "Index")] #[tracing::instrument(name = "Index", skip_all)]
async fn index(state: web::Data<State>) -> Result<HttpResponse, StateError> { async fn index(state: web::Data<State>) -> Result<HttpResponse, StateError> {
rendered( rendered(
|cursor| self::templates::index_html(cursor, &state), |cursor| self::templates::index_html(cursor, &state),
@ -766,35 +800,134 @@ async fn index(state: web::Data<State>) -> Result<HttpResponse, StateError> {
.stateful(&state) .stateful(&state)
} }
#[tracing::instrument(name = "Collection", skip(req))] #[tracing::instrument(name = "Collection", skip(req, token, connection, state))]
async fn collection( async fn collection(
req: HttpRequest,
path: web::Path<CollectionPath>, path: web::Path<CollectionPath>,
token: Option<ValidToken>, token: Option<ValidToken>,
connection: web::Data<Connection>,
state: web::Data<State>, state: web::Data<State>,
req: HttpRequest,
) -> Result<HttpResponse, StateError> { ) -> Result<HttpResponse, StateError> {
match token { match token {
Some(token) => edit_collection(path, token, state.clone(), req) Some(token) => edit_collection(req, path, token, connection, state.clone())
.await
.stateful(&state),
None => view_collection(path, connection, state.clone())
.await .await
.stateful(&state), .stateful(&state),
None => view_collection(path, state.clone()).await.stateful(&state),
} }
} }
#[tracing::instrument(name = "View Collection")] 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 {
*entry = handle.await.map_err(|_| ErrorKind::Canceled)??;
}
}
Ok(entries)
}
#[tracing::instrument(name = "View Collection", skip(connection, state))]
async fn view_collection( async fn view_collection(
path: web::Path<CollectionPath>, path: web::Path<CollectionPath>,
connection: web::Data<Connection>,
state: web::Data<State>, state: web::Data<State>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let collection = match state.store.collection(&path).await? { let collection = match state.store.collection(&path).await? {
Some(collection) => collection, Some(collection) => collection,
None => return Ok(to_404(&state)), None => return Ok(to_404(&state)),
}; };
let entries = state let entries = state
.store .store
.entries(path.order_key(), path.entry_range()) .entries(path.order_key(), path.entry_range())
.await?; .await?;
let entries = ensure_dimensions(state.clone(), connection, path.collection, entries).await?;
rendered( rendered(
|cursor| { |cursor| {
self::templates::view_collection_html( self::templates::view_collection_html(
@ -809,12 +942,13 @@ async fn view_collection(
) )
} }
#[tracing::instrument(name = "Edit Collection", skip(req))] #[tracing::instrument(name = "Edit Collection", skip(req, connection, state))]
async fn edit_collection( async fn edit_collection(
req: HttpRequest,
path: web::Path<CollectionPath>, path: web::Path<CollectionPath>,
token: ValidToken, token: ValidToken,
connection: web::Data<Connection>,
state: web::Data<State>, state: web::Data<State>,
req: HttpRequest,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let qr = qr(&req, &path, &state); let qr = qr(&req, &path, &state);
@ -827,6 +961,8 @@ async fn edit_collection(
.entries(path.order_key(), path.entry_range()) .entries(path.order_key(), path.entry_range())
.await?; .await?;
let entries = ensure_dimensions(state.clone(), connection, path.collection, entries).await?;
rendered( rendered(
|cursor| { |cursor| {
self::templates::edit_collection_html( self::templates::edit_collection_html(
@ -843,7 +979,7 @@ async fn edit_collection(
) )
} }
#[tracing::instrument(name = "Create Collection")] #[tracing::instrument(name = "Create Collection", skip_all)]
async fn create_collection( async fn create_collection(
collection: web::Form<Collection>, collection: web::Form<Collection>,
state: web::Data<State>, state: web::Data<State>,
@ -873,7 +1009,7 @@ async fn create_collection(
)) ))
} }
#[tracing::instrument(name = "Update Collection")] #[tracing::instrument(name = "Update Collection", skip(token, state))]
async fn update_collection( async fn update_collection(
path: web::Path<CollectionPath>, path: web::Path<CollectionPath>,
form: web::Form<Collection>, form: web::Form<Collection>,
@ -911,7 +1047,7 @@ async fn move_entry(
)) ))
} }
#[tracing::instrument(name = "Update Entry")] #[tracing::instrument(name = "Update Entry", skip(token, state))]
async fn update_entry( async fn update_entry(
entry_path: web::Path<EntryPath>, entry_path: web::Path<EntryPath>,
entry: web::Form<Entry>, entry: web::Form<Entry>,
@ -939,7 +1075,7 @@ struct ConfirmQuery {
confirmed: Option<bool>, confirmed: Option<bool>,
} }
#[tracing::instrument(name = "Delete Entry")] #[tracing::instrument(name = "Delete Entry", skip(token, conn, state))]
async fn delete_entry( async fn delete_entry(
entry_path: web::Path<EntryPath>, entry_path: web::Path<EntryPath>,
query: web::Query<ConfirmQuery>, query: web::Query<ConfirmQuery>,
@ -974,6 +1110,7 @@ async fn delete_entry(
if let EntryKind::Ready { if let EntryKind::Ready {
filename, filename,
delete_token, delete_token,
..
} = &entry.file_info } = &entry.file_info
{ {
conn.delete(filename, delete_token).await.stateful(&state)?; conn.delete(filename, delete_token).await.stateful(&state)?;
@ -1031,7 +1168,7 @@ fn to_svg_string(qr: &qrcodegen::QrCode, border: i32) -> String {
result result
} }
#[tracing::instrument(name = "Delete Collection")] #[tracing::instrument(name = "Delete Collection", skip(token, conn, state))]
async fn delete_collection( async fn delete_collection(
path: web::Path<CollectionPath>, path: web::Path<CollectionPath>,
query: web::Query<ConfirmQuery>, query: web::Query<ConfirmQuery>,
@ -1075,6 +1212,7 @@ async fn delete_collection(
if let EntryKind::Ready { if let EntryKind::Ready {
filename, filename,
delete_token, delete_token,
..
} = entry.file_info.clone() } = entry.file_info.clone()
{ {
let conn = conn.clone(); let conn = conn.clone();
@ -1088,11 +1226,7 @@ async fn delete_collection(
let mut results = Vec::new(); let mut results = Vec::new();
for rx in future_vec { for rx in future_vec {
results.push( results.push(rx.await.map_err(|_| ErrorKind::Canceled).stateful(&state)?);
rx.await
.map_err(|_| ErrorKind::UploadString("Canceled".to_string()))
.stateful(&state)?,
);
} }
// Only bail before deleting collection if all images failed deletion // Only bail before deleting collection if all images failed deletion

View file

@ -12,13 +12,12 @@ use tracing_awc::Tracing;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_log::LogTracer; use tracing_log::LogTracer;
use tracing_subscriber::{ use tracing_subscriber::{
filter::Targets, fmt::format::FmtSpan, layer::SubscriberExt, registry::LookupSpan, Layer, filter::Targets, layer::SubscriberExt, registry::LookupSpan, Layer, Registry,
Registry,
}; };
use url::Url; use url::Url;
#[actix_rt::main] #[actix_rt::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> color_eyre::Result<()> {
let config = pict_rs_aggregator::Config::parse(); let config = pict_rs_aggregator::Config::parse();
init_logger( init_logger(
@ -36,11 +35,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let bind_address = config.bind_address(); let bind_address = config.bind_address();
let state = pict_rs_aggregator::state(config, "", db)?; let state = pict_rs_aggregator::state(config, "", db)?;
tracing::info!("Launching on {bind_address}");
HttpServer::new(move || { HttpServer::new(move || {
let client = Client::builder() let client = Client::builder()
.wrap(Tracing) .wrap(Tracing)
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.add_default_header(("User-Agent", "pict_rs_aggregator-v0.5.0-alpha.1")) .add_default_header(("User-Agent", "pict_rs_aggregator-v0.5.0-beta.2"))
.disable_redirects() .disable_redirects()
.finish(); .finish();
@ -58,7 +59,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
fn init_logger( fn init_logger(
opentelemetry_url: Option<&Url>, opentelemetry_url: Option<&Url>,
console_event_buffer_size: Option<usize>, console_event_buffer_size: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> color_eyre::Result<()> {
color_eyre::install()?;
LogTracer::init()?; LogTracer::init()?;
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new()); opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
@ -67,9 +70,7 @@ fn init_logger(
.unwrap_or_else(|_| "info".into()) .unwrap_or_else(|_| "info".into())
.parse()?; .parse()?;
let format_layer = tracing_subscriber::fmt::layer() let format_layer = tracing_subscriber::fmt::layer().with_filter(targets.clone());
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
.with_filter(targets.clone());
let subscriber = Registry::default() let subscriber = Registry::default()
.with(format_layer) .with(format_layer)
@ -94,7 +95,7 @@ fn init_subscriber<S>(
subscriber: S, subscriber: S,
targets: Targets, targets: Targets,
opentelemetry_url: Option<&Url>, opentelemetry_url: Option<&Url>,
) -> Result<(), Box<dyn std::error::Error>> ) -> color_eyre::Result<()>
where where
S: SubscriberExt + Send + Sync, S: SubscriberExt + Send + Sync,
for<'a> S: LookupSpan<'a>, for<'a> S: LookupSpan<'a>,

View file

@ -24,6 +24,7 @@ impl std::fmt::Display for Extension {
pub(crate) struct Image { pub(crate) struct Image {
file: String, file: String,
delete_token: String, delete_token: String,
details: Details,
} }
impl Image { impl Image {
@ -34,11 +35,26 @@ impl Image {
pub(crate) fn delete_token(&self) -> &str { pub(crate) fn delete_token(&self) -> &str {
&self.delete_token &self.delete_token
} }
pub(crate) fn width(&self) -> u32 {
self.details.width
}
pub(crate) fn height(&self) -> u32 {
self.details.height
}
}
#[derive(serde::Deserialize)]
pub(crate) struct Details {
pub(super) width: u32,
pub(super) height: u32,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub(crate) struct Images { pub(crate) struct Images {
msg: String, msg: String,
code: Option<String>,
files: Option<Vec<Image>>, files: Option<Vec<Image>>,
} }
@ -47,6 +63,10 @@ impl Images {
&self.msg &self.msg
} }
pub(crate) fn code(&self) -> Option<&str> {
self.code.as_deref()
}
pub(crate) fn files(&self) -> impl Iterator<Item = &Image> { pub(crate) fn files(&self) -> impl Iterator<Item = &Image> {
self.files.iter().flat_map(|v| v.iter()) self.files.iter().flat_map(|v| v.iter())
} }

View file

@ -390,19 +390,19 @@ impl Store {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub(crate) enum Error { pub(crate) enum Error {
#[error("{0}")] #[error("Error in stored json")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("{0}")] #[error("Error parsing UUID")]
Uuid(#[from] uuid::Error), Uuid(#[from] uuid::Error),
#[error("{0}")] #[error("Error in sled")]
Sled(#[from] sled::Error), Sled(#[from] sled::Error),
#[error("{0}")] #[error("Error in sled transaction")]
Transaction(#[from] sled::transaction::TransactionError), Transaction(#[from] sled::transaction::TransactionError),
#[error("{0}")] #[error("Error hashing or validating token")]
Bcrypt(#[from] bcrypt::BcryptError), Bcrypt(#[from] bcrypt::BcryptError),
#[error("Panic in blocking operation")] #[error("Panic in blocking operation")]

View file

@ -15,7 +15,7 @@
<div class="content-group"> <div class="content-group">
<div class="edit-row"> <div class="edit-row">
<div class="edit-item"> <div class="edit-item">
@:image_html(entry, state) @:image_html(id, entry, state)
</div> </div>
<div class="edit-item"> <div class="edit-item">
<p class="delete-confirmation">Are you sure you want to delete this image?</p> <p class="delete-confirmation">Are you sure you want to delete this image?</p>

View file

@ -48,7 +48,7 @@ text_input_html, statics::file_upload_js};
<article> <article>
<div class="edit-row"> <div class="edit-row">
<div class="edit-item"> <div class="edit-item">
@:image_html(entry, state) @:image_html(*id, entry, state)
</div> </div>
<div class="edit-item"> <div class="edit-item">
<form method="POST" action="@state.update_entry_path(collection_id, *id, token)"> <form method="POST" action="@state.update_entry_path(collection_id, *id, token)">
@ -61,8 +61,7 @@ text_input_html, statics::file_upload_js};
<div class="button-group button-space"> <div class="button-group button-space">
@if let Some(upload_id) = entry.upload_id() { @if let Some(upload_id) = entry.upload_id() {
<input type="hidden" name="upload_id" value="@upload_id" /> <input type="hidden" name="upload_id" value="@upload_id" />
@:button_link_html("Refresh", &state.edit_collection_path(collection_id, Some(*id), token), @:button_link_html("Refresh", "javascript:window.location.reload(true);", ButtonKind::Submit)
ButtonKind::Submit)
} }
@if let Some((filename, delete_token)) = entry.file_parts() { @if let Some((filename, delete_token)) = entry.file_parts() {
<input type="hidden" name="filename" value="@filename" /> <input type="hidden" name="filename" value="@filename" />

View file

@ -1,9 +1,10 @@
@use crate::{Entry, State}; @use crate::{Entry, State};
@use super::image_preview_html; @use super::image_preview_html;
@use uuid::Uuid;
@(entry: &Entry, state: &State) @(id: Uuid, entry: &Entry, state: &State)
@:image_preview_html(entry, state) @:image_preview_html(id, entry, state)
<div class="image-meta"> <div class="image-meta">
@if let Some(title) = entry.title.as_ref() { @if let Some(title) = entry.title.as_ref() {
<div class="image-title">@title</div> <div class="image-title">@title</div>

View file

@ -1,16 +1,24 @@
@use crate::{pict::Extension, Entry, State}; @use crate::{pict::Extension, Entry, State};
@use uuid::Uuid;
@(entry: &Entry, state: &State) @(id: Uuid, entry: &Entry, state: &State)
@if let Some(filename) = entry.filename() { @if let Some(filename) = entry.filename() {
<div class="image-box"> <div class="image-box">
<a href="#@id">
<picture> <picture>
<source type="image/webp" srcset="@state.srcset(filename, Extension::Webp)" /> <source type="image/webp" srcset="@state.srcset(filename, Extension::Webp)" />
<source type="image/avif" srcset="@state.srcset(filename, Extension::Avif)" /> <source type="image/avif" srcset="@state.srcset(filename, Extension::Avif)" />
<source type="image/jpeg" srcset="@state.srcset(filename, Extension::Jpg)" /> <source type="image/jpeg" srcset="@state.srcset(filename, Extension::Jpg)" />
<img src="@state.image_path(filename)" @if let Some(title)=entry.title.as_ref() { title="@title" } @if let <img
Some(description)=entry.description.as_ref() { alt="@description" } /> loading="lazy"
src="@state.image_path(filename)"
@if let Some(title)=entry.title.as_ref() { title="@title" }
@if let Some(description)=entry.description.as_ref() { alt="@description" }
@if let Some(dimensions) = entry.dimensions() { width="@dimensions.width" height="@dimensions.height" }
/>
</picture> </picture>
</a>
</div> </div>
} else { } else {
<span>Pending</span> <span>Pending</span>

View file

@ -28,10 +28,10 @@
</div> </div>
</article> </article>
<ul> <ul>
@for (_, entry) in entries { @for (id, entry) in entries {
<li class="content-group even"> <li class="content-group even" id="@id">
<article> <article>
@:image_html(entry, state) @:image_html(*id, entry, state)
</article> </article>
</li> </li>
} }