Implement basic error handling with anyhow

This commit is contained in:
Klemens Schölhorn 2023-03-12 21:11:30 +01:00
parent 02dcb9d5d0
commit 81b7339634
4 changed files with 136 additions and 104 deletions

1
Cargo.lock generated
View File

@ -798,6 +798,7 @@ dependencies = [
name = "image-gallery" name = "image-gallery"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"axum", "axum",
"axum-sessions", "axum-sessions",
"axum-template", "axum-template",

View File

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.69"
axum = { version = "0.6.7", features = ["form", "macros"] } axum = { version = "0.6.7", features = ["form", "macros"] }
axum-sessions = "0.4.1" axum-sessions = "0.4.1"
axum-template = { version = "0.14.0", features = ["minijinja"] } axum-template = { version = "0.14.0", features = ["minijinja"] }

26
src/error.rs Normal file
View File

@ -0,0 +1,26 @@
use axum::{response::{IntoResponse, Response}, http::StatusCode};
pub struct AppError(anyhow::Error);
// Tell axum how to convert `AppError` into a response.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status_code = match self.0.downcast_ref::<StatusCode>() {
Some(status_code) => *status_code,
None => StatusCode::INTERNAL_SERVER_ERROR,
};
tracing::error!("{:#}", self.0);
status_code.into_response()
}
}
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
// `Result<_, AppError>`. That way you don't need to do that manually.
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

View File

