diff --git a/Cargo.lock b/Cargo.lock index 40aa129..93f64e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,6 +205,30 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http 1.0.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -354,6 +378,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -421,6 +456,15 @@ dependencies = [ "syn 2.0.52", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -743,6 +787,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -1083,6 +1136,7 @@ dependencies = [ "ammonia", "async-stream", "axum", + "axum-extra", "base64 0.22.0", "bytes", "chrono", @@ -1090,6 +1144,7 @@ dependencies = [ "eyre", "fend-core", "futures", + "html-escape", "maud", "numbat", "rand", @@ -1101,6 +1156,8 @@ dependencies = [ "tokio", "tokio-stream", "toml", + "tower", + "tower-http", "tracing", "tracing-subscriber", "url", @@ -1160,6 +1217,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-format" version = "0.4.4" @@ -1425,6 +1488,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -2095,6 +2164,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2219,6 +2319,23 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.4.2", + "bytes", + "http 1.0.0", + "http-body 1.0.0", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", ] [[package]] @@ -2239,6 +2356,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2373,6 +2491,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ee5a4df..0c0c3f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,8 @@ build = "src/build.rs" [dependencies] ammonia = "3.3.0" async-stream = "0.3.5" -axum = { version = "0.7.4", default-features = false, features = [ - "tokio", - "http1", - "http2", - "query", - "json", -] } +axum = { version = "0.7.4", default-features = false, features = ["tokio", "http1", "http2", "query", "json", "form"] } +axum-extra = { version = "0.9.3", features = ["cookie"] } base64 = "0.22.0" bytes = "1.5.0" chrono = "0.4.35" @@ -23,6 +18,7 @@ chrono-tz = { version = "0.8.6", features = ["case-insensitive"] } eyre = "0.6.12" fend-core = "1.4.5" futures = "0.3.30" +html-escape = "0.2.13" maud = "0.26.0" numbat = "1.11.0" rand = "0.8.5" @@ -40,6 +36,8 @@ serde_json = { version = "1.0.114", features = ["preserve_order"] } tokio = { version = "1.36.0", features = ["rt", "macros"] } tokio-stream = "0.1.15" toml = { version = "0.8.12", default-features = false, features = ["parse"] } +tower = "0.4.13" +tower-http = "0.5.2" tracing = "0.1.40" tracing-subscriber = "0.3.18" url = "2.5.0" diff --git a/src/config.rs b/src/config.rs index 1808fab..56ac643 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,25 @@ -use std::{collections::HashMap, fs, net::SocketAddr, path::Path, sync::LazyLock}; +use std::{ + collections::HashMap, + fs, + net::SocketAddr, + path::Path, + sync::{Arc, LazyLock}, +}; use serde::Deserialize; use tracing::info; use crate::engines::Engine; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Config { pub bind: SocketAddr, /// Whether the JSON API should be accessible. pub api: bool, pub ui: UiConfig, pub image_search: ImageSearchConfig, - pub engines: EnginesConfig, + // wrapped in an arc to make Config cheaper to clone + pub engines: Arc, } #[derive(Deserialize, Debug)] @@ -31,23 +38,31 @@ impl Config { self.ui.overlay(partial.ui.unwrap_or_default()); self.image_search .overlay(partial.image_search.unwrap_or_default()); - self.engines.overlay(partial.engines.unwrap_or_default()); + if let Some(partial_engines) = partial.engines { + let mut engines = self.engines.as_ref().clone(); + engines.overlay(partial_engines); + self.engines = Arc::new(engines); + } } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct UiConfig { pub show_engine_list_separator: bool, pub show_version_info: bool, + /// Settings are always accessible anyways, this just controls whether the + /// link to them in the index page is visible. + pub show_settings_link: bool, pub site_name: String, - pub stylesheet_url: Option, - pub stylesheet_str: Option, + pub stylesheet_url: String, + pub stylesheet_str: String, } #[derive(Deserialize, Debug, Default)] pub struct PartialUiConfig { pub show_engine_list_separator: Option, pub show_version_info: Option, + pub show_settings_link: Option, pub site_name: Option, pub stylesheet_url: Option, pub stylesheet_str: Option, @@ -59,13 +74,20 @@ impl UiConfig { .show_engine_list_separator .unwrap_or(self.show_engine_list_separator); self.show_version_info = partial.show_version_info.unwrap_or(self.show_version_info); + self.show_settings_link = partial + .show_settings_link + .unwrap_or(self.show_settings_link); self.site_name = partial.site_name.unwrap_or(self.site_name.clone()); - self.stylesheet_url = partial.stylesheet_url.or(self.stylesheet_url.clone()); - self.stylesheet_str = partial.stylesheet_str.or(self.stylesheet_str.clone()); + self.stylesheet_url = partial + .stylesheet_url + .unwrap_or(self.stylesheet_url.clone()); + self.stylesheet_str = partial + .stylesheet_str + .unwrap_or(self.stylesheet_str.clone()); } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ImageSearchConfig { pub enabled: bool, pub show_engines: bool, @@ -87,7 +109,7 @@ impl ImageSearchConfig { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ImageProxyConfig { /// Whether we should proxy remote images through our server. This is mostly /// a privacy feature. @@ -109,7 +131,7 @@ impl ImageProxyConfig { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct EnginesConfig { pub map: HashMap, } @@ -152,7 +174,7 @@ impl EnginesConfig { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct EngineConfig { pub enabled: bool, /// The priority of this engine relative to the other engines. @@ -206,8 +228,9 @@ impl Default for Config { show_engine_list_separator: false, show_version_info: false, site_name: "metasearch".to_string(), - stylesheet_url: None, - stylesheet_str: None, + show_settings_link: true, + stylesheet_url: "".to_string(), + stylesheet_str: "".to_string(), }, image_search: ImageSearchConfig { enabled: false, @@ -217,7 +240,7 @@ impl Default for Config { max_download_size: 10_000_000, }, }, - engines: EnginesConfig::default(), + engines: Arc::new(EnginesConfig::default()), } } } diff --git a/src/web/assets/script.js b/src/web/assets/script.js index a74377d..23c0c64 100644 --- a/src/web/assets/script.js +++ b/src/web/assets/script.js @@ -1,165 +1,203 @@ const searchInputEl = document.getElementById("search-input"); -// add an element with search suggestions after the search input -const suggestionsEl = document.createElement("div"); -suggestionsEl.id = "search-input-suggestions"; -suggestionsEl.style.visibility = "hidden"; -searchInputEl.insertAdjacentElement("afterend", suggestionsEl); +if (searchInputEl) { + // add an element with search suggestions after the search input + const suggestionsEl = document.createElement("div"); + suggestionsEl.id = "search-input-suggestions"; + suggestionsEl.style.visibility = "hidden"; + searchInputEl.insertAdjacentElement("afterend", suggestionsEl); -let lastValue = ""; -let nextQueryId = 0; -let lastLoadedQueryId = -1; -async function updateSuggestions() { - const value = searchInputEl.value; + let lastValue = ""; + let nextQueryId = 0; + let lastLoadedQueryId = -1; + async function updateSuggestions() { + const value = searchInputEl.value; - if (value === "") { - suggestionsEl.style.visibility = "hidden"; + if (value === "") { + suggestionsEl.style.visibility = "hidden"; + nextQueryId++; + lastLoadedQueryId = nextQueryId; + return; + } + + if (value === lastValue) { + suggestionsEl.style.visibility = "visible"; + return; + } + lastValue = value; + + const thisQueryId = nextQueryId; nextQueryId++; - lastLoadedQueryId = nextQueryId; - return; + + const res = await fetch( + `/autocomplete?q=${encodeURIComponent(value)}` + ).then((res) => res.json()); + const options = res[1]; + + // this makes sure we don't load suggestions out of order + if (thisQueryId < lastLoadedQueryId) { + return; + } + lastLoadedQueryId = thisQueryId; + + renderSuggestions(options); } - if (value === lastValue) { + function renderSuggestions(options) { + if (options.length === 0) { + suggestionsEl.style.visibility = "hidden"; + return; + } + suggestionsEl.style.visibility = "visible"; - return; - } - lastValue = value; + suggestionsEl.innerHTML = ""; + options.forEach((option) => { + const optionEl = document.createElement("div"); + optionEl.textContent = option; + optionEl.className = "search-input-suggestion"; + suggestionsEl.appendChild(optionEl); - const thisQueryId = nextQueryId; - nextQueryId++; - - const res = await fetch(`/autocomplete?q=${encodeURIComponent(value)}`).then( - (res) => res.json() - ); - const options = res[1]; - - // this makes sure we don't load suggestions out of order - if (thisQueryId < lastLoadedQueryId) { - return; - } - lastLoadedQueryId = thisQueryId; - - renderSuggestions(options); -} - -function renderSuggestions(options) { - if (options.length === 0) { - suggestionsEl.style.visibility = "hidden"; - return; - } - - suggestionsEl.style.visibility = "visible"; - suggestionsEl.innerHTML = ""; - options.forEach((option) => { - const optionEl = document.createElement("div"); - optionEl.textContent = option; - optionEl.className = "search-input-suggestion"; - suggestionsEl.appendChild(optionEl); - - optionEl.addEventListener("mousedown", () => { - searchInputEl.value = option; - searchInputEl.focus(); - searchInputEl.form.submit(); + optionEl.addEventListener("mousedown", () => { + searchInputEl.value = option; + searchInputEl.focus(); + searchInputEl.form.submit(); + }); }); + } + + let focusedSuggestionIndex = -1; + let focusedSuggestionEl = null; + + function clearFocusedSuggestion() { + if (focusedSuggestionEl) { + focusedSuggestionEl.classList.remove("focused"); + focusedSuggestionEl = null; + focusedSuggestionIndex = -1; + } + } + + function focusSelectionIndex(index) { + clearFocusedSuggestion(); + focusedSuggestionIndex = index; + focusedSuggestionEl = suggestionsEl.children[focusedSuggestionIndex]; + focusedSuggestionEl.classList.add("focused"); + searchInputEl.value = focusedSuggestionEl.textContent; + } + + document.addEventListener("keydown", (e) => { + // if it's focused then use different keybinds + if (searchInputEl.matches(":focus")) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (focusedSuggestionIndex === -1) { + focusSelectionIndex(0); + } else if (focusedSuggestionIndex < suggestionsEl.children.length - 1) { + focusSelectionIndex(focusedSuggestionIndex + 1); + } else { + focusSelectionIndex(0); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (focusedSuggestionIndex === -1) { + focusSelectionIndex(suggestionsEl.children.length - 1); + } else if (focusedSuggestionIndex > 0) { + focusSelectionIndex(focusedSuggestionIndex - 1); + } else { + focusSelectionIndex(suggestionsEl.children.length - 1); + } + } + + return; + } + + // if the currently selected element is not the search bar and is contenteditable, don't do anything + const focusedEl = document.querySelector(":focus"); + if ( + focusedEl && + (focusedEl.tagName.toLowerCase() == "input" || + focusedEl.tagName.toLowerCase() == "textarea" || + focusedEl.getAttribute("contenteditable") !== null) + ) + return; + + // if the user starts typing but they don't have focus on the input, focus it + + // no modifier keys + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { + return; + } + // must be a letter or number + if (e.key.match(/^[a-z0-9]$/i)) { + searchInputEl.focus(); + } + // right arrow key focuses it at the end + else if (e.key === "ArrowRight") { + searchInputEl.focus(); + searchInputEl.setSelectionRange( + searchInputEl.value.length, + searchInputEl.value.length + ); + } + // left arrow key focuses it at the beginning + else if (e.key === "ArrowLeft") { + searchInputEl.focus(); + searchInputEl.setSelectionRange(0, 0); + } + // backspace key focuses it at the end + else if (e.key === "Backspace") { + searchInputEl.focus(); + searchInputEl.setSelectionRange( + searchInputEl.value.length, + searchInputEl.value.length + ); + } + }); + + // update the input suggestions on input + searchInputEl.addEventListener("input", () => { + clearFocusedSuggestion(); + updateSuggestions(); + }); + // and when they click suggestions + searchInputEl.addEventListener("click", updateSuggestions); + // on unfocus hide the suggestions + searchInputEl.addEventListener("blur", (e) => { + suggestionsEl.style.visibility = "hidden"; }); } -let focusedSuggestionIndex = -1; -let focusedSuggestionEl = null; - -function clearFocusedSuggestion() { - if (focusedSuggestionEl) { - focusedSuggestionEl.classList.remove("focused"); - focusedSuggestionEl = null; - focusedSuggestionIndex = -1; - } -} - -function focusSelectionIndex(index) { - clearFocusedSuggestion(); - focusedSuggestionIndex = index; - focusedSuggestionEl = suggestionsEl.children[focusedSuggestionIndex]; - focusedSuggestionEl.classList.add("focused"); - searchInputEl.value = focusedSuggestionEl.textContent; -} - -document.addEventListener("keydown", (e) => { - // if it's focused then use different keybinds - if (searchInputEl.matches(":focus")) { - if (e.key === "ArrowDown") { +const customCssEl = document.getElementById("custom-css"); +if (customCssEl) { + // tab to indent + // https://stackoverflow.com/a/6637396 + customCssEl.addEventListener("keydown", (e) => { + if (e.key == "Tab") { e.preventDefault(); - if (focusedSuggestionIndex === -1) { - focusSelectionIndex(0); - } else if (focusedSuggestionIndex < suggestionsEl.children.length - 1) { - focusSelectionIndex(focusedSuggestionIndex + 1); - } else { - focusSelectionIndex(0); - } - } else if (e.key === "ArrowUp") { - e.preventDefault(); - if (focusedSuggestionIndex === -1) { - focusSelectionIndex(suggestionsEl.children.length - 1); - } else if (focusedSuggestionIndex > 0) { - focusSelectionIndex(focusedSuggestionIndex - 1); - } else { - focusSelectionIndex(suggestionsEl.children.length - 1); - } + var start = customCssEl.selectionStart; + var end = customCssEl.selectionEnd; + customCssEl.value = + customCssEl.value.substring(0, start) + + "\t" + + customCssEl.value.substring(end); + customCssEl.selectionStart = customCssEl.selectionEnd = start + 1; } + }); - return; - } + // ctrl+enter anywhere on the page to submit + const saveEl = document.getElementById("save-settings-button"); + document.addEventListener("keydown", (e) => { + if (e.key == "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + console.log("click"); + saveEl.click(); + } + }); - // if the currently selected element is not the search bar and is contenteditable, don't do anything - const focusedEl = document.querySelector(":focus"); - if ( - focusedEl && - (focusedEl.tagName.toLowerCase() == "input" || - focusedEl.tagName.toLowerCase() == "textarea" || - focusedEl.getAttribute("contenteditable") !== null) - ) - return; - - // if the user starts typing but they don't have focus on the input, focus it - - // no modifier keys - if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { - return; - } - // must be a letter or number - if (e.key.match(/^[a-z0-9]$/i)) { - searchInputEl.focus(); - } - // right arrow key focuses it at the end - else if (e.key === "ArrowRight") { - searchInputEl.focus(); - searchInputEl.setSelectionRange( - searchInputEl.value.length, - searchInputEl.value.length - ); - } - // left arrow key focuses it at the beginning - else if (e.key === "ArrowLeft") { - searchInputEl.focus(); - searchInputEl.setSelectionRange(0, 0); - } - // backspace key focuses it at the end - else if (e.key === "Backspace") { - searchInputEl.focus(); - searchInputEl.setSelectionRange( - searchInputEl.value.length, - searchInputEl.value.length - ); - } -}); - -// update the input suggestions on input -searchInputEl.addEventListener("input", () => { - clearFocusedSuggestion(); - updateSuggestions(); -}); -// and when they click suggestions -searchInputEl.addEventListener("click", updateSuggestions); -// on unfocus hide the suggestions -searchInputEl.addEventListener("blur", (e) => { - suggestionsEl.style.visibility = "hidden"; -}); + // save whether the details are open or not + const customCssDetailsEl = document.getElementById("custom-css-details"); + const customCssDetailsOpen = localStorage.getItem("custom-css-details-open"); + if (customCssDetailsOpen === "true") customCssDetailsEl.open = true; + customCssDetailsEl.addEventListener("toggle", () => { + localStorage.setItem("custom-css-details-open", customCssDetailsEl.open); + }); +} diff --git a/src/web/assets/style.css b/src/web/assets/style.css index 427d8cb..05a32ee 100644 --- a/src/web/assets/style.css +++ b/src/web/assets/style.css @@ -40,12 +40,19 @@ body { line-height: 1.2; height: 100%; } + +.settings-link { + position: absolute; + top: 1em; + right: 1em; +} .version-info { position: absolute; - bottom: 16px; - right: 16px; + bottom: 1em; + right: 1em; } -.results-container { + +.main-container { /* enough space for the infobox */ max-width: 73.5rem; margin: 0 auto; @@ -62,17 +69,19 @@ main { /* image search uses 100% width */ max-width: 100%; } -.results-container.search-images { +.main-container.search-images { max-width: none; } @media screen and (max-width: 74rem) { /* small screens */ - .results-container { + .main-container { margin: 0 auto; max-width: 40rem; } } -input { +input, +textarea, +select { font-family: monospace; background-color: var(--bg-2); color: var(--fg-1); @@ -107,7 +116,7 @@ blockquote { } /* index page */ -.main-container { +.main-container.index-page { display: flex; flex-direction: column; min-height: 100%; @@ -122,6 +131,22 @@ h1 { margin-top: 0; } +/* settings page */ +.settings-page .back-to-index-button { + bottom: 0.5em; + position: relative; +} +.settings-form select { + display: block; +} +#save-settings-button { + margin-top: 1em; + display: block; +} +#custom-css { + tab-size: 2; +} + /* header */ .search-form { margin-bottom: 1rem; diff --git a/src/web/autocomplete.rs b/src/web/autocomplete.rs index efad3cb..0409966 100644 --- a/src/web/autocomplete.rs +++ b/src/web/autocomplete.rs @@ -1,18 +1,13 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; +use axum::{extract::Query, http::StatusCode, response::IntoResponse, Extension, Json}; use tracing::error; use crate::{config::Config, engines}; pub async fn route( Query(params): Query>, - State(config): State>, + Extension(config): Extension, ) -> impl IntoResponse { let query = params .get("q") diff --git a/src/web/image_proxy.rs b/src/web/image_proxy.rs index 22f1862..9b8d668 100644 --- a/src/web/image_proxy.rs +++ b/src/web/image_proxy.rs @@ -1,9 +1,10 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use axum::{ - extract::{Query, State}, + extract::Query, http::StatusCode, response::{IntoResponse, Response}, + Extension, }; use tracing::error; @@ -11,7 +12,7 @@ use crate::{config::Config, engines}; pub async fn route( Query(params): Query>, - State(config): State>, + Extension(config): Extension, ) -> Response { let image_search_config = &config.image_search; let proxy_config = &image_search_config.proxy; diff --git a/src/web/index.rs b/src/web/index.rs index 53b11c9..0856ee8 100644 --- a/src/web/index.rs +++ b/src/web/index.rs @@ -1,64 +1,49 @@ -use std::sync::Arc; - -use axum::{extract::State, http::header, response::IntoResponse}; +use axum::{http::header, response::IntoResponse, Extension}; use maud::{html, PreEscaped, DOCTYPE}; -use crate::config::Config; +use crate::{config::Config, web::head_html}; const BASE_COMMIT_URL: &str = "https://github.com/mat-1/metasearch2/commit/"; const VERSION: &str = std::env!("CARGO_PKG_VERSION"); const COMMIT_HASH: &str = std::env!("GIT_HASH"); const COMMIT_HASH_SHORT: &str = std::env!("GIT_HASH_SHORT"); -pub async fn index(State(config): State>) -> impl IntoResponse { - let mut html = String::new(); - html.push_str( - &html! { - (PreEscaped("\n")) - (DOCTYPE) - html lang="en" { - head { - meta charset="UTF-8"; - meta name="viewport" content="width=device-width, initial-scale=1.0"; - title { {(config.ui.site_name)} } - link rel="stylesheet" href="/style.css"; - @if let Some(stylesheet_url) = &config.ui.stylesheet_url { - link rel="stylesheet" href=(stylesheet_url); - } - @if let Some(stylesheet_str) = &config.ui.stylesheet_str { - link rel="stylesheet" href=(stylesheet_str); - } - script src="/script.js" defer {} - link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml"; +pub async fn get(Extension(config): Extension) -> impl IntoResponse { + let html = html! { + (PreEscaped("\n")) + (DOCTYPE) + html lang="en" { + {(head_html(None, &config))} + body { + @if config.ui.show_settings_link { + a.settings-link href="/settings" { "Settings" } } - body { - div."main-container" { - h1 { {(config.ui.site_name)} } - form."search-form" action="/search" method="get" { - input type="text" name="q" placeholder="Search" id="search-input" autofocus onfocus="this.select()" autocomplete="off"; - input type="submit" value="Search"; - } + div.main-container.index-page { + h1 { {(config.ui.site_name)} } + form.search-form action="/search" method="get" { + input type="text" name="q" placeholder="Search" id="search-input" autofocus onfocus="this.select()" autocomplete="off"; + input type="submit" value="Search"; } - @if config.ui.show_version_info { - span."version-info" { - @if COMMIT_HASH == "unknown" || COMMIT_HASH_SHORT == "unknown" { - "Version " - (VERSION) - } else { - "Version " - (VERSION) - " (" - a href=(format!("{BASE_COMMIT_URL}{COMMIT_HASH}")) { (COMMIT_HASH_SHORT) } - ")" - } + } + @if config.ui.show_version_info { + span.version-info { + @if COMMIT_HASH == "unknown" || COMMIT_HASH_SHORT == "unknown" { + "Version " + (VERSION) + } else { + "Version " + (VERSION) + " (" + a href=(format!("{BASE_COMMIT_URL}{COMMIT_HASH}")) { (COMMIT_HASH_SHORT) } + ")" } } } - } + } - .into_string(), - ); + } + .into_string(); ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html) } diff --git a/src/web/mod.rs b/src/web/mod.rs index e31a26c..34ad5f5 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,16 +1,22 @@ -pub mod autocomplete; +mod autocomplete; mod image_proxy; -pub mod index; -pub mod opensearch; -pub mod search; +mod index; +mod opensearch; +mod search; +mod settings; use std::{convert::Infallible, net::SocketAddr, sync::Arc}; use axum::{ - http::header, - routing::{get, MethodRouter}, + extract::{Request, State}, + http::{header, StatusCode}, + middleware::{self, Next}, + response::Response, + routing::{get, post, MethodRouter}, Router, }; +use axum_extra::extract::CookieJar; +use maud::{html, Markup, PreEscaped}; use tracing::info; use crate::config::Config; @@ -18,6 +24,8 @@ use crate::config::Config; pub async fn run(config: Config) { let bind_addr = config.bind; + let config = Arc::new(config); + fn static_route( content: &'static str, content_type: &'static str, @@ -30,7 +38,10 @@ pub async fn run(config: Config) { } let app = Router::new() - .route("/", get(index::index)) + .route("/", get(index::get)) + .route("/search", get(search::get)) + .route("/settings", get(settings::get)) + .route("/settings", post(settings::post)) .route( "/style.css", static_route(include_str!("assets/style.css"), "text/css; charset=utf-8"), @@ -57,10 +68,13 @@ pub async fn run(config: Config) { ), ) .route("/opensearch.xml", get(opensearch::route)) - .route("/search", get(search::route)) .route("/autocomplete", get(autocomplete::route)) .route("/image-proxy", get(image_proxy::route)) - .with_state(Arc::new(config)); + .layer(middleware::from_fn_with_state( + config.clone(), + config_middleware, + )) + .with_state(config); info!("Listening on http://{bind_addr}"); @@ -72,3 +86,52 @@ pub async fn run(config: Config) { .await .unwrap(); } + +async fn config_middleware( + State(config): State>, + cookies: CookieJar, + mut req: Request, + next: Next, +) -> Result { + let mut config = config.clone().as_ref().clone(); + + fn set_from_cookie(config: &mut String, cookies: &CookieJar, name: &str) { + if let Some(cookie) = cookies.get(name) { + let value = cookie.value(); + *config = value.to_string(); + } + } + + set_from_cookie(&mut config.ui.stylesheet_url, &cookies, "stylesheet-url"); + set_from_cookie(&mut config.ui.stylesheet_str, &cookies, "stylesheet-str"); + + // modify the state + req.extensions_mut().insert(config); + + Ok(next.run(req).await) +} + +pub fn head_html(title: Option<&str>, config: &Config) -> Markup { + html! { + head { + meta charset="UTF-8"; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + title { + @if let Some(title) = title { + { (title) } + { " - " } + } + {(config.ui.site_name)} + } + link rel="stylesheet" href="/style.css"; + @if !config.ui.stylesheet_url.is_empty() { + link rel="stylesheet" href=(config.ui.stylesheet_url); + } + @if !config.ui.stylesheet_str.is_empty() { + style { (PreEscaped(html_escape::encode_style(&config.ui.stylesheet_str))) } + } + script src="/script.js" defer {} + link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml"; + } + } +} diff --git a/src/web/search.rs b/src/web/search.rs index 2750f34..1777cba 100644 --- a/src/web/search.rs +++ b/src/web/search.rs @@ -1,15 +1,15 @@ mod all; mod images; -use std::{collections::HashMap, net::SocketAddr, str::FromStr, sync::Arc}; +use std::{collections::HashMap, net::SocketAddr, str::FromStr}; use async_stream::stream; use axum::{ body::Body, - extract::{ConnectInfo, Query, State}, + extract::{ConnectInfo, Query}, http::{header, HeaderMap, StatusCode}, response::IntoResponse, - Json, + Extension, Json, }; use bytes::Bytes; use maud::{html, PreEscaped, DOCTYPE}; @@ -20,29 +20,10 @@ use crate::{ self, Engine, EngineProgressUpdate, ProgressUpdateData, ResponseForTab, SearchQuery, SearchTab, }, + web::head_html, }; fn render_beginning_of_html(search: &SearchQuery) -> String { - let head_html = html! { - head { - meta charset="UTF-8"; - meta name="viewport" content="width=device-width, initial-scale=1.0"; - title { - (search.query) - " - " - (search.config.ui.site_name) - } - link rel="stylesheet" href="/style.css"; - @if let Some(stylesheet_url) = &search.config.ui.stylesheet_url { - link rel="stylesheet" href=(stylesheet_url); - } - @if let Some(stylesheet_str) = &search.config.ui.stylesheet_str { - link rel="stylesheet" href=(stylesheet_str); - } - script src="/script.js" defer {} - link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml"; - } - }; let form_html = html! { form."search-form" action="/search" method="get" { input #"search-input" type="text" name="q" placeholder="Search" value=(search.query) autofocus onfocus="this.select()" autocomplete="off"; @@ -62,9 +43,9 @@ fn render_beginning_of_html(search: &SearchQuery) -> String { html! { (DOCTYPE) html lang="en"; - (head_html) + {(head_html(Some(&search.query), &search.config))} body; - div.results-container.{"search-" (search.tab.to_string())}; + div.main-container.{"search-" (search.tab.to_string())}; main; (form_html) div.progress-updates; @@ -131,9 +112,9 @@ pub fn render_engine_list(engines: &[engines::Engine], config: &Config) -> PreEs } } -pub async fn route( +pub async fn get( Query(params): Query>, - State(config): State>, + Extension(config): Extension, headers: HeaderMap, ConnectInfo(addr): ConnectInfo, ) -> axum::response::Response { @@ -182,7 +163,7 @@ pub async fn route( || addr.ip().to_string(), |ip| ip.to_str().unwrap_or_default().to_string(), ), - config: config.clone(), + config: config.clone().into(), }; let trying_to_use_api = diff --git a/src/web/settings.rs b/src/web/settings.rs new file mode 100644 index 0000000..6bd6bd9 --- /dev/null +++ b/src/web/settings.rs @@ -0,0 +1,79 @@ +use axum::{ http::{header, StatusCode}, response::IntoResponse, Extension, Form}; +use axum_extra::extract::{cookie::Cookie, CookieJar}; +use maud::{html, Markup, PreEscaped, DOCTYPE}; +use serde::Deserialize; + +use crate::{config::Config, web::head_html}; + +pub async fn get( + Extension(config): Extension, +) -> impl IntoResponse { + let theme_option = |value: &str, name: &str| -> Markup { + let selected = config.ui.stylesheet_url == value; + html! { + option value=(value) selected[selected] { + { (name) } + } + } + }; + + let html = html! { + (PreEscaped("\n")) + (DOCTYPE) + html lang="en" { + {(head_html(Some("settings"), &config))} + body { + div.main-container.settings-page { + main { + a.back-to-index-button href="/" { "Back" } + h1 { "Settings" } + form.settings-form method="post" { + label for="theme" { "Theme" } + select name="stylesheet-url" selected=(config.ui.stylesheet_url) { + { (theme_option("", "Ayu Dark")) } + { (theme_option("/themes/catppuccin-mocha.css", "Catppuccin Mocha")) } + } + + br; + + // custom css textarea + details #custom-css-details { + summary { "Custom CSS" } + textarea name="stylesheet-str" id="custom-css" rows="10" cols="50" { + { (config.ui.stylesheet_str) } + } + } + + input #save-settings-button type="submit" value="Save"; + } + } + } + } + + } + } + .into_string(); + + ( [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html) +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Settings { + stylesheet_url: String, + stylesheet_str: String, +} + +pub async fn post( + mut jar: CookieJar, + Form(settings): Form, +) -> impl IntoResponse { + jar = jar.add(Cookie::new("stylesheet-url", settings.stylesheet_url)); + jar = jar.add(Cookie::new("stylesheet-str", settings.stylesheet_str)); + + ( + StatusCode::FOUND, + [(header::LOCATION, "/settings")], + jar + ) +}