Compare commits
15 Commits
a7ade951aa
...
main
Author | SHA1 | Date | |
---|---|---|---|
286284300a | |||
d197e7591b | |||
1424d765f7 | |||
f3483c528d | |||
812326752c | |||
9c319d6949 | |||
54295ea098 | |||
48a3a206a3 | |||
68c7fd2ac4 | |||
17d0bae1aa | |||
03ab64ddbc | |||
8f004e5e65 | |||
68f50b5292 | |||
9af4831efa | |||
6206d0ea58 |
1092
Cargo.lock
generated
1092
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ jemallocator = "0.5.0"
|
||||
kamadak-exif = "0.5.5"
|
||||
minijinja = "0.30.4"
|
||||
serde = { version = "1.0.152", features = ["derive"] }
|
||||
serde_yaml = "0.9.21"
|
||||
tokio = { version = "1.25.0", features = ["full"] }
|
||||
tower-http = { version = "0.3.5", features = ["fs", "trace", "compression-br"] }
|
||||
tracing = "0.1.37"
|
||||
|
14
config.yaml.example
Normal file
14
config.yaml.example
Normal file
@ -0,0 +1,14 @@
|
||||
# Save this file as config.yaml into the image directory
|
||||
|
||||
# Config and image list will be updated every 60s. If there is
|
||||
# an error with the config (or no config at all), no access to
|
||||
# the images will be granted; check the log in case of errors.
|
||||
# Changing the config invalidates all active sessions.
|
||||
|
||||
title: Website title
|
||||
passwords:
|
||||
- password1
|
||||
- password2
|
||||
|
||||
# Optional, will be displayed above images
|
||||
top-message: "Please do not share or save these images"
|
18
src/config.rs
Normal file
18
src/config.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use std::{path::Path, fs::File};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq, Deserialize)]
|
||||
pub struct Config {
|
||||
pub title: String,
|
||||
#[serde(alias = "top-message")]
|
||||
pub top_message: Option<String>,
|
||||
pub passwords: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn read_from_file(config_file: &Path) -> Result<Config> {
|
||||
let file = File::open(config_file)?;
|
||||
Ok(serde_yaml::from_reader(file)?)
|
||||
}
|
247
src/main.rs
247
src/main.rs
@ -1,8 +1,8 @@
|
||||
use std::{net::SocketAddr, path::{Path, PathBuf}, ffi::{OsStr, OsString}, fs::File, io::{BufReader, Seek, Cursor, BufRead}, env::args_os, os::unix::prelude::OsStrExt, cmp::Reverse, sync::Arc, collections::HashMap};
|
||||
use std::{net::SocketAddr, path::{Path, PathBuf}, ffi::{OsStr, OsString}, fs::File, io::{BufReader, Seek, Cursor, BufRead}, env::args_os, os::unix::prelude::OsStrExt, cmp::Reverse, sync::{Arc, RwLock}, collections::{HashMap, HashSet}, time::{Instant, Duration}};
|
||||
|
||||
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_sessions::{async_session::CookieStore, SessionLayer, extractors::{ReadableSession, WritableSession}};
|
||||
use axum::{Router, routing::{get, post}, response::{IntoResponse, Redirect, Response}, http::{StatusCode, header}, extract::{self, State}, Form, handler::Handler};
|
||||
use axum_sessions::{async_session::{MemoryStore, SessionStore}, SessionLayer, extractors::{ReadableSession, WritableSession}};
|
||||
use axum_template::{RenderHtml, engine::Engine};
|
||||
use chacha20poly1305::{XChaCha20Poly1305, KeyInit, AeadCore, aead::{OsRng, rand_core::RngCore, Aead}, XNonce};
|
||||
use chrono::{DateTime, Utc};
|
||||
@ -10,11 +10,12 @@ use error::AppError;
|
||||
use exif::Exif;
|
||||
use image::{imageops::FilterType, DynamicImage};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tokio::{task, sync::RwLock};
|
||||
use tokio::{task, sync::Semaphore};
|
||||
use tower_http::{trace::{self, TraceLayer}, compression::CompressionLayer};
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
|
||||
#[global_allocator]
|
||||
@ -22,31 +23,47 @@ static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
||||
|
||||
// TODO
|
||||
// * Polish the css
|
||||
// * Add configuration file
|
||||
// * Brute-force protection / rate-limiting
|
||||
// * Support for headlines
|
||||
// * Cache generated images (+ cache cleanup)
|
||||
// * Image cache cleanup
|
||||
// * Limit session time (inactivity timeout)
|
||||
|
||||
type TemplateEngine = Engine<minijinja::Environment<'static>>;
|
||||
type ImageDir = PathBuf;
|
||||
type SecretKey = [u8; 64];
|
||||
type ImageListCache = Arc<RwLock<Vec<ImageInfo>>>;
|
||||
type ImageCache = Arc<RwLock<HashMap<OsString, (DateTime<Utc>, Vec<u8>)>>>;
|
||||
type Config = Arc<RwLock<config::Config>>;
|
||||
type CpuTaskLimit = Arc<Semaphore>;
|
||||
|
||||
#[derive(Clone, extract::FromRef)]
|
||||
struct ApplicationState {
|
||||
config: Config,
|
||||
engine: TemplateEngine,
|
||||
image_cache: ImageCache,
|
||||
image_dir: ImageDir,
|
||||
image_list_cache: ImageListCache,
|
||||
secret_key: SecretKey,
|
||||
cpu_task_limit: CpuTaskLimit,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let image_path = args_os().nth(1).expect("Usage: image-gallery IMAGE_DIRECTORY");
|
||||
let args = args_os()
|
||||
.skip(1)
|
||||
.collect::<Vec<_>>();
|
||||
let args = args
|
||||
.windows(2)
|
||||
.next()
|
||||
.expect("Usage: image-gallery IMAGE_DIRECTORY PORT");
|
||||
|
||||
let image_path = &args[0];
|
||||
let port = args[1].to_string_lossy().parse::<u16>().expect("Invalid port specified");
|
||||
|
||||
let mut secret_key: SecretKey = [0; 64];
|
||||
OsRng.fill_bytes(&mut secret_key);
|
||||
|
||||
let default_tracing = "image_gallery=debug,tower_http=info".into();
|
||||
let default_tracing = "image_gallery=debug,tower_http=error".into();
|
||||
let tracing_filter = EnvFilter::try_from_default_env().unwrap_or(default_tracing);
|
||||
let tracing_formatter = tracing_subscriber::fmt::layer()
|
||||
.with_target(false)
|
||||
@ -56,17 +73,32 @@ async fn main() {
|
||||
.with(tracing_formatter)
|
||||
.init();
|
||||
|
||||
let sessions = MemoryStore::new();
|
||||
let image_dir = PathBuf::from(image_path);
|
||||
let config = Config::default();
|
||||
let image_cache = ImageCache::default();
|
||||
let image_list_cache = ImageListCache::default();
|
||||
|
||||
tokio::spawn(update_config_and_image_list_cache_job(
|
||||
image_dir.clone(),
|
||||
config.clone(),
|
||||
image_list_cache.clone(),
|
||||
sessions.clone(),
|
||||
));
|
||||
|
||||
let mut jinja = minijinja::Environment::new();
|
||||
jinja.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
|
||||
jinja.add_template("base", include_str!("../templates/base.html")).unwrap();
|
||||
jinja.add_template("index", include_str!("../templates/index.html")).unwrap();
|
||||
jinja.add_template("login", include_str!("../templates/login.html")).unwrap();
|
||||
|
||||
let session_layer = SessionLayer::new(CookieStore::new(), &secret_key)
|
||||
let session_layer = SessionLayer::new(sessions, &secret_key)
|
||||
.with_cookie_name("session")
|
||||
.with_session_ttl(None);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index.layer(CompressionLayer::new())))
|
||||
.route("/config.yaml", get(example_config))
|
||||
.route("/authenticate", post(authenticate))
|
||||
.route("/image/:encrypted_filename_hex", get(converted_image))
|
||||
.layer(TraceLayer::new_for_http()
|
||||
@ -75,13 +107,16 @@ async fn main() {
|
||||
)
|
||||
.layer(session_layer)
|
||||
.with_state(ApplicationState {
|
||||
config,
|
||||
engine: Engine::from(jinja),
|
||||
image_cache: Default::default(),
|
||||
image_dir: PathBuf::from(image_path),
|
||||
image_cache,
|
||||
image_dir,
|
||||
image_list_cache,
|
||||
secret_key,
|
||||
cpu_task_limit: Arc::new(Semaphore::new(4)),
|
||||
});
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
tracing::info!("listening on {}", addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
@ -89,7 +124,121 @@ async fn main() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
async fn update_config_and_image_list_cache_job(
|
||||
image_dir: ImageDir,
|
||||
config: Config,
|
||||
image_metadata_cache: ImageListCache,
|
||||
sessions: MemoryStore,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(10));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let mut last_images = HashSet::new();
|
||||
let mut last_image_dir_change = DateTime::<Utc>::UNIX_EPOCH;
|
||||
let mut last_forced_update = Instant::now();
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Read config
|
||||
let config_file = image_dir.join("config.yaml");
|
||||
let config = config.clone();
|
||||
let invalidate_sessions = if config_file.exists() {
|
||||
task::spawn_blocking(move || {
|
||||
match config::read_from_file(&config_file) {
|
||||
Ok(new_config) => {
|
||||
let mut config = config.write().unwrap();
|
||||
if new_config != *config {
|
||||
tracing::debug!("New config: {:?}", new_config);
|
||||
*config = new_config;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::error!("Could not read config, using default: {:#}", error);
|
||||
*config.write().unwrap() = config::Config::default();
|
||||
true
|
||||
}
|
||||
}
|
||||
}).await.unwrap()
|
||||
} else {
|
||||
*config.write().unwrap() = config::Config::default();
|
||||
true
|
||||
};
|
||||
if invalidate_sessions {
|
||||
sessions.clear_store().await.ok();
|
||||
}
|
||||
|
||||
// Read the modification time of the image dir
|
||||
// Note that some fuse implementations (e.g. seaf_fuse) will update
|
||||
// the mtime of the directory when a file inside the directory is
|
||||
// modified. It might also be very expensive to stat files in such
|
||||
// a case, so we only record the modification time of the directory.
|
||||
let image_dir_change = match image_dir.metadata().and_then(|m| m.modified()) {
|
||||
Ok(modified) => modified.into(),
|
||||
Err(error) => {
|
||||
tracing::error!("Could not read modification time of image dir: {:#}", error);
|
||||
continue
|
||||
},
|
||||
};
|
||||
|
||||
// Read all image files from the image_dir
|
||||
let images: HashSet<_> = match image_dir.read_dir() {
|
||||
Ok(images) => {
|
||||
// flatten ignores io error while traversing the iterator
|
||||
images.flatten().filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
if path.extension().unwrap_or_default().to_ascii_lowercase() == "jpg" {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect()
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::error!("Could not read images: {:#}", error);
|
||||
continue
|
||||
},
|
||||
};
|
||||
|
||||
let update_image_metadata = if image_dir_change != last_image_dir_change {
|
||||
tracing::debug!("Update image list because image dir was modified");
|
||||
true
|
||||
} else if images != last_images {
|
||||
// TODO: Maybe clear removed files from the image cache here?
|
||||
tracing::debug!("Update image list because list of images changed");
|
||||
true
|
||||
} else if last_forced_update.elapsed() > Duration::from_secs(60*60) {
|
||||
last_forced_update = Instant::now();
|
||||
tracing::debug!("Update image list because one hour elapsed");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Update image list
|
||||
if update_image_metadata {
|
||||
let images = images.clone();
|
||||
let image_metadata = task::spawn_blocking(move || {
|
||||
read_image_metadata(&images)
|
||||
}).await.unwrap_or_else(|error| {
|
||||
tracing::error!("Could not read images due to panic: {:#}", error);
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
tracing::debug!("{} images in the image list cache", image_metadata.len());
|
||||
*image_metadata_cache.write().unwrap() = image_metadata;
|
||||
}
|
||||
|
||||
last_images = images;
|
||||
last_image_dir_change = image_dir_change;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default, Clone)]
|
||||
pub struct ImageInfo {
|
||||
width: u32,
|
||||
height: u32,
|
||||
@ -101,26 +250,35 @@ pub struct ImageInfo {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IndexTempalte {
|
||||
pub struct LoginTemplate {
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IndexTemplate {
|
||||
title: String,
|
||||
top_message: Option<String>,
|
||||
images: Vec<ImageInfo>,
|
||||
}
|
||||
|
||||
async fn index(
|
||||
engine: TemplateEngine,
|
||||
State(image_dir): State<ImageDir>,
|
||||
State(config): State<Config>,
|
||||
State(image_list_cache): State<ImageListCache>,
|
||||
State(secret_key): State<SecretKey>,
|
||||
session: ReadableSession,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
) -> Result<Response, AppError> {
|
||||
let logged_in = session.get::<()>("logged_in").is_some();
|
||||
|
||||
let config = config.read().unwrap();
|
||||
|
||||
if logged_in {
|
||||
let images = read_images(&image_dir)?;
|
||||
let images = image_list_cache.read().unwrap();
|
||||
|
||||
// Encrypt image names
|
||||
let chacha_key = chacha20poly1305::Key::from_slice(&secret_key[0..32]);
|
||||
let chacha = XChaCha20Poly1305::new(chacha_key);
|
||||
let mut images = images.into_iter().map(|mut image_info| {
|
||||
let mut images = images.iter().cloned().map(|mut image_info| {
|
||||
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||
let mut ciphertext = chacha.encrypt(&nonce, image_info.name.as_bytes())?;
|
||||
|
||||
@ -132,18 +290,22 @@ async fn index(
|
||||
|
||||
images.sort_by_key(|entry| Reverse(entry.created));
|
||||
|
||||
Ok(RenderHtml("index", engine, IndexTempalte {
|
||||
title: "Some pictures".into(),
|
||||
Ok(RenderHtml("index", engine, IndexTemplate {
|
||||
title: config.title.clone(),
|
||||
top_message: config.top_message.clone(),
|
||||
images,
|
||||
}))
|
||||
}).into_response())
|
||||
} else {
|
||||
Ok(RenderHtml("login", engine, IndexTempalte {
|
||||
title: "Some pictures".into(),
|
||||
images: vec![],
|
||||
}))
|
||||
Ok(RenderHtml("login", engine, LoginTemplate {
|
||||
title: config.title.clone(),
|
||||
}).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
async fn example_config() -> &'static str {
|
||||
include_str!("../config.yaml.example")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticateForm {
|
||||
password: String,
|
||||
@ -151,9 +313,10 @@ struct AuthenticateForm {
|
||||
|
||||
async fn authenticate(
|
||||
mut session: WritableSession,
|
||||
Form(form): Form<AuthenticateForm>
|
||||
State(config): State<Config>,
|
||||
Form(form): Form<AuthenticateForm>,
|
||||
) -> Redirect {
|
||||
if form.password == "testle" {
|
||||
if config.read().unwrap().passwords.contains(&form.password) {
|
||||
session.insert("logged_in", ()).ok();
|
||||
}
|
||||
Redirect::to("/")
|
||||
@ -165,6 +328,7 @@ async fn converted_image(
|
||||
State(image_dir): State<ImageDir>,
|
||||
State(secret_key): State<SecretKey>,
|
||||
session: ReadableSession,
|
||||
State(cpu_task_limit): State<CpuTaskLimit>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
session.get::<()>("logged_in")
|
||||
.ok_or(anyhow!("Trying to load image while not logged in!"))
|
||||
@ -195,7 +359,7 @@ async fn converted_image(
|
||||
.ok();
|
||||
let cached_buffer = if let Some(image_modified) = image_modified {
|
||||
image_cache
|
||||
.read().await
|
||||
.read().unwrap()
|
||||
.get(image_name)
|
||||
.filter(|(cache_modified, _)| *cache_modified == image_modified)
|
||||
.map(|(_, image_buffer)| image_buffer)
|
||||
@ -208,6 +372,7 @@ async fn converted_image(
|
||||
image_buffer
|
||||
}
|
||||
None => {
|
||||
let _cpu_task_permit = cpu_task_limit.clone().acquire_owned().await?;
|
||||
let image_buffer = task::spawn_blocking(move || {
|
||||
convert_image(&image_path)
|
||||
.with_context(|| format!("Could not convert image {:?}", image_path))
|
||||
@ -216,7 +381,7 @@ async fn converted_image(
|
||||
if let Some(image_modified) = image_modified {
|
||||
tracing::debug!("Add {:?} ({}k) to cache", image_name, image_buffer.len() / 1024);
|
||||
image_cache
|
||||
.write().await
|
||||
.write().unwrap()
|
||||
.insert(image_name.to_owned(), (image_modified, image_buffer.clone()));
|
||||
}
|
||||
|
||||
@ -300,17 +465,11 @@ fn fix_image_orientation(image: DynamicImage, exif: &Exif) -> DynamicImage {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_images(directory: &Path) -> Result<Vec<ImageInfo>> {
|
||||
fn read_image_metadata(images: &HashSet<PathBuf>) -> Vec<ImageInfo> {
|
||||
let mut files = vec![];
|
||||
|
||||
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();
|
||||
if path.extension() == Some(OsStr::new("jpg")) {
|
||||
let image_info = match read_image_info(&path) {
|
||||
for path in images {
|
||||
let image_info = match read_image_info(path) {
|
||||
Ok(image_info) => image_info,
|
||||
Err(error) => {
|
||||
tracing::warn!("Skipping {:?} due to error: {:#}", path, error);
|
||||
@ -319,9 +478,8 @@ fn read_images(directory: &Path) -> Result<Vec<ImageInfo>> {
|
||||
};
|
||||
files.push(image_info);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
files
|
||||
}
|
||||
|
||||
fn extract_exif_string(field: &exif::Field) -> Option<String> {
|
||||
@ -353,10 +511,13 @@ fn read_image_info(path: &Path) -> Result<ImageInfo> {
|
||||
.and_then(extract_exif_string);
|
||||
|
||||
match (datetime_without_timezone, timezone) {
|
||||
(Some(datetime), Some(timezone)) => (datetime + &timezone).parse().ok(),
|
||||
(Some(datetime), None) => (datetime + "Z").parse().ok(),
|
||||
(Some(datetime), Some(timezone)) => Some(datetime + &timezone),
|
||||
(Some(datetime), None) => Some(datetime + "+00:00"),
|
||||
_ => None,
|
||||
}
|
||||
}.and_then(|datetime| {
|
||||
DateTime::parse_from_str(&datetime, "%Y:%m:%d %T%:z")
|
||||
.ok().map(|d| d.with_timezone(&Utc))
|
||||
})
|
||||
}).or_else(|| {
|
||||
// If that doesn't work, fall back to the file modification time
|
||||
std::fs::metadata(path)
|
||||
|
@ -7,6 +7,15 @@
|
||||
padding: 0.7rem;
|
||||
margin: 0;
|
||||
}
|
||||
header {
|
||||
font-family: sans-serif;
|
||||
font-size: 1.3rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background-color: #ebff7a;
|
||||
border: 3px solid #b0cf00;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -42,10 +51,15 @@
|
||||
{% endblock head %}
|
||||
|
||||
{% block main %}
|
||||
{% if top_message %}
|
||||
<header>
|
||||
{{top_message}}
|
||||
</header>
|
||||
{% endif %}
|
||||
<main>
|
||||
{% for image in images %}
|
||||
<div style="aspect-ratio: {{image.width}} / {{image.height}}; flex-grow: {{10*image.width/image.height}};">
|
||||
<img loading="lazy" src="/image/{{image.encrypted_name}}" alt="YOU SHALL NOT SAVE THIS IMAGE!" />
|
||||
<img loading="lazy" src="/image/{{image.encrypted_name}}" alt="Could not load image" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</main>
|
||||
|
Reference in New Issue
Block a user