Compare commits

..

7 commits

16 changed files with 379 additions and 121 deletions

73
Cargo.lock generated
View file

@ -4,9 +4,9 @@ version = 3
[[package]]
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"

View file

@ -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/*"]

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

BIN
resources/favicon.xcf Normal file

Binary file not shown.

View file

@ -1,4 +1,4 @@
use std::{net::SocketAddr, ops::Bound, path::PathBuf, sync::Arc};
use std::{net::SocketAddr, ops::Bound, path::PathBuf, str::FromStr, sync::Arc};
use async_zip::tokio::read::ZipEntryReader;
use 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())?)
}
}

View file

@ -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,

View file

@ -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 {

View file

@ -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(),
}
}
}

View file

@ -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>;

View file

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

View file

@ -1,4 +1,4 @@
use std::{fmt::Write, hash::Hash, str::FromStr};
use std::{collections::HashMap, fmt::Write, str::FromStr};
use once_cell::sync::Lazy;
use 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]

View file

@ -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),
}
})
}
}

View file

@ -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};

View file

@ -158,15 +158,10 @@
</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 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>

View file

@ -170,15 +170,10 @@
<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 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>