@ -1,19 +1,23 @@
use std::{net::SocketAddr, path::{Path, PathBuf}, ffi::OsStr, fs::File, io::{BufReader, Seek, Cursor, BufRead}, time::SystemTime, env::args_os}; use std::{net::SocketAddr, path::{Path, PathBuf}, ffi::OsStr, fs::File, io::{BufReader, Seek, Cursor, BufRead}, time::SystemTime, env::args_os};
use anyhow::{Context, Result, anyhow};
use axum::{Router, routing::{get, post}, response::{IntoResponse, Redirect}, http::{StatusCode, header}, extract::{self, State}, Form, handler::Handler}; use axum::{Router, routing::{get, post}, response::{IntoResponse, Redirect}, http::{StatusCode, header}, extract::{self, State}, Form, handler::Handler};
use axum_sessions::{async_session::CookieStore, SessionLayer, extractors::{ReadableSession, WritableSession}}; use axum_sessions::{async_session::CookieStore, SessionLayer, extractors::{ReadableSession, WritableSession}};
use axum_template::{RenderHtml, engine::Engine}; use axum_template::{RenderHtml, engine::Engine};
use error::AppError;
use exif::{Tag, Value, Field, In, Exif}; use exif::{Tag, Value, Field, In, Exif};
use image::{imageops::FilterType, DynamicImage}; use image::{imageops::FilterType, DynamicImage};
use rand::Rng; use rand::Rng;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tokio::task; use tokio::task;
use tower_http::{trace::{self, TraceLayer}, compression::CompressionLayer}; use tower_http::{trace::{self, TraceLayer}, compression::CompressionLayer};
use tracing::{Level, debug_span}; use tracing::Level;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt, fmt::format::FmtSpan}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
mod error;
// TODO // TODO
// * Proper error handling // * sort images by date
// * Cache generated images (+ cache cleanup) // * Cache generated images (+ cache cleanup)
// * Add configuration file // * Add configuration file
// * Support for headlines // * Support for headlines
@ -36,7 +40,6 @@ async fn main() {
let tracing_filter = EnvFilter::try_from_default_env().unwrap_or(default_tracing); let tracing_filter = EnvFilter::try_from_default_env().unwrap_or(default_tracing);
let tracing_formatter = tracing_subscriber::fmt::layer() let tracing_formatter = tracing_subscriber::fmt::layer()
.with_target(false) .with_target(false)
.with_span_events(FmtSpan::CLOSE) // TODO: maybe drop this
.compact(); .compact();
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_filter) .with(tracing_filter)
@ -92,24 +95,23 @@ async fn index(
engine: TemplateEngine, engine: TemplateEngine,
State(image_dir): State<ImageDir>, State(image_dir): State<ImageDir>,
session: ReadableSession, session: ReadableSession,
) -> impl IntoResponse { ) -> Result<impl IntoResponse, AppError> {
let logged_in = session.get::<()>("logged_in").is_some();
let logged_in = session.get("logged_in").unwrap_or(false);
if logged_in { if logged_in {
let images = read_images(&image_dir); let images = read_images(&image_dir)?;
let images = images.into_iter().rev().take(30).collect(); let images = images.into_iter().rev().collect();
RenderHtml("index", engine, IndexTempalte { Ok(RenderHtml("index", engine, IndexTempalte {
title: "Some pictures".into(), title: "Some pictures".into(),
images, images,
}) }))
} else { } else {
RenderHtml("login", engine, IndexTempalte { Ok(RenderHtml("login", engine, IndexTempalte {
title: "Some pictures".into(), title: "Some pictures".into(),
images: vec![], images: vec![],
}) }))
} }
} }
@ -123,7 +125,7 @@ async fn authenticate(
Form(form): Form<AuthenticateForm> Form(form): Form<AuthenticateForm>
) -> Redirect { ) -> Redirect {
if form.password == "testle" { if form.password == "testle" {
session.insert("logged_in", true).ok(); session.insert("logged_in", ()).ok();
} }
Redirect::to("/") Redirect::to("/")
} }
@ -132,81 +134,68 @@ async fn converted_image(
extract::Path(image): extract::Path<String>, extract::Path(image): extract::Path<String>,
State(image_dir): State<ImageDir>, State(image_dir): State<ImageDir>,
session: ReadableSession, session: ReadableSession,
) -> Result<impl IntoResponse, impl IntoResponse> { ) -> Result<impl IntoResponse, AppError> {
if ! session.get("logged_in").unwrap_or(false) { session.get::<()>("logged_in")
return Err(StatusCode::FORBIDDEN); .ok_or(anyhow!("Trying to load image while not logged in!"))
} .context(StatusCode::FORBIDDEN)?;
let image_path = image_dir.join(image); let image_path = image_dir.join(image);
if ! image_path.exists() { image_path.exists()
return Err(StatusCode::NOT_FOUND); .then_some(())
} .ok_or(anyhow!("Requested image not found!"))
.context(StatusCode::NOT_FOUND)?;
let image_buffer = task::spawn_blocking(move || -> Result<_, StatusCode> { let image_buffer = task::spawn_blocking(move || {
let file = File::open(&image_path).unwrap(); convert_image(&image_path)
let mut file = BufReader::new(file); .with_context(|| format!("Could not convert image {:?}", image_path))
}).await??;
// Read exif data first, as it only reads a tiny amount of data
let exif = read_exif_data(&mut file);
let mut image = debug_span!("decode_image",
image=?image_path.file_name(),
).in_scope(|| -> Result<_, StatusCode>{
file.rewind().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
image::io::Reader::new(&mut file)
.with_guessed_format()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.decode()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
})?;
// Fix image orientation
if let Ok(exif) = exif {
image = fix_image_orientation(image, &exif);
}
let small_image = debug_span!("resize_image",
image=?image_path.file_name(),
).in_scope(|| {
image.resize(600, 600, FilterType::Lanczos3)
});
let mut image_buffer = Cursor::new(Vec::new());
debug_span!("encode_image",
image=?image_path.file_name(),
).in_scope(|| {
small_image.write_to(&mut image_buffer, image::ImageOutputFormat::WebP)
}).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(image_buffer)
}).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)??;
Ok(( Ok((
[ [
(header::CONTENT_TYPE, "image/webp"), (header::CONTENT_TYPE, "image/webp"),
], ],
image_buffer.into_inner() image_buffer
)) ))
} }
fn read_exif_data(mut file: impl BufRead+Seek) -> Result<Exif, ()> { fn convert_image(image_path: &Path) -> Result<Vec<u8>> {
file.rewind().map_err(|_| ())?; let file = File::open(image_path)?;
let exifreader = exif::Reader::new(); let mut file = BufReader::new(file);
exifreader.read_from_container(&mut file)
.map_err(|_| ()) // Read exif data first, as it only reads a tiny amount of data
let exif = read_exif_data(&mut file);
file.rewind()?;
let mut image = image::io::Reader::new(&mut file)
.with_guessed_format()?
.decode()?;
// Fix image orientation
if let Ok(exif) = exif {
image = fix_image_orientation(image, &exif);
}
let small_image = image.resize(600, 600, FilterType::Lanczos3);
let mut image_buffer = Cursor::new(Vec::new());
small_image.write_to(&mut image_buffer, image::ImageOutputFormat::WebP)?;
Ok(image_buffer.into_inner())
} }
fn read_image_size(mut file: impl BufRead+Seek, exif: Option<&Exif>) -> Result<(u32, u32), ()> { fn read_exif_data(mut file: impl BufRead+Seek) -> Result<Exif> {
file.rewind().map_err(|_| ())?; file.rewind()?;
let exifreader = exif::Reader::new();
Ok(exifreader.read_from_container(&mut file)?)
}
fn read_image_size(mut file: impl BufRead+Seek, exif: Option<&Exif>) -> Result<(u32, u32)> {
file.rewind()?;
let (mut width, mut height) = image::io::Reader::new(&mut file) let (mut width, mut height) = image::io::Reader::new(&mut file)
.with_guessed_format() .with_guessed_format()?
.map_err(|_| ())? .into_dimensions()?;
.into_dimensions()
.map_err(|_| ())?;
if let Some(exif) = exif { if let Some(exif) = exif {
if let Some(orientation) = exif.get_field(Tag::Orientation, In::PRIMARY) { if let Some(orientation) = exif.get_field(Tag::Orientation, In::PRIMARY) {
@ -238,44 +227,59 @@ fn fix_image_orientation(image: DynamicImage, exif: &Exif) -> DynamicImage {
} }
} }
fn read_images(directory: &Path) -> Result<Vec<ImageInfo>> {
fn read_images(directory: &Path) -> Vec<ImageInfo> {
let mut files = vec![]; let mut files = vec![];
for file in directory.read_dir().expect("read_dir call failed").flatten() {
let directory_iterator = directory
.read_dir()
.with_context(|| format!("Could not read files in directory {:?}", directory))?.flatten();
for file in directory_iterator {
let path = file.path(); let path = file.path();
if path.extension() == Some(OsStr::new("jpg")) { if path.extension() == Some(OsStr::new("jpg")) {
let file = File::open(&path).unwrap(); let image_info = match read_image_info(&path) {
let mut file = BufReader::new(file); Ok(image_info) => image_info,
Err(error) => {
let exif = read_exif_data(&mut file); tracing::warn!("Skipping {:?} due to error: {:#}", path, error);
continue;
// Check if we need to flip the coordinates
let (width, height) = read_image_size(&mut file, exif.as_ref().ok()).unwrap();
let datetime = 'datetime: {
// First, try to read creation date from EXIF data
if let Ok(ref exif) = exif {
match exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) {
Some(Field { value: Value::Ascii(value), ..}) if !value.is_empty() => {
break 'datetime String::from_utf8_lossy(&value[0]).into_owned()
}
_ => {}
};
} }
// If that doesn't work, fall back to the file modification time
format!("{:?}", std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(SystemTime::UNIX_EPOCH).unwrap())
}; };
let image_info = ImageInfo {
width,
height,
name: path.file_name().expect("invalid file path").to_string_lossy().to_string(),
};
files.push(image_info); files.push(image_info);
} }
} }
files
Ok(files)
}
fn read_image_info(path: &Path) -> Result<ImageInfo> {
let file = File::open(path)?;
let mut file = BufReader::new(file);
let exif = read_exif_data(&mut file);
// Check if we need to flip the coordinates
let (width, height) = read_image_size(&mut file, exif.as_ref().ok())
.context("Could not read image size")?;
let datetime = 'datetime: {
// First, try to read creation date from EXIF data
if let Ok(ref exif) = exif {
match exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) {
Some(Field { value: Value::Ascii(value), ..}) if !value.is_empty() => {
break 'datetime String::from_utf8_lossy(&value[0]).into_owned()
}
_ => {}
};
}
// If that doesn't work, fall back to the file modification time
format!("{:?}", std::fs::metadata(path).unwrap().modified().unwrap().duration_since(SystemTime::UNIX_EPOCH).unwrap())
};
Ok(ImageInfo {
width,
height,
name: path.file_name().expect("invalid file path").to_string_lossy().to_string(),
})
} }