Add support for config file in image directory

This config file will be loaded together with the images and defines the
passwords for login and some other parameters.
This commit is contained in:
Klemens Schölhorn 2023-04-07 00:37:54 +02:00
parent 68f50b5292
commit 8f004e5e65
6 changed files with 160 additions and 18 deletions

36
Cargo.lock generated
View File

@ -682,6 +682,12 @@ dependencies = [
"wasi 0.11.0+wasi-snapshot-preview1", "wasi 0.11.0+wasi-snapshot-preview1",
] ]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "headers" name = "headers"
version = "0.3.8" version = "0.3.8"
@ -868,12 +874,23 @@ dependencies = [
"kamadak-exif", "kamadak-exif",
"minijinja", "minijinja",
"serde", "serde",
"serde_yaml",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.3" version = "0.1.3"
@ -1358,6 +1375,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9d684e3ec7de3bf5466b32bd75303ac16f0736426e5a4e0d6e489559ce1249c"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.5" version = "0.10.5"
@ -1741,6 +1771,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "unsafe-libyaml"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1865806a559042e51ab5414598446a5871b561d21b6764f2eabb0dd481d880a6"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.0" version = "0.1.0"

View File

@ -16,6 +16,7 @@ jemallocator = "0.5.0"
kamadak-exif = "0.5.5" kamadak-exif = "0.5.5"
minijinja = "0.30.4" minijinja = "0.30.4"
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
serde_yaml = "0.9.21"
tokio = { version = "1.25.0", features = ["full"] } tokio = { version = "1.25.0", features = ["full"] }
tower-http = { version = "0.3.5", features = ["fs", "trace", "compression-br"] } tower-http = { version = "0.3.5", features = ["fs", "trace", "compression-br"] }
tracing = "0.1.37" tracing = "0.1.37"

14
config.yaml.example Normal file
View 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 this 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
View 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)?)
}

View File

