From a4d6914f0bacbe04b8b00c01e8f1c5917a79e616 Mon Sep 17 00:00:00 2001 From: taschenuhr <29057942+taschenuhr@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:41:12 +0200 Subject: [PATCH 1/3] add option to define a custom css --- README.md | 1 + packages/backend/src/config.rs | 2 ++ packages/backend/src/main.rs | 8 ++++++-- packages/backend/src/status/mod.rs | 2 ++ packages/cli/src/shared/api.ts | 1 + packages/frontend/src/routes/+layout.svelte | 3 +++ 6 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c4a2fa50..5a2d917a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ of the notes even if it tried to. | `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable | | `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. | | `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. | +| `THEME_CUSTOM_CSS_FILE` | `""` | Path to a CSS file inside the container. When set, the file is served at `/custom.css` and loaded by the frontend, allowing full visual customisation via CSS variables or selectors. Mount the file as a volume: `-v ./my-theme.css:/custom.css:ro` and set `THEME_CUSTOM_CSS_FILE=/custom.css`. | | `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. | | `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. | ## Deployment diff --git a/packages/backend/src/config.rs b/packages/backend/src/config.rs index 91ff8cbc..79b93139 100644 --- a/packages/backend/src/config.rs +++ b/packages/backend/src/config.rs @@ -74,4 +74,6 @@ lazy_static! { .unwrap_or("true".to_string()) .parse() .unwrap(); + pub static ref THEME_CUSTOM_CSS_FILE: String = std::env::var("THEME_CUSTOM_CSS_FILE") + .unwrap_or("".to_string()); } diff --git a/packages/backend/src/main.rs b/packages/backend/src/main.rs index e738eeae..5ae2eda9 100644 --- a/packages/backend/src/main.rs +++ b/packages/backend/src/main.rs @@ -53,8 +53,12 @@ async fn main() { let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html"); let serve_dir = ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index)); - let app = Router::new() - .nest("/api", api_routes) + let mut app = Router::new() + .nest("/api", api_routes); + if !config::THEME_CUSTOM_CSS_FILE.is_empty() { + app = app.route_service("/custom.css", ServeFile::new(config::THEME_CUSTOM_CSS_FILE.as_str())); + } + let app = app .fallback_service(serve_dir) // Disabled for now, as svelte inlines scripts // .layer(middleware::from_fn(csp::add_csp_header)) diff --git a/packages/backend/src/status/mod.rs b/packages/backend/src/status/mod.rs index e15941a3..3df4fe0c 100644 --- a/packages/backend/src/status/mod.rs +++ b/packages/backend/src/status/mod.rs @@ -21,6 +21,7 @@ pub struct Status { pub theme_favicon: String, pub theme_new_note_notice: bool, pub theme_home_link: bool, + pub theme_custom_css: bool, } pub async fn get_status() -> (StatusCode, Json) { @@ -35,6 +36,7 @@ pub async fn get_status() -> (StatusCode, Json) { imprint_html: config::IMPRINT_HTML.to_string(), theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE, theme_home_link: *config::THEME_HOME_LINK, + theme_custom_css: !config::THEME_CUSTOM_CSS_FILE.is_empty(), theme_image: config::THEME_IMAGE.to_string(), theme_text: config::THEME_TEXT.to_string(), theme_page_title: config::THEME_PAGE_TITLE.to_string(), diff --git a/packages/cli/src/shared/api.ts b/packages/cli/src/shared/api.ts index f8a99367..f49f8bd4 100644 --- a/packages/cli/src/shared/api.ts +++ b/packages/cli/src/shared/api.ts @@ -121,6 +121,7 @@ export type Status = { theme_page_title: string theme_new_note_notice: boolean theme_home_link: boolean + theme_custom_css: boolean } async function status() { diff --git a/packages/frontend/src/routes/+layout.svelte b/packages/frontend/src/routes/+layout.svelte index d35c0a31..a551ac1d 100644 --- a/packages/frontend/src/routes/+layout.svelte +++ b/packages/frontend/src/routes/+layout.svelte @@ -22,6 +22,9 @@ {$status?.theme_page_title || 'cryptgeon'} + {#if $status?.theme_custom_css} + + {/if} {#await waitLocale() then _} From c0850b7207a9eda10ef8538d95ca0cd7b1adbd97 Mon Sep 17 00:00:00 2001 From: taschenuhr <29057942+taschenuhr@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:33:19 +0200 Subject: [PATCH 2/3] add option to use a custom theme SVG --- README.md | 1 + packages/backend/Cargo.toml | 1 + packages/backend/src/config.rs | 7 +++++++ packages/backend/src/main.rs | 17 +++++++++++++++++ packages/backend/src/status/mod.rs | 2 ++ packages/cli/src/shared/api.ts | 1 + packages/frontend/src/lib/views/Header.svelte | 2 ++ 7 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 5a2d917a..f434dc3b 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ of the notes even if it tried to. | `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. | | `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` | | `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable | +| `THEME_SVG` | `""` | SVG to replace the logo, rendered inline so it inherits `currentColor` and scales with CSS like the default logo. Accepts a public URL (`https://…`) or an absolute path to a file mounted into the container. Takes precedence over `THEME_IMAGE`. | | `THEME_TEXT` | `""` | Custom text for replacing the description below the logo | | `THEME_PAGE_TITLE` | `""` | Custom text the page title | | `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable | diff --git a/packages/backend/Cargo.toml b/packages/backend/Cargo.toml index 78db4be2..cb504e10 100644 --- a/packages/backend/Cargo.toml +++ b/packages/backend/Cargo.toml @@ -25,3 +25,4 @@ ring = "0.17" bs62 = "0.1" byte-unit = "4" dotenv = "0.15" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } diff --git a/packages/backend/src/config.rs b/packages/backend/src/config.rs index 79b93139..9de9ee3a 100644 --- a/packages/backend/src/config.rs +++ b/packages/backend/src/config.rs @@ -1,4 +1,7 @@ use byte_unit::Byte; +use std::sync::OnceLock; + +pub static THEME_SVG_CONTENT: OnceLock = OnceLock::new(); // Internal lazy_static! { @@ -54,6 +57,10 @@ lazy_static! { .unwrap_or("".to_string()) .parse() .unwrap(); + pub static ref THEME_SVG: String = std::env::var("THEME_SVG") + .unwrap_or("".to_string()) + .parse() + .unwrap(); pub static ref THEME_TEXT: String = std::env::var("THEME_TEXT") .unwrap_or("".to_string()) .parse() diff --git a/packages/backend/src/main.rs b/packages/backend/src/main.rs index 5ae2eda9..aceb4bac 100644 --- a/packages/backend/src/main.rs +++ b/packages/backend/src/main.rs @@ -1,4 +1,5 @@ use std::{collections::HashMap, sync::Arc}; +use reqwest; use axum::{ Router, ServiceExt, @@ -34,6 +35,22 @@ async fn main() { locks: Arc::new(Mutex::new(HashMap::new())), }; + let theme_svg = config::THEME_SVG.as_str(); + if !theme_svg.is_empty() { + let content = if theme_svg.starts_with("http://") || theme_svg.starts_with("https://") { + reqwest::get(theme_svg) + .await + .unwrap_or_else(|e| panic!("Failed to fetch THEME_SVG from {theme_svg}: {e}")) + .text() + .await + .unwrap_or_else(|e| panic!("Failed to read THEME_SVG response: {e}")) + } else { + std::fs::read_to_string(theme_svg) + .unwrap_or_else(|e| panic!("Cannot read THEME_SVG file {theme_svg}: {e}")) + }; + config::THEME_SVG_CONTENT.set(content).unwrap(); + } + if !store::can_reach_redis() { println!("cannot reach redis"); panic!("cannot reach redis"); diff --git a/packages/backend/src/status/mod.rs b/packages/backend/src/status/mod.rs index 3df4fe0c..c4c16dfb 100644 --- a/packages/backend/src/status/mod.rs +++ b/packages/backend/src/status/mod.rs @@ -16,6 +16,7 @@ pub struct Status { pub imprint_html: String, // Theme pub theme_image: String, + pub theme_svg: String, pub theme_text: String, pub theme_page_title: String, pub theme_favicon: String, @@ -38,6 +39,7 @@ pub async fn get_status() -> (StatusCode, Json) { theme_home_link: *config::THEME_HOME_LINK, theme_custom_css: !config::THEME_CUSTOM_CSS_FILE.is_empty(), theme_image: config::THEME_IMAGE.to_string(), + theme_svg: config::THEME_SVG_CONTENT.get().cloned().unwrap_or_default(), theme_text: config::THEME_TEXT.to_string(), theme_page_title: config::THEME_PAGE_TITLE.to_string(), theme_favicon: config::THEME_FAVICON.to_string(), diff --git a/packages/cli/src/shared/api.ts b/packages/cli/src/shared/api.ts index f49f8bd4..46280aa1 100644 --- a/packages/cli/src/shared/api.ts +++ b/packages/cli/src/shared/api.ts @@ -116,6 +116,7 @@ export type Status = { imprint_url: string imprint_html: string theme_image: string + theme_svg: string theme_text: string theme_favicon: string theme_page_title: string diff --git a/packages/frontend/src/lib/views/Header.svelte b/packages/frontend/src/lib/views/Header.svelte index dd6330b8..4bdf099e 100644 --- a/packages/frontend/src/lib/views/Header.svelte +++ b/packages/frontend/src/lib/views/Header.svelte @@ -10,6 +10,8 @@ {#if $status === null} + {:else if $status.theme_svg} + {@html $status.theme_svg} {:else if $status.theme_image} logo {:else} From 7915ca58b0af5affe7d21ab9f77d3413a8531b76 Mon Sep 17 00:00:00 2001 From: taschenuhr <29057942+taschenuhr@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:13:25 +0200 Subject: [PATCH 3/3] allow base64 encoded theme images and remove the duplicated environment variable --- README.md | 3 +- packages/backend/Cargo.toml | 1 + packages/backend/src/config.rs | 7 +-- packages/backend/src/main.rs | 90 +++++++++++++++++++++++++----- packages/backend/src/status/mod.rs | 4 +- 5 files changed, 81 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index f434dc3b..dd00fa28 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,7 @@ of the notes even if it tried to. | `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. | | `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. | | `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` | -| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable | -| `THEME_SVG` | `""` | SVG to replace the logo, rendered inline so it inherits `currentColor` and scales with CSS like the default logo. Accepts a public URL (`https://…`) or an absolute path to a file mounted into the container. Takes precedence over `THEME_IMAGE`. | +| `THEME_IMAGE` | `""` | Custom image to replace the logo. Accepts a public URL (`https://…`), an absolute path to a file mounted into the container, or a base64-encoded data URL (`data:image/png;base64,…`). SVG content (detected via content-type, file extension, or markup) is rendered inline so it inherits `currentColor`; other formats are rendered as a regular ``. | | `THEME_TEXT` | `""` | Custom text for replacing the description below the logo | | `THEME_PAGE_TITLE` | `""` | Custom text the page title | | `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable | diff --git a/packages/backend/Cargo.toml b/packages/backend/Cargo.toml index cb504e10..639e5f9e 100644 --- a/packages/backend/Cargo.toml +++ b/packages/backend/Cargo.toml @@ -26,3 +26,4 @@ bs62 = "0.1" byte-unit = "4" dotenv = "0.15" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +base64 = "0.22" diff --git a/packages/backend/src/config.rs b/packages/backend/src/config.rs index 9de9ee3a..dca18db6 100644 --- a/packages/backend/src/config.rs +++ b/packages/backend/src/config.rs @@ -1,7 +1,8 @@ use byte_unit::Byte; use std::sync::OnceLock; -pub static THEME_SVG_CONTENT: OnceLock = OnceLock::new(); +pub static THEME_RESOLVED_SVG: OnceLock = OnceLock::new(); +pub static THEME_RESOLVED_IMAGE: OnceLock = OnceLock::new(); // Internal lazy_static! { @@ -57,10 +58,6 @@ lazy_static! { .unwrap_or("".to_string()) .parse() .unwrap(); - pub static ref THEME_SVG: String = std::env::var("THEME_SVG") - .unwrap_or("".to_string()) - .parse() - .unwrap(); pub static ref THEME_TEXT: String = std::env::var("THEME_TEXT") .unwrap_or("".to_string()) .parse() diff --git a/packages/backend/src/main.rs b/packages/backend/src/main.rs index aceb4bac..89be69c3 100644 --- a/packages/backend/src/main.rs +++ b/packages/backend/src/main.rs @@ -1,4 +1,5 @@ use std::{collections::HashMap, sync::Arc}; +use base64::Engine; use reqwest; use axum::{ @@ -27,6 +28,79 @@ mod note; mod status; mod store; +fn is_svg_content(s: &str) -> bool { + let t = s.trim_start(); + t.starts_with(" &'static str { + let lower = path.to_lowercase(); + if lower.ends_with(".png") { "image/png" } + else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { "image/jpeg" } + else if lower.ends_with(".gif") { "image/gif" } + else if lower.ends_with(".webp") { "image/webp" } + else if lower.ends_with(".ico") { "image/x-icon" } + else if lower.ends_with(".bmp") { "image/bmp" } + else { "application/octet-stream" } +} + +async fn resolve_theme_image(value: &str) { + if value.is_empty() { + return; + } + + if let Some(rest) = value.strip_prefix("data:") { + // Base64 or raw data URL: data:[mediatype][;base64], + let (header, data) = rest.split_once(',') + .unwrap_or_else(|| panic!("Invalid data URL format in THEME_IMAGE")); + if header.contains("svg") { + let svg = if header.contains(";base64") { + let bytes = base64::engine::general_purpose::STANDARD.decode(data) + .unwrap_or_else(|e| panic!("Invalid base64 in THEME_IMAGE: {e}")); + String::from_utf8(bytes) + .unwrap_or_else(|e| panic!("Invalid UTF-8 in THEME_IMAGE SVG data: {e}")) + } else { + data.to_string() + }; + config::THEME_RESOLVED_SVG.set(svg).unwrap(); + } else { + config::THEME_RESOLVED_IMAGE.set(value.to_string()).unwrap(); + } + } else if value.starts_with("http://") || value.starts_with("https://") { + let response = reqwest::get(value) + .await + .unwrap_or_else(|e| panic!("Failed to fetch THEME_IMAGE from {value}: {e}")); + let is_svg_ct = response.headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|ct| ct.contains("svg")) + .unwrap_or(false); + let content = response.text() + .await + .unwrap_or_else(|e| panic!("Failed to read THEME_IMAGE response: {e}")); + if is_svg_ct || is_svg_content(&content) { + config::THEME_RESOLVED_SVG.set(content).unwrap(); + } else { + config::THEME_RESOLVED_IMAGE.set(value.to_string()).unwrap(); + } + } else { + // Local file path + let bytes = std::fs::read(value) + .unwrap_or_else(|e| panic!("Cannot read THEME_IMAGE file {value}: {e}")); + let is_svg = value.to_lowercase().ends_with(".svg") + || std::str::from_utf8(&bytes).map(is_svg_content).unwrap_or(false); + if is_svg { + let content = String::from_utf8(bytes) + .unwrap_or_else(|e| panic!("Invalid UTF-8 in THEME_IMAGE SVG file: {e}")); + config::THEME_RESOLVED_SVG.set(content).unwrap(); + } else { + let mime = mime_from_path(value); + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + config::THEME_RESOLVED_IMAGE.set(format!("data:{mime};base64,{encoded}")).unwrap(); + } + } +} + #[tokio::main] async fn main() { dotenv().ok(); @@ -35,21 +109,7 @@ async fn main() { locks: Arc::new(Mutex::new(HashMap::new())), }; - let theme_svg = config::THEME_SVG.as_str(); - if !theme_svg.is_empty() { - let content = if theme_svg.starts_with("http://") || theme_svg.starts_with("https://") { - reqwest::get(theme_svg) - .await - .unwrap_or_else(|e| panic!("Failed to fetch THEME_SVG from {theme_svg}: {e}")) - .text() - .await - .unwrap_or_else(|e| panic!("Failed to read THEME_SVG response: {e}")) - } else { - std::fs::read_to_string(theme_svg) - .unwrap_or_else(|e| panic!("Cannot read THEME_SVG file {theme_svg}: {e}")) - }; - config::THEME_SVG_CONTENT.set(content).unwrap(); - } + resolve_theme_image(config::THEME_IMAGE.as_str()).await; if !store::can_reach_redis() { println!("cannot reach redis"); diff --git a/packages/backend/src/status/mod.rs b/packages/backend/src/status/mod.rs index c4c16dfb..45dc11c2 100644 --- a/packages/backend/src/status/mod.rs +++ b/packages/backend/src/status/mod.rs @@ -38,8 +38,8 @@ pub async fn get_status() -> (StatusCode, Json) { theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE, theme_home_link: *config::THEME_HOME_LINK, theme_custom_css: !config::THEME_CUSTOM_CSS_FILE.is_empty(), - theme_image: config::THEME_IMAGE.to_string(), - theme_svg: config::THEME_SVG_CONTENT.get().cloned().unwrap_or_default(), + theme_image: config::THEME_RESOLVED_IMAGE.get().cloned().unwrap_or_default(), + theme_svg: config::THEME_RESOLVED_SVG.get().cloned().unwrap_or_default(), theme_text: config::THEME_TEXT.to_string(), theme_page_title: config::THEME_PAGE_TITLE.to_string(), theme_favicon: config::THEME_FAVICON.to_string(),