Compare commits

..

7 commits

16 changed files with 379 additions and 121 deletions

73
Cargo.lock generated
View file

@ -4,9 +4,9 @@ version = 3
[[package]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.21.0" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
dependencies = [ dependencies = [
"gimli", "gimli",
] ]
@ -184,9 +184,9 @@ dependencies = [
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.10" version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c90a406b4495d129f00461241616194cb8a032c8d1c53c657f0961d5f8e0498" checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5"
dependencies = [ dependencies = [
"bzip2", "bzip2",
"deflate64", "deflate64",
@ -319,9 +319,9 @@ dependencies = [
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.71" version = "0.3.72"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" checksum = "17c6a35df3749d2e8bb1b7b21a976d82b15548788d2735b9d82f329268f71a11"
dependencies = [ dependencies = [
"addr2line", "addr2line",
"cc", "cc",
@ -828,9 +828,9 @@ dependencies = [
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.28.1" version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
[[package]] [[package]]
name = "glob" name = "glob"
@ -1028,9 +1028,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.4" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d8d52be92d09acc2e01dddb7fde3ad983fc6489c7db4837e605bc3fca4cb63e" checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
@ -1241,11 +1241,10 @@ dependencies = [
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.11" version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
dependencies = [ dependencies = [
"lazy_static",
"libc", "libc",
"log", "log",
"openssl", "openssl",
@ -1313,9 +1312,9 @@ dependencies = [
[[package]] [[package]]
name = "object" name = "object"
version = "0.32.2" version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" checksum = "b8ec7ab813848ba4522158d5517a6093db1ded27575b070f4177b8d12b41db5e"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -1506,6 +1505,15 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.84" version = "1.0.84"
@ -1715,9 +1723,9 @@ dependencies = [
[[package]] [[package]]
name = "rstest" name = "rstest"
version = "0.19.0" version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5316d2a1479eeef1ea21e7f9ddc67c191d497abc8fc3ba2467857abbb68330" checksum = "27059f51958c5f8496a6f79511e7c0ac396dd815dc8894e9b6e2efb5779cf6f0"
dependencies = [ dependencies = [
"rstest_macros", "rstest_macros",
"rustc_version", "rustc_version",
@ -1725,12 +1733,13 @@ dependencies = [
[[package]] [[package]]
name = "rstest_macros" name = "rstest_macros"
version = "0.19.0" version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04a9df72cc1f67020b0d63ad9bfe4a323e459ea7eb68e03bd9824db49f9a4c25" checksum = "e6132d64df104c0b3ea7a6ad7766a43f587bd773a4a9cf4cd59296d426afaf3a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"glob", "glob",
"proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
@ -2210,6 +2219,23 @@ dependencies = [
"serde", "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]] [[package]]
name = "tower" name = "tower"
version = "0.4.13" version = "0.4.13"
@ -2707,6 +2733,15 @@ version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.52.0" version = "0.52.0"

View file

@ -33,7 +33,7 @@ serde_json = "1.0.117"
thiserror = "1.0.61" thiserror = "1.0.61"
tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] } tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] }
tokio-util = { version = "0.7.11", features = ["io"] } 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 = "0.1.40"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"
url = "2.5.0" url = "2.5.0"
@ -44,7 +44,7 @@ yarte_helpers = "0.15.8"
[dev-dependencies] [dev-dependencies]
proptest = "1.4.0" proptest = "1.4.0"
rstest = { version = "0.19.0", default-features = false } rstest = { version = "0.20.0", default-features = false }
[workspace] [workspace]
members = [".", "crates/*"] members = [".", "crates/*"]

View file

@ -62,11 +62,7 @@ use serde::de::{
value::{MapDeserializer, SeqDeserializer}, value::{MapDeserializer, SeqDeserializer},
IntoDeserializer, IntoDeserializer,
}; };
use std::{ use std::{borrow::Cow, env, iter::IntoIterator, str::MatchIndices};
borrow::Cow,
env,
iter::{empty, IntoIterator},
};
// Ours // Ours
mod error; mod error;
@ -137,19 +133,19 @@ impl<'de> de::Deserializer<'de> for Val {
where where
V: de::Visitor<'de>, V: de::Visitor<'de>,
{ {
// std::str::split doesn't work as expected for our use case: when we let values = SplitEscaped::new(&self.1, ";", true);
// get an empty string we want to produce an empty Vec, but split would SeqDeserializer::new(values.map(|v| Val(self.0.clone(), v))).deserialize_seq(visitor)
// still yield an iterator with an empty string in it. So we need to }
// special case empty strings.
if self.1.is_empty() { fn deserialize_map<V>(self, visitor: V) -> Result<V::Value>
SeqDeserializer::new(empty::<Val>()).deserialize_seq(visitor) where
} else { V: de::Visitor<'de>,
let values = self {
.1 MapDeserializer::new(SplitEscaped::new(&self.1, ";", false).filter_map(|pair| {
.split(',') let mut parts = SplitEscaped::new(&pair, "=>", true);
.map(|v| Val(self.0.clone(), v.trim().to_owned())); parts.next().zip(parts.next())
SeqDeserializer::new(values).deserialize_seq(visitor) }))
} .deserialize_map(visitor)
} }
fn deserialize_option<V>(self, visitor: V) -> Result<V::Value> fn deserialize_option<V>(self, visitor: V) -> Result<V::Value>
@ -216,7 +212,7 @@ impl<'de> de::Deserializer<'de> for Val {
serde::forward_to_deserialize_any! { serde::forward_to_deserialize_any! {
char str string unit char str string unit
bytes byte_buf map unit_struct tuple_struct bytes byte_buf unit_struct tuple_struct
identifier tuple ignored_any identifier tuple ignored_any
struct struct
} }
@ -372,6 +368,63 @@ where
Prefixed(prefix.into()) 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<Self::Item> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -416,7 +469,7 @@ mod tests {
let data = vec![ let data = vec![
(String::from("BAR"), String::from("test")), (String::from("BAR"), String::from("test")),
(String::from("BAZ"), String::from("true")), (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. // Empty string should result in empty vector.
(String::from("BOOM"), String::from("")), (String::from("BOOM"), String::from("")),
(String::from("SIZE"), String::from("small")), (String::from("SIZE"), String::from("small")),
@ -479,7 +532,7 @@ mod tests {
Ok(_) => panic!("expected failure"), Ok(_) => panic!("expected failure"),
Err(e) => assert_eq!( Err(e) => assert_eq!(
e, 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_BAR"), String::from("test")),
(String::from("APP_BAZ"), String::from("true")), (String::from("APP_BAZ"), String::from("true")),
(String::from("APP_DOOM"), String::from("")), (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_SIZE"), String::from("small")),
(String::from("APP_PROVIDED"), String::from("test")), (String::from("APP_PROVIDED"), String::from("test")),
(String::from("APP_NEWTYPE"), String::from("42")), (String::from("APP_NEWTYPE"), String::from("42")),
@ -557,4 +610,34 @@ mod tests {
let res = from_iter::<_, X>(data).unwrap(); let res = from_iter::<_, X>(data).unwrap();
assert_eq!(res.val, None) assert_eq!(res.val, None)
} }
#[test]
fn deserialize_map() {
#[derive(Deserialize)]
struct X {
val: HashMap<String, String>,
}
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::<Vec<_>>();
assert_eq!(res, [r"He\llo", "Wor=>ld"]);
let input = r"4;5";
let res = SplitEscaped::new(input, ";", true).collect::<Vec<_>>();
assert_eq!(res, ["4", "5"]);
assert!(SplitEscaped::new("", ";", true).next().is_none());
}
} }

BIN
resources/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

BIN
resources/favicon.xcf Normal file

Binary file not shown.

View file

@ -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 async_zip::tokio::read::ZipEntryReader;
use axum::{ use axum::{
@ -7,7 +7,7 @@ use axum::{
http::{Response, Uri}, http::{Response, Uri},
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
routing::{any, get, post}, routing::{any, get, post},
Form, Json, Router, Form, Router,
}; };
use headers::HeaderMapExt; use headers::HeaderMapExt;
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, StatusCode};
@ -20,17 +20,20 @@ use tokio_util::{
compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}, compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt},
io::ReaderStream, io::ReaderStream,
}; };
use tower_http::trace::{DefaultOnResponse, TraceLayer}; use tower_http::{
set_header::SetResponseHeaderLayer,
trace::{DefaultOnResponse, TraceLayer},
};
use crate::{ use crate::{
artifact_api::{Artifact, ArtifactApi}, artifact_api::ArtifactApi,
cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile, IndexEntry}, cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile},
config::Config, config::Config,
error::{Error, Result}, error::Error,
gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN}, gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN},
query::Query, query::Query,
templates::{self, ArtifactItem, LinkItem}, templates::{self, ArtifactItem, LinkItem},
util::{self, InsertTypedHeader}, util::{self, ErrorJson, ResponseBuilderExt},
App, App,
}; };
@ -56,6 +59,9 @@ struct UrlForm {
url: String, url: String,
} }
const FAVICON_PATH: &str = "/favicon.ico";
const FAVICON_BYTES: &[u8; 268] = include_bytes!("../resources/favicon.ico");
impl App { impl App {
pub fn new() -> Self { pub fn new() -> Self {
Self Self
@ -65,7 +71,7 @@ impl App {
AppState::new() AppState::new()
} }
pub async fn run(&self) -> Result<()> { pub async fn run(&self) -> Result<(), Error> {
let address = "0.0.0.0:3000"; let address = "0.0.0.0:3000";
let listener = tokio::net::TcpListener::bind(address).await?; let listener = tokio::net::TcpListener::bind(address).await?;
tracing::info!("Listening on http://{address}"); tracing::info!("Listening on http://{address}");
@ -98,7 +104,8 @@ impl App {
tracing::error_span!("request", url = util::full_url_from_request(request), ip) tracing::error_span!("request", url = util::full_url_from_request(request), ip)
}) })
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)),
); )
.layer(SetResponseHeaderLayer::appending(http::header::X_CONTENT_TYPE_OPTIONS, http::HeaderValue::from_static("nosniff")));
axum::serve( axum::serve(
listener, listener,
router.into_make_service_with_connect_info::<SocketAddr>(), router.into_make_service_with_connect_info::<SocketAddr>(),
@ -112,19 +119,23 @@ impl App {
Host(host): Host, Host(host): Host,
uri: Uri, uri: Uri,
request: Request, request: Request,
) -> Result<Response<Body>> { ) -> Result<Response<Body>, Error> {
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
if subdomain.is_empty() { if subdomain.is_empty() {
// Main page // Main page
if uri.path() == FAVICON_PATH {
return Self::favicon();
}
if uri.path() != "/" { if uri.path() != "/" {
return Err(Error::NotFound("path".into())); return Err(Error::NotFound("path".into()));
} }
Ok(Response::builder() Ok(Response::builder()
.typed_header(headers::ContentType::html()) .typed_header(headers::ContentType::html())
.cache()
.body(templates::Index::default().to_string().into())?) .body(templates::Index::default().to_string().into())?)
} else { } 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)?; state.i.cfg.check_filterlist(&query)?;
let path = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy(); let path = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy();
let hdrs = request.headers(); let hdrs = request.headers();
@ -138,12 +149,12 @@ impl App {
state.garbage_collect(); state.garbage_collect();
} }
match entry.get_file(&path, uri.query().unwrap_or_default())? { match entry.get_file(&path, uri.query().unwrap_or_default()) {
GetFileResult::File(res) => { Ok(GetFileResult::File(res)) => {
Self::serve_artifact_file(state, entry, entry_res.zip_path, res, hdrs) Self::serve_artifact_file(state, entry, entry_res.zip_path, res, hdrs)
.await .await
} }
GetFileResult::Listing(listing) => { Ok(GetFileResult::Listing(listing)) => {
if !path.ends_with('/') { if !path.ends_with('/') {
return Ok(Redirect::to(&format!("{path}/")).into_response()); return Ok(Redirect::to(&format!("{path}/")).into_response());
} }
@ -154,7 +165,7 @@ impl App {
url: state url: state
.i .i
.cfg .cfg
.url_with_subdomain(&query.subdomain_with_artifact(None)), .url_with_subdomain(&query.subdomain_with_artifact(None)?),
}, },
LinkItem { LinkItem {
name: entry.name.to_owned(), name: entry.name.to_owned(),
@ -185,13 +196,25 @@ impl App {
Ok(Response::builder() Ok(Response::builder()
.typed_header(headers::ContentType::html()) .typed_header(headers::ContentType::html())
.cache_immutable()
.body(tmpl.to_string().into())?) .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) => { Query::Run(query) => {
let artifacts = state.i.api.list(&query).await?; let artifacts = state.i.api.list(&query).await?;
if uri.path() == FAVICON_PATH {
return Self::favicon();
}
if uri.path() != "/" { if uri.path() != "/" {
return Err(Error::NotFound("path".into())); return Err(Error::NotFound("path".into()));
} }
@ -210,10 +233,11 @@ impl App {
artifacts: artifacts artifacts: artifacts
.into_iter() .into_iter()
.map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg)) .map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg))
.collect(), .collect::<Result<Vec<_>, _>>()?,
}; };
Ok(Response::builder() Ok(Response::builder()
.typed_header(headers::ContentType::html()) .typed_header(headers::ContentType::html())
.cache()
.body(tmpl.to_string().into())?) .body(tmpl.to_string().into())?)
} }
} }
@ -224,12 +248,12 @@ impl App {
State(state): State<AppState>, State(state): State<AppState>,
Host(host): Host, Host(host): Host,
Form(url): Form<UrlForm>, Form(url): Form<UrlForm>,
) -> Result<Redirect> { ) -> Result<Redirect, Error> {
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
if subdomain.is_empty() { if subdomain.is_empty() {
let query = Query::from_forge_url(&url.url)?; let query = Query::from_forge_url(&url.url, &state.i.cfg.load().site_aliases)?;
let subdomain = query.subdomain(); let subdomain = query.subdomain()?;
let target = format!( let target = format!(
"{}{}.{}", "{}{}.{}",
state.i.cfg.url_proto(), state.i.cfg.url_proto(),
@ -248,7 +272,7 @@ impl App {
zip_path: PathBuf, zip_path: PathBuf,
res: GetFileResultFile, res: GetFileResultFile,
hdrs: &HeaderMap, hdrs: &HeaderMap,
) -> Result<Response<Body>> { ) -> Result<Response<Body>, Error> {
let file = res.file; let file = res.file;
// Dont serve files above the configured size limit // Dont serve files above the configured size limit
@ -267,7 +291,8 @@ impl App {
let mut resp = Response::builder() let mut resp = Response::builder()
.status(res.status) .status(res.status)
.typed_header(headers::AcceptRanges::bytes()) .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 { if let Some(mime) = res.mime {
resp = resp.typed_header(headers::ContentType::from(mime)); resp = resp.typed_header(headers::ContentType::from(mime));
} }
@ -366,24 +391,24 @@ impl App {
async fn get_artifacts( async fn get_artifacts(
State(state): State<AppState>, State(state): State<AppState>,
Host(host): Host, Host(host): Host,
) -> Result<Json<Vec<Artifact>>> { ) -> Result<Response<Body>, ErrorJson> {
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; 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)?; state.i.cfg.check_filterlist(&query)?;
let artifacts = state.i.api.list(&query.into_runquery()).await?; 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 /// API endpoint to get the metadata of the current artifact
async fn get_artifact( async fn get_artifact(
State(state): State<AppState>, State(state): State<AppState>,
Host(host): Host, Host(host): Host,
) -> Result<Json<Artifact>> { ) -> Result<Response<Body>, ErrorJson> {
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; 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)?; state.i.cfg.check_filterlist(&query)?;
let artifact = state.i.api.fetch(&query.try_into_artifactquery()?).await?; 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 /// API endpoint to get a file listing
@ -391,10 +416,10 @@ impl App {
State(state): State<AppState>, State(state): State<AppState>,
Host(host): Host, Host(host): Host,
request: Request, request: Request,
) -> Result<Json<Vec<IndexEntry>>> { ) -> Result<Response<Body>, ErrorJson> {
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; 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 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)?; state.i.cfg.check_filterlist(&query)?;
let entry_res = state let entry_res = state
.i .i
@ -405,7 +430,17 @@ impl App {
state.garbage_collect(); state.garbage_collect();
} }
let files = entry_res.entry.get_files(); 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<Response<Body>, Error> {
Ok(Response::builder()
.typed_header(headers::ContentType::from_str("image/x-icon").unwrap())
.cache_immutable()
.body(FAVICON_BYTES.as_slice().into())?)
} }
} }

View file

@ -112,7 +112,7 @@ impl ArtifactApi {
} }
pub async fn list<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> { pub async fn list<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
let subdomain = query.subdomain_with_artifact(None); let subdomain = query.subdomain_with_artifact(None)?;
self.qc self.qc
.get_or_insert_async(&subdomain, async { .get_or_insert_async(&subdomain, async {
if query.is_github() { if query.is_github() {
@ -277,6 +277,7 @@ mod tests {
async fn fetch_forgejo() { async fn fetch_forgejo() {
let query = ArtifactQuery { let query = ArtifactQuery {
host: "code.thetadev.de".to_owned(), host: "code.thetadev.de".to_owned(),
host_alias: None,
user: "HSA".to_owned(), user: "HSA".to_owned(),
repo: "Visitenbuch".to_owned(), repo: "Visitenbuch".to_owned(),
run: 32, run: 32,
@ -293,6 +294,7 @@ mod tests {
async fn fetch_github() { async fn fetch_github() {
let query = ArtifactQuery { let query = ArtifactQuery {
host: "github.com".to_owned(), host: "github.com".to_owned(),
host_alias: None,
user: "actions".to_owned(), user: "actions".to_owned(),
repo: "upload-artifact".to_owned(), repo: "upload-artifact".to_owned(),
run: 8805345396, run: 8805345396,

View file

@ -3,7 +3,7 @@ use std::{
collections::{BTreeMap, HashMap}, collections::{BTreeMap, HashMap},
fs::FileTimes, fs::FileTimes,
net::IpAddr, net::IpAddr,
num::{NonZeroU32, NonZeroUsize}, num::NonZeroUsize,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
time::{Duration, SystemTime}, time::{Duration, SystemTime},
@ -103,7 +103,7 @@ impl Cache {
.load() .load()
.limit_artifacts_per_min .limit_artifacts_per_min
.map(|lim| RateLimiter::keyed(Quota::per_minute(lim))), .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, cfg,
} }
} }
@ -114,7 +114,7 @@ impl Cache {
query: &ArtifactQuery, query: &ArtifactQuery,
ip: &IpAddr, ip: &IpAddr,
) -> Result<GetEntryResult> { ) -> Result<GetEntryResult> {
let subdomain = query.subdomain(); let subdomain = query.subdomain_noalias();
let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip")); let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip"));
let downloaded = !zip_path.is_file(); let downloaded = !zip_path.is_file();
if downloaded { if downloaded {

View file

@ -1,4 +1,5 @@
use std::{ use std::{
collections::HashMap,
num::{NonZeroU32, NonZeroUsize}, num::{NonZeroU32, NonZeroUsize},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
@ -58,8 +59,12 @@ pub struct ConfigData {
pub real_ip_header: Option<String>, pub real_ip_header: Option<String>,
/// Limit the amount of downloaded artifacts per IP address and minute /// Limit the amount of downloaded artifacts per IP address and minute
pub limit_artifacts_per_min: Option<NonZeroU32>, pub limit_artifacts_per_min: Option<NonZeroU32>,
/// List of sites/users/repos that can NOT be accessed
pub repo_blacklist: QueryFilterList, pub repo_blacklist: QueryFilterList,
/// List of sites/users/repos that can ONLY be accessed
pub repo_whitelist: QueryFilterList, pub repo_whitelist: QueryFilterList,
/// Aliases for sites (Example: `gh => github.com`)
pub site_aliases: HashMap<String, String>,
} }
impl Default for ConfigData { impl Default for ConfigData {
@ -79,6 +84,7 @@ impl Default for ConfigData {
limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()), limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()),
repo_blacklist: QueryFilterList::default(), repo_blacklist: QueryFilterList::default(),
repo_whitelist: QueryFilterList::default(), repo_whitelist: QueryFilterList::default(),
site_aliases: HashMap::new(),
} }
} }
} }

View file

@ -6,7 +6,7 @@ use axum::{
}; };
use http::StatusCode; use http::StatusCode;
use crate::{templates, util::InsertTypedHeader}; use crate::{templates, util::ResponseBuilderExt};
pub type Result<T> = core::result::Result<T, Error>; pub type Result<T> = core::result::Result<T, Error>;

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
mod app; mod app;
mod artifact_api; mod artifact_api;
mod cache; mod cache;

View file

@ -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 once_cell::sync::Lazy;
use regex::{Captures, Regex}; use regex::{Captures, Regex};
@ -18,10 +18,12 @@ pub enum Query {
pub type RunQuery = QueryData<()>; pub type RunQuery = QueryData<()>;
pub type ArtifactQuery = QueryData<u64>; pub type ArtifactQuery = QueryData<u64>;
#[derive(Debug, PartialEq, Eq, Hash)] #[derive(Debug, PartialEq, Eq)]
pub struct QueryData<T> { pub struct QueryData<T> {
/// Forge host /// Forge host
pub host: String, pub host: String,
/// Host alias if the query was constructed using one
pub host_alias: Option<String>,
/// User/org name (case-insensitive) /// User/org name (case-insensitive)
pub user: String, pub user: String,
/// Repository name (case-insensitive) /// Repository name (case-insensitive)
@ -35,7 +37,7 @@ pub struct QueryData<T> {
static RE_REPO_NAME: Lazy<Regex> = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap()); static RE_REPO_NAME: Lazy<Regex> = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap());
impl Query { impl Query {
pub fn from_subdomain(subdomain: &str) -> Result<Self> { pub fn from_subdomain(subdomain: &str, aliases: &HashMap<String, String>) -> Result<Self> {
let segments = subdomain.split("--").collect::<Vec<_>>(); let segments = subdomain.split("--").collect::<Vec<_>>();
if segments.len() != 4 { if segments.len() != 4 {
return Err(Error::InvalidUrl); return Err(Error::InvalidUrl);
@ -46,14 +48,22 @@ impl Query {
return Err(Error::InvalidUrl); 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 user = decode_domain(segments[1], '-');
let repo = decode_domain(segments[2], '-'); let repo = decode_domain(segments[2], '-');
let run = run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?; 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) { Ok(match run_and_artifact.get(1) {
Some(x) => Self::Artifact(QueryData { Some(x) => Self::Artifact(QueryData {
host, host,
host_alias,
user, user,
repo, repo,
run, run,
@ -61,6 +71,7 @@ impl Query {
}), }),
None => Self::Run(QueryData { None => Self::Run(QueryData {
host, host,
host_alias,
user, user,
repo, repo,
run, run,
@ -69,7 +80,7 @@ impl Query {
}) })
} }
pub fn from_forge_url(url: &str) -> Result<Self> { pub fn from_forge_url(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
let (host, mut path_segs) = util::parse_url(url)?; let (host, mut path_segs) = util::parse_url(url)?;
let user = path_segs let user = path_segs
@ -93,13 +104,20 @@ impl Query {
return Err(Error::BadRequest("invalid repository name".into())); 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 let run = path_segs
.next() .next()
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
.ok_or(Error::BadRequest("no run ID".into()))?; .ok_or(Error::BadRequest("no run ID".into()))?;
Ok(Self::Run(RunQuery { Ok(Self::Run(RunQuery {
host: host.to_owned(), host,
host_alias: None,
user, user,
repo, repo,
run, run,
@ -107,7 +125,7 @@ impl Query {
})) }))
} }
pub fn subdomain(&self) -> String { pub fn subdomain(&self) -> Result<String> {
match self { match self {
Query::Artifact(q) => q.subdomain(), Query::Artifact(q) => q.subdomain(),
Query::Run(q) => q.subdomain(), Query::Run(q) => q.subdomain(),
@ -130,22 +148,33 @@ impl Query {
} }
impl ArtifactQuery { impl ArtifactQuery {
pub fn subdomain(&self) -> String { pub fn subdomain(&self) -> Result<String> {
self.subdomain_with_artifact(Some(self.artifact)) 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 { impl RunQuery {
pub fn subdomain(&self) -> String { pub fn subdomain(&self) -> Result<String> {
self.subdomain_with_artifact(None) self.subdomain_with_artifact(None)
} }
} }
impl<T> QueryData<T> { impl<T> QueryData<T> {
pub fn subdomain_with_artifact(&self, artifact: Option<u64>) -> String { pub fn _subdomain(&self, artifact: Option<u64>, 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!( let mut res = format!(
"{}--{}--{}--{}", "{}--{}--{}--{}",
encode_domain(&self.host, '.'), encode_domain(host, '.'),
encode_domain(&self.user, '-'), encode_domain(&self.user, '-'),
encode_domain(&self.repo, '-'), encode_domain(&self.repo, '-'),
self.run, self.run,
@ -156,6 +185,14 @@ impl<T> QueryData<T> {
res res
} }
pub fn subdomain_with_artifact(&self, artifact: Option<u64>) -> Result<String> {
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 { pub fn shortid(&self) -> String {
format!("{}/{}#{}", self.user, self.repo, self.run) format!("{}/{}#{}", self.user, self.repo, self.run)
} }
@ -174,6 +211,7 @@ impl<T> QueryData<T> {
pub fn into_runquery(self) -> RunQuery { pub fn into_runquery(self) -> RunQuery {
RunQuery { RunQuery {
host: self.host, host: self.host,
host_alias: self.host_alias,
user: self.user, user: self.user,
repo: self.repo, repo: self.repo,
run: self.run, run: self.run,
@ -348,7 +386,7 @@ impl<'de> Deserialize<'de> for QueryFilterList {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::str::FromStr; use std::{collections::HashMap, str::FromStr};
use crate::query::{QueryFilter, QueryFilterList}; use crate::query::{QueryFilter, QueryFilterList};
@ -388,18 +426,19 @@ mod tests {
#[test] #[test]
fn query_from_subdomain() { fn query_from_subdomain() {
let d1 = "github-com--thetadev--newpipe-extractor--14-123"; 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!( assert_eq!(
query, query,
Query::Artifact(ArtifactQuery { Query::Artifact(ArtifactQuery {
host: "github.com".to_owned(), host: "github.com".to_owned(),
host_alias: None,
user: "thetadev".to_owned(), user: "thetadev".to_owned(),
repo: "newpipe-extractor".to_owned(), repo: "newpipe-extractor".to_owned(),
run: 14, run: 14,
artifact: 123 artifact: 123
}) })
); );
assert_eq!(query.subdomain(), d1); assert_eq!(query.subdomain().unwrap(), d1);
} }
#[rstest] #[rstest]

View file

@ -2,6 +2,7 @@ use crate::{
artifact_api::Artifact, artifact_api::Artifact,
cache::{ListingEntry, Size}, cache::{ListingEntry, Size},
config::Config, config::Config,
error::Result,
query::QueryData, query::QueryData,
}; };
use yarte::{Render, Template}; use yarte::{Render, Template};
@ -61,14 +62,18 @@ pub struct ArtifactItem {
} }
impl ArtifactItem { impl ArtifactItem {
pub fn from_artifact<T>(artifact: Artifact, query: &QueryData<T>, cfg: &Config) -> Self { pub fn from_artifact<T>(
Self { artifact: Artifact,
query: &QueryData<T>,
cfg: &Config,
) -> Result<Self> {
Ok(Self {
name: artifact.name, 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), size: Size(artifact.size as u32),
expired: artifact.expired, expired: artifact.expired,
download_url: artifact.user_download_url.unwrap_or(artifact.download_url), download_url: artifact.user_download_url.unwrap_or(artifact.download_url),
} })
} }
} }

View file

@ -8,26 +8,64 @@ use async_zip::error::ZipError;
use axum::{ use axum::{
extract::{ConnectInfo, Request}, extract::{ConnectInfo, Request},
http::HeaderMap, http::HeaderMap,
response::{IntoResponse, Response},
}; };
use headers::{Header, HeaderMapExt}; use headers::{Header, HeaderMapExt};
use http::header; use http::{header, StatusCode};
use mime_guess::Mime; use mime_guess::Mime;
use serde::Serialize;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt};
use tokio_util::bytes::{BufMut, BytesMut};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
pub trait InsertTypedHeader { /// HTTP response builder extensions
pub trait ResponseBuilderExt {
/// Inserts a typed header to this response. /// Inserts a typed header to this response.
fn typed_header<T: Header>(self, header: T) -> Self; fn typed_header<T: 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<T: Serialize>(self, val: &T) -> core::result::Result<Response, http::Error>;
} }
impl InsertTypedHeader for axum::http::response::Builder { impl ResponseBuilderExt for axum::http::response::Builder {
fn typed_header<T: Header>(mut self, header: T) -> Self { fn typed_header<T: Header>(mut self, header: T) -> Self {
if let Some(headers) = self.headers_mut() { if let Some(headers) = self.headers_mut() {
headers.typed_insert(header); headers.typed_insert(header);
} }
self 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<T: Serialize>(self, val: &T) -> core::result::Result<Response, http::Error> {
// 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 { pub fn accepts_gzip(headers: &HeaderMap) -> bool {
@ -203,6 +241,33 @@ pub fn parse_url(input: &str) -> Result<(&str, std::str::Split<char>)> {
Ok((host, parts)) Ok((host, parts))
} }
#[derive(Serialize)]
pub struct ErrorJson {
status: u16,
msg: String,
}
impl From<Error> for ErrorJson {
fn from(value: Error) -> Self {
Self {
status: value.status().as_u16(),
msg: value.to_string(),
}
}
}
impl From<http::Error> 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)] #[cfg(test)]
mod tests { mod tests {
use http::{header, HeaderMap}; use http::{header, HeaderMap};

View file

@ -158,16 +158,11 @@
</footer> </footer>
<script> <script>
// @license // @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT var
filterEl=document.getElementById("filter");function var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
initFilter(){if(!filterEl.value){var filterParam=new
URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function // @license-end
filter(){var q=filterEl.value.trim().toLowerCase();var
elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var
nameEl=el.querySelector("td");var
nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
// @license-end
</script> </script>
</body> </body>

View file

@ -170,16 +170,11 @@
<script> <script>
// @license // @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT var
filterEl=document.getElementById("filter");function var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
initFilter(){if(!filterEl.value){var filterParam=new
URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function // @license-end
filter(){var q=filterEl.value.trim().toLowerCase();var
elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var
nameEl=el.querySelector("td");var
nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
// @license-end
</script> </script>
</body> </body>