@ -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, RwLock}, 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};
use anyhow::{Context, Result, anyhow}; 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, Response}, http::{StatusCode, header}, extract::{self, State}, Form, handler::Handler};
use axum_sessions::{async_session::MemoryStore, SessionLayer, extractors::{ReadableSession, WritableSession}}; use axum_sessions::{async_session::{MemoryStore, SessionStore}, SessionLayer, extractors::{ReadableSession, WritableSession}};
use axum_template::{RenderHtml, engine::Engine}; use axum_template::{RenderHtml, engine::Engine};
use chacha20poly1305::{XChaCha20Poly1305, KeyInit, AeadCore, aead::{OsRng, rand_core::RngCore, Aead}, XNonce}; use chacha20poly1305::{XChaCha20Poly1305, KeyInit, AeadCore, aead::{OsRng, rand_core::RngCore, Aead}, XNonce};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@ -15,6 +15,7 @@ use tower_http::{trace::{self, TraceLayer}, compression::CompressionLayer};
use tracing::Level; use tracing::Level;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
mod config;
mod error; mod error;
#[global_allocator] #[global_allocator]
@ -22,7 +23,7 @@ static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc;
// TODO // TODO
// * Polish the css // * Polish the css
// * Add configuration file // * Brute-force protection / rate-limiting
// * Support for headlines // * Support for headlines
// * Image cache cleanup // * Image cache cleanup
@ -31,9 +32,11 @@ type ImageDir = PathBuf;
type SecretKey = [u8; 64]; type SecretKey = [u8; 64];
type ImageListCache = Arc<RwLock<Vec<ImageInfo>>>; type ImageListCache = Arc<RwLock<Vec<ImageInfo>>>;
type ImageCache = Arc<RwLock<HashMap<OsString, (DateTime<Utc>, Vec<u8>)>>>; type ImageCache = Arc<RwLock<HashMap<OsString, (DateTime<Utc>, Vec<u8>)>>>;
type Config = Arc<RwLock<config::Config>>;
#[derive(Clone, extract::FromRef)] #[derive(Clone, extract::FromRef)]
struct ApplicationState { struct ApplicationState {
config: Config,
engine: TemplateEngine, engine: TemplateEngine,
image_cache: ImageCache, image_cache: ImageCache,
image_dir: ImageDir, image_dir: ImageDir,
@ -60,15 +63,19 @@ async fn main() {
let sessions = MemoryStore::new(); let sessions = MemoryStore::new();
let image_dir = PathBuf::from(image_path); let image_dir = PathBuf::from(image_path);
let config = Config::default();
let image_cache = ImageCache::default(); let image_cache = ImageCache::default();
let image_list_cache = ImageListCache::default(); let image_list_cache = ImageListCache::default();
tokio::spawn(update_image_list_cache_job( tokio::spawn(update_config_and_image_list_cache_job(
image_dir.clone(), image_dir.clone(),
image_list_cache.clone() config.clone(),
image_list_cache.clone(),
sessions.clone(),
)); ));
let mut jinja = minijinja::Environment::new(); 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("base", include_str!("../templates/base.html")).unwrap();
jinja.add_template("index", include_str!("../templates/index.html")).unwrap(); jinja.add_template("index", include_str!("../templates/index.html")).unwrap();
jinja.add_template("login", include_str!("../templates/login.html")).unwrap(); jinja.add_template("login", include_str!("../templates/login.html")).unwrap();
@ -79,6 +86,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/", get(index.layer(CompressionLayer::new()))) .route("/", get(index.layer(CompressionLayer::new())))
.route("/config.yaml", get(example_config))
.route("/authenticate", post(authenticate)) .route("/authenticate", post(authenticate))
.route("/image/:encrypted_filename_hex", get(converted_image)) .route("/image/:encrypted_filename_hex", get(converted_image))
.layer(TraceLayer::new_for_http() .layer(TraceLayer::new_for_http()
@ -87,6 +95,7 @@ async fn main() {
) )
.layer(session_layer) .layer(session_layer)
.with_state(ApplicationState { .with_state(ApplicationState {
config,
engine: Engine::from(jinja), engine: Engine::from(jinja),
image_cache, image_cache,
image_dir, image_dir,
@ -102,13 +111,50 @@ async fn main() {
.unwrap(); .unwrap();
} }
async fn update_image_list_cache_job(image_dir: ImageDir, image_cache: ImageListCache) { async fn update_config_and_image_list_cache_job(
image_dir: ImageDir,
config: Config,
image_cache: ImageListCache,
sessions: MemoryStore,
) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60)); let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop { loop {
interval.tick().await; 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();
}
// Update image list
let image_dir = image_dir.clone(); let image_dir = image_dir.clone();
let images = task::spawn_blocking(move || { let images = task::spawn_blocking(move || {
// TODO: Only update images with changed modification times // TODO: Only update images with changed modification times
@ -140,19 +186,28 @@ pub struct ImageInfo {
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct IndexTempalte { pub struct LoginTemplate {
title: String, title: String,
}
#[derive(Debug, Serialize)]
pub struct IndexTemplate {
title: String,
top_message: Option<String>,
images: Vec<ImageInfo>, images: Vec<ImageInfo>,
} }
async fn index( async fn index(
engine: TemplateEngine, engine: TemplateEngine,
State(config): State<Config>,
State(image_list_cache): State<ImageListCache>, State(image_list_cache): State<ImageListCache>,
State(secret_key): State<SecretKey>, State(secret_key): State<SecretKey>,
session: ReadableSession, session: ReadableSession,
) -> Result<impl IntoResponse, AppError> { ) -> Result<Response, AppError> {
let logged_in = session.get::<()>("logged_in").is_some(); let logged_in = session.get::<()>("logged_in").is_some();
let config = config.read().unwrap();
if logged_in { if logged_in {
let images = image_list_cache.read().unwrap(); let images = image_list_cache.read().unwrap();
@ -171,18 +226,22 @@ async fn index(
images.sort_by_key(|entry| Reverse(entry.created)); images.sort_by_key(|entry| Reverse(entry.created));
Ok(RenderHtml("index", engine, IndexTempalte { Ok(RenderHtml("index", engine, IndexTemplate {
title: "Some pictures".into(), title: config.title.clone(),
top_message: config.top_message.clone(),
images, images,
})) }).into_response())
} else { } else {
Ok(RenderHtml("login", engine, IndexTempalte { Ok(RenderHtml("login", engine, LoginTemplate {
title: "Some pictures".into(), title: config.title.clone(),
images: vec![], }).into_response())
}))
} }
} }
async fn example_config() -> &'static str {
include_str!("../config.yaml.example")
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticateForm { struct AuthenticateForm {
password: String, password: String,
@ -190,9 +249,10 @@ struct AuthenticateForm {
async fn authenticate( async fn authenticate(
mut session: WritableSession, mut session: WritableSession,
Form(form): Form<AuthenticateForm> State(config): State<Config>,
Form(form): Form<AuthenticateForm>,
) -> Redirect { ) -> Redirect {
if form.password == "testle" { if config.read().unwrap().passwords.contains(&form.password) {
session.insert("logged_in", ()).ok(); session.insert("logged_in", ()).ok();
} }
Redirect::to("/") Redirect::to("/")

View File

@ -7,6 +7,14 @@
padding: 0.7rem; padding: 0.7rem;
margin: 0; margin: 0;
} }
header {
font-size: 1.3rem;
padding: 1rem;
margin-bottom: 1rem;
background-color: #ebff7a;
border: 3px solid #b0cf00;
border-radius: 1rem;
}
main { main {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -42,10 +50,15 @@
{% endblock head %} {% endblock head %}
{% block main %} {% block main %}
{% if top_message %}
<header>
{{top_message}}
</header>
{% endif %}
<main> <main>
{% for image in images %} {% for image in images %}
<div style="aspect-ratio: {{image.width}} / {{image.height}}; flex-grow: {{10*image.width/image.height}};"> <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> </div>
{% endfor %} {% endfor %}
</main> </main>