From 6ee058e232dd5a8b095db7152d1a40948cf30bfb Mon Sep 17 00:00:00 2001 From: shouya <526598+shouya@users.noreply.github.com> Date: Mon, 11 Nov 2024 08:22:12 +0800 Subject: [PATCH] Support hosting service on non-root path prefix (#160) fixes #159. The path prefix is inferred by the following order: 1. The environment `RSS_FUNNEL_PATH_PREFIX` (Example value: `"app/"`), or if unspecified 2. The path of environment `RSS_FUNNEL_APP_BASE`, or if unspecified 3. `"/"`. --- src/cli.rs | 4 +- src/filter/image_proxy.rs | 2 +- src/filter/merge.rs | 1 + src/server.rs | 61 ++++++--- src/server/auth.rs | 20 +-- src/server/endpoint.rs | 16 +-- src/server/feed_service.rs | 1 + src/server/image_proxy.rs | 5 +- src/server/web.rs | 11 +- src/server/web/endpoint.rs | 13 +- src/server/web/list.rs | 8 +- src/server/web/login.rs | 16 ++- src/util.rs | 254 ++++++++++++++++++++++--------------- 13 files changed, 256 insertions(+), 156 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 8f898e0..8bbfa49 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,6 +7,7 @@ use url::Url; use crate::{ server::{self, EndpointConfig, ServerConfig}, + util::relative_path, ConfigError, Result, }; @@ -167,7 +168,8 @@ async fn health_check(health_check_config: HealthCheckConfig) -> Result<()> { server = format!("http://{server}"); } - let url = Url::parse(&server)?.join("/_health")?; + let path = relative_path("_health"); + let url = Url::parse(&server)?.join(&path)?; match reqwest::get(url.clone()).await { Ok(res) if res.status().is_success() => { eprintln!("{server} is healthy"); diff --git a/src/filter/image_proxy.rs b/src/filter/image_proxy.rs index 9e400a6..54095ca 100644 --- a/src/filter/image_proxy.rs +++ b/src/filter/image_proxy.rs @@ -8,7 +8,7 @@ use url::Url; use super::{FeedFilter, FeedFilterConfig, FilterContext}; use crate::{feed::Feed, ConfigError, Error, Result}; -const IMAGE_PROXY_ROUTE: &str = "/_image"; +const IMAGE_PROXY_ROUTE: &str = "_image"; #[derive( JsonSchema, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, diff --git a/src/filter/merge.rs b/src/filter/merge.rs index f6fa91a..9feca10 100644 --- a/src/filter/merge.rs +++ b/src/filter/merge.rs @@ -16,6 +16,7 @@ use super::{FeedFilter, FeedFilterConfig, FilterContext}; JsonSchema, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, )] #[serde(untagged)] +#[allow(clippy::large_enum_variant)] pub enum MergeConfig { /// Simple merge with default client and no filters Simple(MergeSimpleConfig), diff --git a/src/server.rs b/src/server.rs index 9223bf2..3f7734d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -9,13 +9,21 @@ mod web; use std::{path::Path, sync::Arc}; -use axum::{routing::get, Extension, Router}; +use axum::{ + response::{IntoResponse, Redirect}, + routing::get, + Extension, Router, +}; use clap::Parser; use http::StatusCode; use tower_http::compression::CompressionLayer; use tracing::{info, warn}; -use crate::Result; +use crate::{ + util::{self, relative_path}, + Result, +}; + pub use endpoint::{EndpointConfig, EndpointParam}; use self::{feed_service::FeedService, watcher::Watcher}; @@ -62,7 +70,10 @@ impl ServerConfig { pub async fn run_without_config(self) -> Result<()> { let feed_service = FeedService::new_otf().await?; - info!("serving on-the-fly endpoint on /otf"); + let rel_path = relative_path("otf"); + info!( + "No config detected. Serving automatic on-the-fly endpoint on {rel_path}" + ); self.serve(feed_service).await } @@ -108,27 +119,23 @@ impl ServerConfig { self.serve(feed_service).await } - pub async fn serve(self, feed_service: FeedService) -> Result<()> { - info!("listening on {}", &self.bind); - let listener = tokio::net::TcpListener::bind(&*self.bind).await?; - - let mut app = Router::new(); + pub fn router(&self, feed_service: FeedService) -> Router { + let mut routes = Router::new(); #[cfg(feature = "inspector-ui")] if self.inspector_ui { - app = app + routes = routes .nest("/", inspector::router()) .nest("/_/", web::router()) - .route( - "/", - get(|| async { axum::response::Redirect::temporary("/_/") }), - ); + .route("/", get(redirect_to_home)); } else { - app = app.route("/", get(|| async { "rss-funnel is up and running!" })); + routes = + routes.route("/", get(|| async { "rss-funnel is up and running!" })); } if !cfg!(feature = "inspector-ui") { - app = app.route("/", get(|| async { "rss-funnel is up and running!" })); + routes = + routes.route("/", get(|| async { "rss-funnel is up and running!" })); } let feed_service_router = Router::new() @@ -138,7 +145,7 @@ impl ServerConfig { })) .layer(CompressionLayer::new().gzip(true)); - app = app + routes = routes // deprecated, will be removed on 0.2 .route("/health", get(|| async { "ok" })) .route("/_health", get(|| async { "ok" })) @@ -146,9 +153,31 @@ impl ServerConfig { .merge(feed_service_router) .layer(Extension(feed_service)); + routes + } + + pub async fn serve(self, feed_service: FeedService) -> Result<()> { + info!("listening on {}", &self.bind); + let listener = tokio::net::TcpListener::bind(&*self.bind).await?; + + let mut app = Router::new(); + + let prefix = util::relative_path(""); + if prefix == "/" { + app = self.router(feed_service); + } else { + info!("Path prefix set to {prefix}"); + app = app.nest(&prefix, self.router(feed_service)) + }; + info!("starting server"); let server = axum::serve(listener, app); Ok(server.await?) } } + +async fn redirect_to_home() -> impl IntoResponse { + let home_path = relative_path("_/"); + Redirect::temporary(&home_path) +} diff --git a/src/server/auth.rs b/src/server/auth.rs index febaa66..ec3caa5 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -6,6 +6,8 @@ use axum::{ use axum_extra::extract::CookieJar; use http::request::Parts; +use crate::util::relative_path; + use super::feed_service::FeedService; pub struct Auth; @@ -42,7 +44,8 @@ impl FromRequestParts for Auth { } fn login() -> Response { - Redirect::to("/_inspector/login.html?login_required=1").into_response() + let login_path = relative_path("_inspector/login.html?login_required=1"); + Redirect::to(&login_path).into_response() } #[derive(serde::Deserialize)] @@ -59,17 +62,18 @@ pub async fn handle_login( match feed_service.login(¶ms.username, ¶ms.password).await { Some(session_id) => { let cookie_jar = cookie_jar.add(("session_id", session_id)); - (cookie_jar, Redirect::to("/_inspector/index.html")).into_response() + let home_path = relative_path("_inspector/index.html"); + (cookie_jar, Redirect::to(&home_path)).into_response() + } + _ => { + let login_path = relative_path("_inspector/login.html?bad_auth=1"); + Redirect::to(&login_path).into_response() } - _ => Redirect::to("/_inspector/login.html?bad_auth=1").into_response(), } } pub async fn handle_logout(cookie_jar: CookieJar) -> Response { let cookie_jar = cookie_jar.remove("session_id"); - ( - cookie_jar, - Redirect::to("/_inspector/login.html?logged_out=1"), - ) - .into_response() + let logout_path = relative_path("_inspector/login.html?logged_out=1"); + (cookie_jar, Redirect::to(&logout_path)).into_response() } diff --git a/src/server/endpoint.rs b/src/server/endpoint.rs index fb190ed..e8340fd 100644 --- a/src/server/endpoint.rs +++ b/src/server/endpoint.rs @@ -188,7 +188,7 @@ impl EndpointParam { } fn get_base(req: &Parts) -> Option { - if let Some(url) = Self::base_from_env().as_ref() { + if let Some(url) = crate::util::app_base_from_env().as_ref() { return Some(url.clone()); } @@ -222,20 +222,6 @@ impl EndpointParam { let base = base.parse().ok()?; Some(base) } - - fn base_from_env() -> &'static Option { - use std::env; - use std::sync::OnceLock; - - static APP_BASE_URL: OnceLock> = OnceLock::new(); - APP_BASE_URL.get_or_init(|| { - let var = env::var("RSS_FUNNEL_APP_BASE").ok(); - var.map(|v| { - v.parse() - .expect("Invalid base url specified in RSS_FUNNEL_APP_BASE") - }) - }) - } } impl Service for EndpointService { diff --git a/src/server/feed_service.rs b/src/server/feed_service.rs index a8c917e..8782878 100644 --- a/src/server/feed_service.rs +++ b/src/server/feed_service.rs @@ -15,6 +15,7 @@ use crate::{cli::RootConfig, ConfigError}; use super::{endpoint::EndpointService, EndpointConfig}; +// can be cheaply cloned. #[derive(Clone)] pub struct FeedService { inner: Arc>, diff --git a/src/server/image_proxy.rs b/src/server/image_proxy.rs index 38cccbf..432e1c7 100644 --- a/src/server/image_proxy.rs +++ b/src/server/image_proxy.rs @@ -12,12 +12,15 @@ use tower_http::cors::CorsLayer; use tracing::{info, warn}; use url::Url; +use crate::util::relative_path; + lazy_static::lazy_static! { static ref SIGN_KEY: Box<[u8]> = init_sign_key(); } pub fn router() -> Router { - info!("handling image proxy: /_image"); + let image_proxy_path = relative_path("_image"); + info!("handling image proxy: {image_proxy_path}"); use tower_http::cors::AllowOrigin; let cors = CorsLayer::new() diff --git a/src/server/web.rs b/src/server/web.rs index 89b29ad..2056f4e 100644 --- a/src/server/web.rs +++ b/src/server/web.rs @@ -13,6 +13,8 @@ use http::StatusCode; use login::Auth; use maud::{html, Markup}; +use crate::util::relative_path; + use super::{feed_service::FeedService, EndpointParam}; #[derive(rust_embed::RustEmbed)] @@ -93,9 +95,11 @@ impl axum::extract::FromRequestParts for AutoReload { async fn handle_home(auth: Option) -> impl IntoResponse { if auth.is_some() { - Redirect::temporary("/_/endpoints") + let endpoints_path = relative_path("_/endpoints"); + Redirect::temporary(&endpoints_path) } else { - Redirect::temporary("/_/login") + let login_path = relative_path("_/login"); + Redirect::temporary(&login_path) } } @@ -147,9 +151,10 @@ fn favicon() -> Markup { } fn sprite(icon: &str) -> Markup { + let sprite_path = relative_path("_/sprite.svg"); html! { svg class="icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" { - use xlink:href=(format!("/_/sprite.svg#{icon}")); + use xlink:href=(format!("{sprite_path}#{icon}")); } } } diff --git a/src/server/web/endpoint.rs b/src/server/web/endpoint.rs index 88fca8c..dd7c9a1 100644 --- a/src/server/web/endpoint.rs +++ b/src/server/web/endpoint.rs @@ -9,6 +9,7 @@ use crate::{ filter::FilterContext, server::{endpoint::EndpointService, web::sprite, EndpointParam}, source::{FromScratch, Source}, + util::relative_path, }; pub async fn render_endpoint_page( @@ -51,7 +52,8 @@ pub async fn render_endpoint_page( body { header .header-bar { button .left-button { - a href="/_/" { "Back" } + @let home_path = relative_path("_/"); + a href=(home_path) { "Back" } } h2 { (path) } button .button title="Copy Endpoint URL" onclick="copyToClipboard()" { @@ -108,13 +110,14 @@ fn source_control_fragment( ) -> Markup { match source { Source::Dynamic => html! { + @let endpoint_path = relative_path(&format!("_/endpoint/{path}")); input .hx-included.grow type="text" name="source" placeholder="Source URL" value=[param.source().map(|url| url.as_str())] - hx-get=(format!("/_/endpoint/{path}")) + hx-get=(endpoint_path) hx-trigger="keyup changed delay:500ms" hx-push-url="true" hx-indicator=".loading" @@ -174,6 +177,7 @@ fn source_template_fragment( @match fragment { Either::Left(plain) => span style="white-space: nowrap" { (plain) }, Either::Right((name, Some(placeholder))) => { + @let endpoint_path = relative_path(&format!("_/endpoint/{path}")); @let value=queries.get(name); @let default_value=placeholder.default.as_ref(); @let value=value.or(default_value); @@ -184,7 +188,7 @@ fn source_template_fragment( placeholder=(name) pattern=[validation] value=[value] - hx-get=(format!("/_/endpoint/{path}")) + hx-get=(endpoint_path) hx-trigger="keyup changed delay:500ms" hx-push-url="true" hx-include=".hx-included" @@ -246,9 +250,10 @@ fn render_config_fragment( } @else { div { header { b { "Filters:" } } + @let endpoint_path = relative_path(&format!("_/endpoint/{path}")); ul #filter-list .hx-included hx-vals="js:{...gatherFilterSkip()}" - hx-get=(format!("/_/endpoint/{path}")) + hx-get=(endpoint_path) hx-trigger="click from:.filter-name" hx-push-url="true" hx-include=".hx-included" diff --git a/src/server/web/list.rs b/src/server/web/list.rs index eb162d4..087effb 100644 --- a/src/server/web/list.rs +++ b/src/server/web/list.rs @@ -7,6 +7,7 @@ use crate::{ cli::RootConfig, server::{web::sprite, EndpointConfig}, source::SourceConfig, + util::relative_path, }; pub fn render_endpoint_list_page( @@ -59,7 +60,10 @@ fn endpoint_list_entry_fragment(endpoint: &EndpointConfig) -> Markup { html! { li { p { - a href={"/_/endpoint/" (endpoint.path.trim_start_matches('/'))} { + @let normalized_path = endpoint.path.trim_start_matches('/'); + @let endpoint_path = format!("/_/endpoint/{}", normalized_path); + @let endpoint_path = relative_path(&endpoint_path); + a href=(endpoint_path) { (endpoint.path) } @@ -104,7 +108,7 @@ fn short_source_repr(source: &SourceConfig) -> Markup { }, SourceConfig::Simple(url) if url.starts_with("/") => { let path = url_path(url.as_str()); - let path = path.map(|p| format!("/_/{p}")); + let path = path.map(|p| relative_path(&format!("_/{p})"))); html! { @if let Some(path) = path { span .tag.local { diff --git a/src/server/web/login.rs b/src/server/web/login.rs index f657f27..17d02ce 100644 --- a/src/server/web/login.rs +++ b/src/server/web/login.rs @@ -9,7 +9,7 @@ use axum_extra::extract::CookieJar; use http::request::Parts; use maud::{html, PreEscaped, DOCTYPE}; -use crate::server::feed_service::FeedService; +use crate::{server::feed_service::FeedService, util::relative_path}; // Put this in request context pub struct Auth; @@ -79,12 +79,14 @@ impl FromRequestParts for Auth { } fn redir_login() -> Response { - Redirect::to("/_/login?login_required=1").into_response() + let login_path = relative_path("_/login?login_required=1"); + Redirect::to(&login_path).into_response() } pub async fn handle_logout(cookie_jar: CookieJar) -> impl IntoResponse { let cookie_jar = cookie_jar.remove("session_id"); - (cookie_jar, Redirect::to("/_/login?logged_out=1")).into_response() + let login_path = relative_path("_/login?logged_out=1"); + (cookie_jar, Redirect::to(&login_path)).into_response() } #[derive(serde::Deserialize)] @@ -102,9 +104,13 @@ pub async fn handle_login( match feed_service.login(¶ms.username, ¶ms.password).await { Some(session_id) => { let cookie_jar = cookie_jar.add(("session_id", session_id)); - (cookie_jar, Redirect::to("/_/endpoints")).into_response() + let home_path = relative_path("_/"); + (cookie_jar, Redirect::to(&home_path)).into_response() + } + _ => { + let login_path = relative_path("_/login?bad_auth=1"); + Redirect::to(&login_path).into_response() } - _ => Redirect::to("/_/login?bad_auth=1").into_response(), } } diff --git a/src/util.rs b/src/util.rs index e8a5239..d1e89e9 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,7 +1,5 @@ mod html; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use url::Url; pub use self::html::{convert_relative_url, fragment_root_node_id, html_body}; @@ -24,131 +22,187 @@ pub fn is_env_set(name: &str) -> bool { matches!(val.as_str(), "1" | "t" | "true" | "y" | "yes") } -#[derive( - JsonSchema, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, -)] -#[serde(untagged)] -pub enum SingleOrVec { - /// A single entry - Single(T), - /// A list of entries - Vec(Vec), +mod path_prefix { + use std::sync::LazyLock; + + const DEFAULT_PATH_PREFIX: &str = "/"; + pub static PATH_PREFIX: LazyLock> = LazyLock::new(|| { + let prefix = std::env::var("RSS_FUNNEL_PATH_PREFIX") + .ok() + .or_else(|| { + super::app_base_from_env() + .as_ref() + .map(|url| url.path().to_owned()) + }) + .unwrap_or_else(|| DEFAULT_PATH_PREFIX.to_owned()) + .into_boxed_str(); + assert!(prefix.ends_with("/")); + prefix + }); + + pub fn relative_path(path: &str) -> String { + debug_assert!(!path.starts_with("/")); + format!("{}{path}", *PATH_PREFIX) + } } -impl Default for SingleOrVec { - fn default() -> Self { - Self::empty() +pub use self::path_prefix::relative_path; + +mod app_base { + use std::sync::LazyLock; + use url::Url; + + static APP_BASE_URL: LazyLock> = LazyLock::new(|| { + let var = std::env::var("RSS_FUNNEL_APP_BASE").ok(); + var.map(|v| { + v.parse() + .expect("Invalid base url specified in RSS_FUNNEL_APP_BASE") + }) + }); + + pub fn app_base_from_env() -> &'static Option { + &APP_BASE_URL } } -pub enum SingleOrVecIter<'a, T> { - Single(std::iter::Once<&'a T>), - Vec(std::slice::Iter<'a, T>), -} +pub use self::app_base::app_base_from_env; + +mod single_or_vec { + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + + #[derive( + JsonSchema, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, + )] + #[serde(untagged)] + pub enum SingleOrVec { + /// A single entry + Single(T), + /// A list of entries + Vec(Vec), + } -impl SingleOrVec { - pub fn empty() -> Self { - Self::Vec(Vec::new()) + impl Default for SingleOrVec { + fn default() -> Self { + Self::empty() + } } - pub fn into_vec(self) -> Vec { - match self { - Self::Single(s) => vec![s], - Self::Vec(v) => v, + pub enum SingleOrVecIter<'a, T> { + Single(std::iter::Once<&'a T>), + Vec(std::slice::Iter<'a, T>), + } + + impl SingleOrVec { + pub fn empty() -> Self { + Self::Vec(Vec::new()) + } + + pub fn into_vec(self) -> Vec { + match self { + Self::Single(s) => vec![s], + Self::Vec(v) => v, + } } } -} -impl<'a, T> IntoIterator for &'a SingleOrVec { - type Item = &'a T; - type IntoIter = SingleOrVecIter<'a, T>; + impl<'a, T> IntoIterator for &'a SingleOrVec { + type Item = &'a T; + type IntoIter = SingleOrVecIter<'a, T>; - fn into_iter(self) -> Self::IntoIter { - match self { - SingleOrVec::Single(s) => SingleOrVecIter::Single(std::iter::once(s)), - SingleOrVec::Vec(v) => SingleOrVecIter::Vec(v.iter()), + fn into_iter(self) -> Self::IntoIter { + match self { + SingleOrVec::Single(s) => SingleOrVecIter::Single(std::iter::once(s)), + SingleOrVec::Vec(v) => SingleOrVecIter::Vec(v.iter()), + } } } -} -impl<'a, T> Iterator for SingleOrVecIter<'a, T> { - type Item = &'a T; + impl<'a, T> Iterator for SingleOrVecIter<'a, T> { + type Item = &'a T; - fn next(&mut self) -> Option { - match self { - Self::Single(s) => s.next(), - Self::Vec(v) => v.next(), + fn next(&mut self) -> Option { + match self { + Self::Single(s) => s.next(), + Self::Vec(v) => v.next(), + } } } } -use std::{ - hash::Hash, - num::NonZeroUsize, - sync::{ - atomic::{AtomicUsize, Ordering}, - RwLock, - }, - time::{Duration, Instant}, -}; - -use lru::LruCache; - -pub struct Timed { - value: T, - created: Instant, -} +pub use self::single_or_vec::SingleOrVec; + +mod cache { + use std::{ + hash::Hash, + num::NonZeroUsize, + sync::{ + atomic::{AtomicUsize, Ordering}, + RwLock, + }, + time::{Duration, Instant}, + }; -pub struct TimedLruCache { - map: RwLock>>, - misses: AtomicUsize, - hits: AtomicUsize, - timeout: Duration, -} + use lru::LruCache; -impl TimedLruCache { - pub fn new(max_entries: usize, timeout: Duration) -> Self { - let max_entries = max_entries.try_into().unwrap_or(NonZeroUsize::MIN); - Self { - map: RwLock::new(LruCache::new(max_entries)), - timeout, - misses: AtomicUsize::new(0), - hits: AtomicUsize::new(0), - } + struct Timed { + value: T, + created: Instant, + } + + pub struct TimedLruCache { + map: RwLock>>, + misses: AtomicUsize, + hits: AtomicUsize, + timeout: Duration, } - pub fn get_cached(&self, key: &K) -> Option { - let mut map = self.map.write().ok()?; - let Some(entry) = map.get(key) else { - self.misses.fetch_add(1, Ordering::Relaxed); - return None; - }; - - if entry.created.elapsed() > self.timeout { - self.misses.fetch_add(1, Ordering::Relaxed); - map.pop(key); - return None; + impl TimedLruCache { + pub fn new(max_entries: usize, timeout: Duration) -> Self { + let max_entries = max_entries.try_into().unwrap_or(NonZeroUsize::MIN); + Self { + map: RwLock::new(LruCache::new(max_entries)), + timeout, + misses: AtomicUsize::new(0), + hits: AtomicUsize::new(0), + } } - self.hits.fetch_add(1, Ordering::Relaxed); - Some(entry.value.clone()) - } + pub fn get_cached(&self, key: &K) -> Option { + let mut map = self.map.write().ok()?; + let Some(entry) = map.get(key) else { + self.misses.fetch_add(1, Ordering::Relaxed); + return None; + }; + + if entry.created.elapsed() > self.timeout { + self.misses.fetch_add(1, Ordering::Relaxed); + map.pop(key); + return None; + } + + self.hits.fetch_add(1, Ordering::Relaxed); + Some(entry.value.clone()) + } - pub fn insert(&self, key: K, value: V) -> Option<()> { - let timed = Timed { - value, - created: Instant::now(), - }; - self.map.write().ok()?.push(key, timed); - Some(()) - } + pub fn insert(&self, key: K, value: V) -> Option<()> { + let timed = Timed { + value, + created: Instant::now(), + }; + self.map.write().ok()?.push(key, timed); + Some(()) + } - // hit, miss - #[allow(unused)] - pub fn stats(&self) -> (usize, usize) { - ( - self.hits.load(Ordering::Relaxed), - self.misses.load(Ordering::Relaxed), - ) + // hit, miss + #[allow(unused)] + pub fn stats(&self) -> (usize, usize) { + ( + self.hits.load(Ordering::Relaxed), + self.misses.load(Ordering::Relaxed), + ) + } } } + +pub use self::cache::TimedLruCache;