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]]
|
[[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"
|
||||||
|
|
|
@ -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/*"]
|
||||||
|
|
|
@ -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() {
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
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
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 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())?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod artifact_api;
|
mod artifact_api;
|
||||||
mod cache;
|
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 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]
|
||||||
|
|
|
@ -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),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
71
src/util.rs
71
src/util.rs
|
@ -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};
|
||||||
|
|
|
@ -158,15 +158,10 @@
|
||||||
</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
|
|
||||||
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-end
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -170,15 +170,10 @@
|
||||||
|
|
||||||
<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
|
|
||||||
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-end
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in a new issue