diff --git a/Cargo.lock b/Cargo.lock index e8caebb..3e413d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] @@ -184,9 +184,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c90a406b4495d129f00461241616194cb8a032c8d1c53c657f0961d5f8e0498" +checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5" dependencies = [ "bzip2", "deflate64", @@ -319,9 +319,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.71" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +checksum = "17c6a35df3749d2e8bb1b7b21a976d82b15548788d2735b9d82f329268f71a11" dependencies = [ "addr2line", "cc", @@ -828,9 +828,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "glob" @@ -1028,9 +1028,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d8d52be92d09acc2e01dddb7fde3ad983fc6489c7db4837e605bc3fca4cb63e" +checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56" dependencies = [ "bytes", "futures-channel", @@ -1241,11 +1241,10 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" dependencies = [ - "lazy_static", "libc", "log", "openssl", @@ -1313,9 +1312,9 @@ dependencies = [ [[package]] name = "object" -version = "0.32.2" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "b8ec7ab813848ba4522158d5517a6093db1ded27575b070f4177b8d12b41db5e" dependencies = [ "memchr", ] @@ -1506,6 +1505,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "proc-macro-crate" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.84" @@ -1715,9 +1723,9 @@ dependencies = [ [[package]] name = "rstest" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5316d2a1479eeef1ea21e7f9ddc67c191d497abc8fc3ba2467857abbb68330" +checksum = "27059f51958c5f8496a6f79511e7c0ac396dd815dc8894e9b6e2efb5779cf6f0" dependencies = [ "rstest_macros", "rustc_version", @@ -1725,12 +1733,13 @@ dependencies = [ [[package]] name = "rstest_macros" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04a9df72cc1f67020b0d63ad9bfe4a323e459ea7eb68e03bd9824db49f9a4c25" +checksum = "e6132d64df104c0b3ea7a6ad7766a43f587bd773a4a9cf4cd59296d426afaf3a" dependencies = [ "cfg-if", "glob", + "proc-macro-crate", "proc-macro2", "quote", "regex", @@ -2210,6 +2219,23 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" + +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -2707,6 +2733,15 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 3b624d6..ee93f72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ serde_json = "1.0.117" thiserror = "1.0.61" tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] } tokio-util = { version = "0.7.11", features = ["io"] } -tower-http = { version = "0.5.2", features = ["trace"] } +tower-http = { version = "0.5.2", features = ["trace", "set-header"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" url = "2.5.0" @@ -44,7 +44,7 @@ yarte_helpers = "0.15.8" [dev-dependencies] proptest = "1.4.0" -rstest = { version = "0.19.0", default-features = false } +rstest = { version = "0.20.0", default-features = false } [workspace] members = [".", "crates/*"] diff --git a/crates/envy/src/lib.rs b/crates/envy/src/lib.rs index 75835d9..3f00ae4 100644 --- a/crates/envy/src/lib.rs +++ b/crates/envy/src/lib.rs @@ -62,11 +62,7 @@ use serde::de::{ value::{MapDeserializer, SeqDeserializer}, IntoDeserializer, }; -use std::{ - borrow::Cow, - env, - iter::{empty, IntoIterator}, -}; +use std::{borrow::Cow, env, iter::IntoIterator, str::MatchIndices}; // Ours mod error; @@ -137,19 +133,19 @@ impl<'de> de::Deserializer<'de> for Val { where V: de::Visitor<'de>, { - // std::str::split doesn't work as expected for our use case: when we - // get an empty string we want to produce an empty Vec, but split would - // still yield an iterator with an empty string in it. So we need to - // special case empty strings. - if self.1.is_empty() { - SeqDeserializer::new(empty::()).deserialize_seq(visitor) - } else { - let values = self - .1 - .split(',') - .map(|v| Val(self.0.clone(), v.trim().to_owned())); - SeqDeserializer::new(values).deserialize_seq(visitor) - } + let values = SplitEscaped::new(&self.1, ";", true); + SeqDeserializer::new(values.map(|v| Val(self.0.clone(), v))).deserialize_seq(visitor) + } + + fn deserialize_map(self, visitor: V) -> Result + where + V: de::Visitor<'de>, + { + MapDeserializer::new(SplitEscaped::new(&self.1, ";", false).filter_map(|pair| { + let mut parts = SplitEscaped::new(&pair, "=>", true); + parts.next().zip(parts.next()) + })) + .deserialize_map(visitor) } fn deserialize_option(self, visitor: V) -> Result @@ -216,7 +212,7 @@ impl<'de> de::Deserializer<'de> for Val { serde::forward_to_deserialize_any! { char str string unit - bytes byte_buf map unit_struct tuple_struct + bytes byte_buf unit_struct tuple_struct identifier tuple ignored_any struct } @@ -372,6 +368,63 @@ where Prefixed(prefix.into()) } +struct SplitEscaped<'a> { + input: &'a str, + pat: &'a str, + epat: String, + fin: bool, + separators: MatchIndices<'a, &'a str>, + start: usize, +} + +impl<'a> SplitEscaped<'a> { + pub fn new(input: &'a str, pat: &'a str, fin: bool) -> Self { + Self { + separators: input.match_indices(pat), + input, + pat, + fin, + epat: format!("\\{pat}"), + start: 0, + } + } +} + +impl<'a> Iterator for SplitEscaped<'a> { + type Item = String; + + fn next(&mut self) -> Option { + let res = match self.separators.find(|(i, _)| { + let b1 = self.input.get(i - 1..*i); + let b2 = if *i > 1 { + self.input.get(i - 2..i - 1) + } else { + None + }; + b1 != Some(r"\") || b2 == Some(r"\") + }) { + Some((pos, _)) => Some(&self.input[self.start..pos]), + None => { + if self.start >= self.input.len() { + None + } else { + Some(&self.input[self.start..]) + } + } + }; + if let Some(res) = res { + self.start += res.len() + self.pat.len(); + let mut out = res.trim().replace(&self.epat, self.pat); + if self.fin { + out = out.replace(r"\\", r"\"); + } + Some(out) + } else { + None + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -416,7 +469,7 @@ mod tests { let data = vec![ (String::from("BAR"), String::from("test")), (String::from("BAZ"), String::from("true")), - (String::from("DOOM"), String::from("1, 2, 3 ")), + (String::from("DOOM"), String::from("1; 2; 3 ")), // Empty string should result in empty vector. (String::from("BOOM"), String::from("")), (String::from("SIZE"), String::from("small")), @@ -479,7 +532,7 @@ mod tests { Ok(_) => panic!("expected failure"), Err(e) => assert_eq!( e, - Error::Custom(String::from("provided string was not `true` or `false` while parsing value \'notabool\' provided by BAZ")) + Error::Custom(String::from("error parsing boolean value: 'notabool'")) ), } } @@ -490,7 +543,7 @@ mod tests { (String::from("APP_BAR"), String::from("test")), (String::from("APP_BAZ"), String::from("true")), (String::from("APP_DOOM"), String::from("")), - (String::from("APP_BOOM"), String::from("4,5")), + (String::from("APP_BOOM"), String::from("4;5")), (String::from("APP_SIZE"), String::from("small")), (String::from("APP_PROVIDED"), String::from("test")), (String::from("APP_NEWTYPE"), String::from("42")), @@ -557,4 +610,34 @@ mod tests { let res = from_iter::<_, X>(data).unwrap(); assert_eq!(res.val, None) } + + #[test] + fn deserialize_map() { + #[derive(Deserialize)] + struct X { + val: HashMap, + } + + let data = vec![( + String::from("VAL"), + String::from("He\\\\llo\\=>W=>orld;this is => me"), + )]; + let res = from_iter::<_, X>(data).unwrap(); + assert_eq!(res.val.len(), 2); + assert_eq!(&res.val["He\\llo=>W"], "orld"); + assert_eq!(&res.val["this is"], "me"); + } + + #[test] + fn split_escaped() { + let input = r"He\\llo=> Wor\=>ld "; + let res = SplitEscaped::new(input, "=>", true).collect::>(); + assert_eq!(res, [r"He\llo", "Wor=>ld"]); + + let input = r"4;5"; + let res = SplitEscaped::new(input, ";", true).collect::>(); + assert_eq!(res, ["4", "5"]); + + assert!(SplitEscaped::new("", ";", true).next().is_none()); + } } diff --git a/resources/favicon.ico b/resources/favicon.ico new file mode 100644 index 0000000..8525003 Binary files /dev/null and b/resources/favicon.ico differ diff --git a/resources/favicon.xcf b/resources/favicon.xcf new file mode 100644 index 0000000..dbb418a Binary files /dev/null and b/resources/favicon.xcf differ diff --git a/src/app.rs b/src/app.rs index 512a5ad..44f1ec9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, ops::Bound, path::PathBuf, sync::Arc}; +use std::{net::SocketAddr, ops::Bound, path::PathBuf, str::FromStr, sync::Arc}; use async_zip::tokio::read::ZipEntryReader; use axum::{ @@ -7,7 +7,7 @@ use axum::{ http::{Response, Uri}, response::{IntoResponse, Redirect}, routing::{any, get, post}, - Form, Json, Router, + Form, Router, }; use headers::HeaderMapExt; use http::{HeaderMap, StatusCode}; @@ -20,17 +20,20 @@ use tokio_util::{ compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}, io::ReaderStream, }; -use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tower_http::{ + set_header::SetResponseHeaderLayer, + trace::{DefaultOnResponse, TraceLayer}, +}; use crate::{ - artifact_api::{Artifact, ArtifactApi}, - cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile, IndexEntry}, + artifact_api::ArtifactApi, + cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile}, config::Config, - error::{Error, Result}, + error::Error, gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN}, query::Query, templates::{self, ArtifactItem, LinkItem}, - util::{self, InsertTypedHeader}, + util::{self, ErrorJson, ResponseBuilderExt}, App, }; @@ -56,6 +59,9 @@ struct UrlForm { url: String, } +const FAVICON_PATH: &str = "/favicon.ico"; +const FAVICON_BYTES: &[u8; 268] = include_bytes!("../resources/favicon.ico"); + impl App { pub fn new() -> Self { Self @@ -65,7 +71,7 @@ impl App { AppState::new() } - pub async fn run(&self) -> Result<()> { + pub async fn run(&self) -> Result<(), Error> { let address = "0.0.0.0:3000"; let listener = tokio::net::TcpListener::bind(address).await?; tracing::info!("Listening on http://{address}"); @@ -98,7 +104,8 @@ impl App { tracing::error_span!("request", url = util::full_url_from_request(request), ip) }) .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), - ); + ) + .layer(SetResponseHeaderLayer::appending(http::header::X_CONTENT_TYPE_OPTIONS, http::HeaderValue::from_static("nosniff"))); axum::serve( listener, router.into_make_service_with_connect_info::(), @@ -112,19 +119,23 @@ impl App { Host(host): Host, uri: Uri, request: Request, - ) -> Result> { + ) -> Result, Error> { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; if subdomain.is_empty() { // Main page + if uri.path() == FAVICON_PATH { + return Self::favicon(); + } if uri.path() != "/" { return Err(Error::NotFound("path".into())); } Ok(Response::builder() .typed_header(headers::ContentType::html()) + .cache() .body(templates::Index::default().to_string().into())?) } else { - let query = Query::from_subdomain(subdomain)?; + let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; let path = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy(); let hdrs = request.headers(); @@ -138,12 +149,12 @@ impl App { state.garbage_collect(); } - match entry.get_file(&path, uri.query().unwrap_or_default())? { - GetFileResult::File(res) => { + match entry.get_file(&path, uri.query().unwrap_or_default()) { + Ok(GetFileResult::File(res)) => { Self::serve_artifact_file(state, entry, entry_res.zip_path, res, hdrs) .await } - GetFileResult::Listing(listing) => { + Ok(GetFileResult::Listing(listing)) => { if !path.ends_with('/') { return Ok(Redirect::to(&format!("{path}/")).into_response()); } @@ -154,7 +165,7 @@ impl App { url: state .i .cfg - .url_with_subdomain(&query.subdomain_with_artifact(None)), + .url_with_subdomain(&query.subdomain_with_artifact(None)?), }, LinkItem { name: entry.name.to_owned(), @@ -185,13 +196,25 @@ impl App { Ok(Response::builder() .typed_header(headers::ContentType::html()) + .cache_immutable() .body(tmpl.to_string().into())?) } + Err(Error::NotFound(e)) => { + if path == FAVICON_PATH { + Self::favicon() + } else { + Err(Error::NotFound(e)) + } + } + Err(e) => Err(e), } } Query::Run(query) => { let artifacts = state.i.api.list(&query).await?; + if uri.path() == FAVICON_PATH { + return Self::favicon(); + } if uri.path() != "/" { return Err(Error::NotFound("path".into())); } @@ -210,10 +233,11 @@ impl App { artifacts: artifacts .into_iter() .map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg)) - .collect(), + .collect::, _>>()?, }; Ok(Response::builder() .typed_header(headers::ContentType::html()) + .cache() .body(tmpl.to_string().into())?) } } @@ -224,12 +248,12 @@ impl App { State(state): State, Host(host): Host, Form(url): Form, - ) -> Result { + ) -> Result { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; if subdomain.is_empty() { - let query = Query::from_forge_url(&url.url)?; - let subdomain = query.subdomain(); + let query = Query::from_forge_url(&url.url, &state.i.cfg.load().site_aliases)?; + let subdomain = query.subdomain()?; let target = format!( "{}{}.{}", state.i.cfg.url_proto(), @@ -248,7 +272,7 @@ impl App { zip_path: PathBuf, res: GetFileResultFile, hdrs: &HeaderMap, - ) -> Result> { + ) -> Result, Error> { let file = res.file; // Dont serve files above the configured size limit @@ -267,7 +291,8 @@ impl App { let mut resp = Response::builder() .status(res.status) .typed_header(headers::AcceptRanges::bytes()) - .typed_header(headers::LastModified::from(entry.last_modified)); + .typed_header(headers::LastModified::from(entry.last_modified)) + .cache_immutable(); if let Some(mime) = res.mime { resp = resp.typed_header(headers::ContentType::from(mime)); } @@ -366,24 +391,24 @@ impl App { async fn get_artifacts( State(state): State, Host(host): Host, - ) -> Result>> { + ) -> Result, ErrorJson> { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; - let query = Query::from_subdomain(subdomain)?; + let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; let artifacts = state.i.api.list(&query.into_runquery()).await?; - Ok(Json(artifacts)) + Ok(Response::builder().cache().json(&artifacts)?) } /// API endpoint to get the metadata of the current artifact async fn get_artifact( State(state): State, Host(host): Host, - ) -> Result> { + ) -> Result, ErrorJson> { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; - let query = Query::from_subdomain(subdomain)?; + let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; let artifact = state.i.api.fetch(&query.try_into_artifactquery()?).await?; - Ok(Json(artifact)) + Ok(Response::builder().cache().json(&artifact)?) } /// API endpoint to get a file listing @@ -391,10 +416,10 @@ impl App { State(state): State, Host(host): Host, request: Request, - ) -> Result>> { + ) -> Result, ErrorJson> { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?; - let query = Query::from_subdomain(subdomain)?; + let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; let entry_res = state .i @@ -405,7 +430,17 @@ impl App { state.garbage_collect(); } let files = entry_res.entry.get_files(); - Ok(Json(files)) + Ok(Response::builder() + .typed_header(headers::LastModified::from(entry_res.entry.last_modified)) + .cache_immutable() + .json(&files)?) + } + + fn favicon() -> Result, Error> { + Ok(Response::builder() + .typed_header(headers::ContentType::from_str("image/x-icon").unwrap()) + .cache_immutable() + .body(FAVICON_BYTES.as_slice().into())?) } } diff --git a/src/artifact_api.rs b/src/artifact_api.rs index 8841a72..47013e6 100644 --- a/src/artifact_api.rs +++ b/src/artifact_api.rs @@ -112,7 +112,7 @@ impl ArtifactApi { } pub async fn list(&self, query: &QueryData) -> Result> { - let subdomain = query.subdomain_with_artifact(None); + let subdomain = query.subdomain_with_artifact(None)?; self.qc .get_or_insert_async(&subdomain, async { if query.is_github() { @@ -277,6 +277,7 @@ mod tests { async fn fetch_forgejo() { let query = ArtifactQuery { host: "code.thetadev.de".to_owned(), + host_alias: None, user: "HSA".to_owned(), repo: "Visitenbuch".to_owned(), run: 32, @@ -293,6 +294,7 @@ mod tests { async fn fetch_github() { let query = ArtifactQuery { host: "github.com".to_owned(), + host_alias: None, user: "actions".to_owned(), repo: "upload-artifact".to_owned(), run: 8805345396, diff --git a/src/cache.rs b/src/cache.rs index 28a05fe..f3db3ec 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -3,7 +3,7 @@ use std::{ collections::{BTreeMap, HashMap}, fs::FileTimes, net::IpAddr, - num::{NonZeroU32, NonZeroUsize}, + num::NonZeroUsize, path::{Path, PathBuf}, sync::Arc, time::{Duration, SystemTime}, @@ -103,7 +103,7 @@ impl Cache { .load() .limit_artifacts_per_min .map(|lim| RateLimiter::keyed(Quota::per_minute(lim))), - lim_gc: RateLimiter::direct(Quota::per_hour(NonZeroU32::MIN)), + lim_gc: RateLimiter::direct(Quota::with_period(Duration::from_secs(1800)).unwrap()), cfg, } } @@ -114,7 +114,7 @@ impl Cache { query: &ArtifactQuery, ip: &IpAddr, ) -> Result { - let subdomain = query.subdomain(); + let subdomain = query.subdomain_noalias(); let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip")); let downloaded = !zip_path.is_file(); if downloaded { diff --git a/src/config.rs b/src/config.rs index 7a7aeb9..2b09e91 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, num::{NonZeroU32, NonZeroUsize}, path::{Path, PathBuf}, sync::Arc, @@ -58,8 +59,12 @@ pub struct ConfigData { pub real_ip_header: Option, /// Limit the amount of downloaded artifacts per IP address and minute pub limit_artifacts_per_min: Option, + /// List of sites/users/repos that can NOT be accessed pub repo_blacklist: QueryFilterList, + /// List of sites/users/repos that can ONLY be accessed pub repo_whitelist: QueryFilterList, + /// Aliases for sites (Example: `gh => github.com`) + pub site_aliases: HashMap, } impl Default for ConfigData { @@ -79,6 +84,7 @@ impl Default for ConfigData { limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()), repo_blacklist: QueryFilterList::default(), repo_whitelist: QueryFilterList::default(), + site_aliases: HashMap::new(), } } } diff --git a/src/error.rs b/src/error.rs index d5710db..336cc47 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,7 +6,7 @@ use axum::{ }; use http::StatusCode; -use crate::{templates, util::InsertTypedHeader}; +use crate::{templates, util::ResponseBuilderExt}; pub type Result = core::result::Result; diff --git a/src/lib.rs b/src/lib.rs index f6cdddd..c3adf4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - mod app; mod artifact_api; mod cache; diff --git a/src/query.rs b/src/query.rs index c96c9a6..5e9d872 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,4 +1,4 @@ -use std::{fmt::Write, hash::Hash, str::FromStr}; +use std::{collections::HashMap, fmt::Write, str::FromStr}; use once_cell::sync::Lazy; use regex::{Captures, Regex}; @@ -18,10 +18,12 @@ pub enum Query { pub type RunQuery = QueryData<()>; pub type ArtifactQuery = QueryData; -#[derive(Debug, PartialEq, Eq, Hash)] +#[derive(Debug, PartialEq, Eq)] pub struct QueryData { /// Forge host pub host: String, + /// Host alias if the query was constructed using one + pub host_alias: Option, /// User/org name (case-insensitive) pub user: String, /// Repository name (case-insensitive) @@ -35,7 +37,7 @@ pub struct QueryData { static RE_REPO_NAME: Lazy = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap()); impl Query { - pub fn from_subdomain(subdomain: &str) -> Result { + pub fn from_subdomain(subdomain: &str, aliases: &HashMap) -> Result { let segments = subdomain.split("--").collect::>(); if segments.len() != 4 { return Err(Error::InvalidUrl); @@ -46,14 +48,22 @@ impl Query { return Err(Error::InvalidUrl); } - let host = decode_domain(segments[0], '.'); + let mut host = decode_domain(segments[0], '.'); + let mut host_alias = None; let user = decode_domain(segments[1], '-'); let repo = decode_domain(segments[2], '-'); let run = run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?; + #[allow(clippy::assigning_clones)] + if let Some(alias) = aliases.get(&host) { + host_alias = Some(host); + host = alias.clone(); + } + Ok(match run_and_artifact.get(1) { Some(x) => Self::Artifact(QueryData { host, + host_alias, user, repo, run, @@ -61,6 +71,7 @@ impl Query { }), None => Self::Run(QueryData { host, + host_alias, user, repo, run, @@ -69,7 +80,7 @@ impl Query { }) } - pub fn from_forge_url(url: &str) -> Result { + pub fn from_forge_url(url: &str, aliases: &HashMap) -> Result { let (host, mut path_segs) = util::parse_url(url)?; let user = path_segs @@ -93,13 +104,20 @@ impl Query { return Err(Error::BadRequest("invalid repository name".into())); } + let host = aliases + .iter() + .find(|(_, v)| *v == host) + .map(|(k, _)| k.to_owned()) + .unwrap_or_else(|| host.to_owned()); + let run = path_segs .next() .and_then(|s| s.parse::().ok()) .ok_or(Error::BadRequest("no run ID".into()))?; Ok(Self::Run(RunQuery { - host: host.to_owned(), + host, + host_alias: None, user, repo, run, @@ -107,7 +125,7 @@ impl Query { })) } - pub fn subdomain(&self) -> String { + pub fn subdomain(&self) -> Result { match self { Query::Artifact(q) => q.subdomain(), Query::Run(q) => q.subdomain(), @@ -130,22 +148,33 @@ impl Query { } impl ArtifactQuery { - pub fn subdomain(&self) -> String { + pub fn subdomain(&self) -> Result { self.subdomain_with_artifact(Some(self.artifact)) } + + /// Non-shortened subdomain (used for cache storage) + pub fn subdomain_noalias(&self) -> String { + self._subdomain(Some(self.artifact), false) + } } impl RunQuery { - pub fn subdomain(&self) -> String { + pub fn subdomain(&self) -> Result { self.subdomain_with_artifact(None) } } impl QueryData { - pub fn subdomain_with_artifact(&self, artifact: Option) -> String { + pub fn _subdomain(&self, artifact: Option, use_alias: bool) -> String { + let host = if use_alias { + self.host_alias.as_deref().unwrap_or(&self.host) + } else { + &self.host + }; + let mut res = format!( "{}--{}--{}--{}", - encode_domain(&self.host, '.'), + encode_domain(host, '.'), encode_domain(&self.user, '-'), encode_domain(&self.repo, '-'), self.run, @@ -156,6 +185,14 @@ impl QueryData { res } + pub fn subdomain_with_artifact(&self, artifact: Option) -> Result { + let res = self._subdomain(artifact, true); + if res.len() > 63 { + return Err(Error::BadRequest("subdomain too long".into())); + } + Ok(res) + } + pub fn shortid(&self) -> String { format!("{}/{}#{}", self.user, self.repo, self.run) } @@ -174,6 +211,7 @@ impl QueryData { pub fn into_runquery(self) -> RunQuery { RunQuery { host: self.host, + host_alias: self.host_alias, user: self.user, repo: self.repo, run: self.run, @@ -348,7 +386,7 @@ impl<'de> Deserialize<'de> for QueryFilterList { #[cfg(test)] mod tests { - use std::str::FromStr; + use std::{collections::HashMap, str::FromStr}; use crate::query::{QueryFilter, QueryFilterList}; @@ -388,18 +426,19 @@ mod tests { #[test] fn query_from_subdomain() { let d1 = "github-com--thetadev--newpipe-extractor--14-123"; - let query = Query::from_subdomain(d1).unwrap(); + let query = Query::from_subdomain(d1, &HashMap::new()).unwrap(); assert_eq!( query, Query::Artifact(ArtifactQuery { host: "github.com".to_owned(), + host_alias: None, user: "thetadev".to_owned(), repo: "newpipe-extractor".to_owned(), run: 14, artifact: 123 }) ); - assert_eq!(query.subdomain(), d1); + assert_eq!(query.subdomain().unwrap(), d1); } #[rstest] diff --git a/src/templates.rs b/src/templates.rs index a1b042d..bc36b21 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -2,6 +2,7 @@ use crate::{ artifact_api::Artifact, cache::{ListingEntry, Size}, config::Config, + error::Result, query::QueryData, }; use yarte::{Render, Template}; @@ -61,14 +62,18 @@ pub struct ArtifactItem { } impl ArtifactItem { - pub fn from_artifact(artifact: Artifact, query: &QueryData, cfg: &Config) -> Self { - Self { + pub fn from_artifact( + artifact: Artifact, + query: &QueryData, + cfg: &Config, + ) -> Result { + Ok(Self { name: artifact.name, - url: cfg.url_with_subdomain(&query.subdomain_with_artifact(Some(artifact.id))), + url: cfg.url_with_subdomain(&query.subdomain_with_artifact(Some(artifact.id))?), size: Size(artifact.size as u32), expired: artifact.expired, download_url: artifact.user_download_url.unwrap_or(artifact.download_url), - } + }) } } diff --git a/src/util.rs b/src/util.rs index 21099ea..2a0d3f7 100644 --- a/src/util.rs +++ b/src/util.rs @@ -8,26 +8,64 @@ use async_zip::error::ZipError; use axum::{ extract::{ConnectInfo, Request}, http::HeaderMap, + response::{IntoResponse, Response}, }; use headers::{Header, HeaderMapExt}; -use http::header; +use http::{header, StatusCode}; use mime_guess::Mime; +use serde::Serialize; use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt}; +use tokio_util::bytes::{BufMut, BytesMut}; use crate::error::{Error, Result}; -pub trait InsertTypedHeader { +/// HTTP response builder extensions +pub trait ResponseBuilderExt { /// Inserts a typed header to this response. fn typed_header(self, header: T) -> Self; + fn cache(self) -> Self; + fn cache_immutable(self) -> Self; + /// Consumes this builder, using the provided json-serializable `val` to return a constructed [`Response`] + fn json(self, val: &T) -> core::result::Result; } -impl InsertTypedHeader for axum::http::response::Builder { +impl ResponseBuilderExt for axum::http::response::Builder { fn typed_header(mut self, header: T) -> Self { if let Some(headers) = self.headers_mut() { headers.typed_insert(header); } self } + + fn cache(self) -> Self { + self.header( + http::header::CACHE_CONTROL, + http::HeaderValue::from_static("max-age=1800,public"), + ) + } + + fn cache_immutable(self) -> Self { + self.header( + http::header::CACHE_CONTROL, + http::HeaderValue::from_static("max-age=31536000,public,immutable"), + ) + } + + fn json(self, val: &T) -> core::result::Result { + // copied from axum::json::into_response + // Use a small initial capacity of 128 bytes like serde_json::to_vec + // https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189 + let mut buf = BytesMut::with_capacity(128).writer(); + match serde_json::to_writer(&mut buf, val) { + Ok(()) => self + .typed_header(headers::ContentType::json()) + .body(buf.into_inner().freeze().into()), + Err(err) => self + .status(StatusCode::INTERNAL_SERVER_ERROR) + .typed_header(headers::ContentType::text()) + .body(err.to_string().into()), + } + } } pub fn accepts_gzip(headers: &HeaderMap) -> bool { @@ -203,6 +241,33 @@ pub fn parse_url(input: &str) -> Result<(&str, std::str::Split)> { Ok((host, parts)) } +#[derive(Serialize)] +pub struct ErrorJson { + status: u16, + msg: String, +} + +impl From for ErrorJson { + fn from(value: Error) -> Self { + Self { + status: value.status().as_u16(), + msg: value.to_string(), + } + } +} + +impl From for ErrorJson { + fn from(value: http::Error) -> Self { + Self::from(Error::from(value)) + } +} + +impl IntoResponse for ErrorJson { + fn into_response(self) -> Response { + Response::builder().json(&self).unwrap() + } +} + #[cfg(test)] mod tests { use http::{header, HeaderMap}; diff --git a/templates/listing.hbs b/templates/listing.hbs index 635c9f6..6c3c476 100644 --- a/templates/listing.hbs +++ b/templates/listing.hbs @@ -158,16 +158,11 @@ diff --git a/templates/selection.hbs b/templates/selection.hbs index 12ab94f..7171640 100644 --- a/templates/selection.hbs +++ b/templates/selection.hbs @@ -170,16 +170,11 @@