diff --git a/Cargo.lock b/Cargo.lock index 237bf9c..ac7fbca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,6 +682,12 @@ dependencies = [ "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]] name = "headers" version = "0.3.8" @@ -868,12 +874,23 @@ dependencies = [ "kamadak-exif", "minijinja", "serde", + "serde_yaml", "tokio", "tower-http", "tracing", "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]] name = "inout" version = "0.1.3" @@ -1358,6 +1375,19 @@ dependencies = [ "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]] name = "sha1" version = "0.10.5" @@ -1741,6 +1771,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1865806a559042e51ab5414598446a5871b561d21b6764f2eabb0dd481d880a6" + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f2c2177..6577625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..2b531c6 --- /dev/null +++ b/config.yaml.example @@ -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" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7eb3596 --- /dev/null +++ b/src/config.rs @@ -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, + pub passwords: Vec, +} + +pub fn read_from_file(config_file: &Path) -> Result { + let file = File::open(config_file)?; + Ok(serde_yaml::from_reader(file)?) +} diff --git a/src/main.rs b/src/main.rs index 9080cf7..53284da 100644 --- a/src/main.rs +++ b/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, RwLock}, collections::HashMap}; 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::MemoryStore, 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}; @@ -15,6 +15,7 @@ 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,7 +23,7 @@ static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; // TODO // * Polish the css -// * Add configuration file +// * Brute-force protection / rate-limiting // * Support for headlines // * Image cache cleanup @@ -31,9 +32,11 @@ type ImageDir = PathBuf; type SecretKey = [u8; 64]; type ImageListCache = Arc>>; type ImageCache = Arc, Vec)>>>; +type Config = Arc>; #[derive(Clone, extract::FromRef)] struct ApplicationState { + config: Config, engine: TemplateEngine, image_cache: ImageCache, image_dir: ImageDir, @@ -60,15 +63,19 @@ async fn main() { 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_image_list_cache_job( + tokio::spawn(update_config_and_image_list_cache_job( image_dir.clone(), - image_list_cache.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(); @@ -79,6 +86,7 @@ async fn main() { 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() @@ -87,6 +95,7 @@ async fn main() { ) .layer(session_layer) .with_state(ApplicationState { + config, engine: Engine::from(jinja), image_cache, image_dir, @@ -102,13 +111,50 @@ async fn main() { .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)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 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(); + } + + // Update image list let image_dir = image_dir.clone(); let images = task::spawn_blocking(move || { // TODO: Only update images with changed modification times @@ -140,19 +186,28 @@ 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, images: Vec, } async fn index( engine: TemplateEngine, + State(config): State, State(image_list_cache): State, State(secret_key): State, session: ReadableSession, -) -> Result { +) -> Result { let logged_in = session.get::<()>("logged_in").is_some(); + let config = config.read().unwrap(); + if logged_in { let images = image_list_cache.read().unwrap(); @@ -171,18 +226,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, @@ -190,9 +249,10 @@ struct AuthenticateForm { async fn authenticate( mut session: WritableSession, - Form(form): Form + State(config): State, + Form(form): Form, ) -> Redirect { - if form.password == "testle" { + if config.read().unwrap().passwords.contains(&form.password) { session.insert("logged_in", ()).ok(); } Redirect::to("/") diff --git a/templates/index.html b/templates/index.html index 6415ca4..705dc6a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,6 +7,14 @@ padding: 0.7rem; margin: 0; } + header { + 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 +50,15 @@ {% endblock head %} {% block main %} + {% if top_message %} +
+ {{top_message}} +
+ {% endif %}
{% for image in images %}
- YOU SHALL NOT SAVE THIS IMAGE! + Could not load image
{% endfor %}