Compare commits
7 commits
334955db43
...
7bf4728416
Author | SHA1 | Date | |
---|---|---|---|
7bf4728416 | |||
8ada5a3d9f | |||
3711eb1231 | |||
6b865aaf5a | |||
fe820fa698 | |||
9360f5837c | |||
8d46314faf |
16 changed files with 379 additions and 121 deletions
73
Cargo.lock
generated
73
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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/*"]
|
||||
|
|
|
@ -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::<Val>()).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<V>(self, visitor: V) -> Result<V::Value>
|
||||
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<V>(self, visitor: V) -> Result<V::Value>
|
||||
|
@ -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<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)]
|
||||
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<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
BIN
resources/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 268 B |
BIN
resources/favicon.xcf
Normal file
BIN
resources/favicon.xcf
Normal file
Binary file not shown.
95
src/app.rs
95
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::<SocketAddr>(),
|
||||
|
@ -112,19 +119,23 @@ impl App {
|
|||
Host(host): Host,
|
||||
uri: Uri,
|
||||
request: Request,
|
||||
) -> Result<Response<Body>> {
|
||||
) -> Result<Response<Body>, 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::<Result<Vec<_>, _>>()?,
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.typed_header(headers::ContentType::html())
|
||||
.cache()
|
||||
.body(tmpl.to_string().into())?)
|
||||
}
|
||||
}
|
||||
|
@ -224,12 +248,12 @@ impl App {
|
|||
State(state): State<AppState>,
|
||||
Host(host): Host,
|
||||
Form(url): Form<UrlForm>,
|
||||
) -> Result<Redirect> {
|
||||
) -> Result<Redirect, Error> {
|
||||
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<Response<Body>> {
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
Host(host): Host,
|
||||
) -> Result<Json<Vec<Artifact>>> {
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
Host(host): Host,
|
||||
) -> Result<Json<Artifact>> {
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
Host(host): Host,
|
||||
request: Request,
|
||||
) -> Result<Json<Vec<IndexEntry>>> {
|
||||
) -> Result<Response<Body>, 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<Response<Body>, Error> {
|
||||
Ok(Response::builder()
|
||||
.typed_header(headers::ContentType::from_str("image/x-icon").unwrap())
|
||||
.cache_immutable()
|
||||
.body(FAVICON_BYTES.as_slice().into())?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -112,7 +112,7 @@ impl ArtifactApi {
|
|||
}
|
||||
|
||||
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
|
||||
.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,
|
||||
|
|
|
@ -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<GetEntryResult> {
|
||||
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 {
|
||||
|
|
|
@ -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<String>,
|
||||
/// Limit the amount of downloaded artifacts per IP address and minute
|
||||
pub limit_artifacts_per_min: Option<NonZeroU32>,
|
||||
/// 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<String, String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ use axum::{
|
|||
};
|
||||
use http::StatusCode;
|
||||
|
||||
use crate::{templates, util::InsertTypedHeader};
|
||||
use crate::{templates, util::ResponseBuilderExt};
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
#![allow(dead_code)]
|
||||
|
||||
mod app;
|
||||
mod artifact_api;
|
||||
mod cache;
|
||||
|
|
67
src/query.rs
67
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<u64>;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct QueryData<T> {
|
||||
/// Forge host
|
||||
pub host: String,
|
||||
/// Host alias if the query was constructed using one
|
||||
pub host_alias: Option<String>,
|
||||
/// User/org name (case-insensitive)
|
||||
pub user: String,
|
||||
/// 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());
|
||||
|
||||
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<_>>();
|
||||
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<Self> {
|
||||
pub fn from_forge_url(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
||||
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::<u64>().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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
self.subdomain_with_artifact(None)
|
||||
}
|
||||
}
|
||||
|
||||
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!(
|
||||
"{}--{}--{}--{}",
|
||||
encode_domain(&self.host, '.'),
|
||||
encode_domain(host, '.'),
|
||||
encode_domain(&self.user, '-'),
|
||||
encode_domain(&self.repo, '-'),
|
||||
self.run,
|
||||
|
@ -156,6 +185,14 @@ impl<T> QueryData<T> {
|
|||
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 {
|
||||
format!("{}/{}#{}", self.user, self.repo, self.run)
|
||||
}
|
||||
|
@ -174,6 +211,7 @@ impl<T> QueryData<T> {
|
|||
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]
|
||||
|
|
|
@ -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<T>(artifact: Artifact, query: &QueryData<T>, cfg: &Config) -> Self {
|
||||
Self {
|
||||
pub fn from_artifact<T>(
|
||||
artifact: Artifact,
|
||||
query: &QueryData<T>,
|
||||
cfg: &Config,
|
||||
) -> Result<Self> {
|
||||
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),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
71
src/util.rs
71
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<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 {
|
||||
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<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 {
|
||||
|
@ -203,6 +241,33 @@ pub fn parse_url(input: &str) -> Result<(&str, std::str::Split<char>)> {
|
|||
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)]
|
||||
mod tests {
|
||||
use http::{header, HeaderMap};
|
||||
|
|
|
@ -158,16 +158,11 @@
|
|||
</footer>
|
||||
<script>
|
||||
|
||||
// @license
|
||||
magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT 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"}})}
|
||||
// @license-end
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
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"}})}
|
||||
|
||||
// @license-end
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
@ -170,16 +170,11 @@
|
|||
|
||||
<script>
|
||||
|
||||
// @license
|
||||
magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT 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"}})}
|
||||
// @license-end
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
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"}})}
|
||||
|
||||
// @license-end
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
|
Loading…
Reference in a new issue