Compare commits

..

11 commits

16 changed files with 1209 additions and 581 deletions

View file

@ -10,5 +10,5 @@ max_line_length = 88
[{Makefile,*.go}] [{Makefile,*.go}]
indent_style = tab indent_style = tab
[*.{json,md,rst,ini,yml,yaml,xml,html,js,jsx,ts,tsx,vue,svelte}] [*.{json,md,rst,ini,yml,yaml,xml,html,js,jsx,ts,tsx,vue,svelte,hbs}]
indent_size = 2 indent_size = 2

167
Cargo.lock generated
View file

@ -150,9 +150,11 @@ dependencies = [
"envy", "envy",
"flate2", "flate2",
"futures-lite", "futures-lite",
"governor",
"headers", "headers",
"hex", "hex",
"http", "http",
"humansize",
"mime", "mime",
"mime_guess", "mime_guess",
"once_cell", "once_cell",
@ -169,7 +171,6 @@ dependencies = [
"serde-env", "serde-env",
"serde-hex", "serde-hex",
"serde_json", "serde_json",
"siphasher",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-util", "tokio-util",
@ -531,6 +532,19 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "deflate64" name = "deflate64"
version = "0.1.8" version = "0.1.8"
@ -683,6 +697,21 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "futures"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.30" version = "0.3.30"
@ -690,6 +719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@ -698,6 +728,17 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
[[package]]
name = "futures-executor"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.30" version = "0.3.30"
@ -717,6 +758,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "futures-macro"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.30" version = "0.3.30"
@ -729,16 +781,28 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.30" version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab",
] ]
[[package]] [[package]]
@ -774,6 +838,26 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "governor"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
dependencies = [
"cfg-if",
"dashmap",
"futures",
"futures-timer",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand",
"smallvec 1.13.2",
"spinning_top",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.5" version = "0.4.5"
@ -890,6 +974,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]] [[package]]
name = "humantime" name = "humantime"
version = "2.1.0" version = "2.1.0"
@ -1164,12 +1257,24 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]] [[package]]
name = "nodrop" name = "nodrop"
version = "0.1.14" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -1373,6 +1478,12 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "portable-atomic"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0"
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@ -1424,6 +1535,21 @@ dependencies = [
"unarray", "unarray",
] ]
[[package]]
name = "quanta"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
@ -1490,6 +1616,15 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "raw-cpuid"
version = "11.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e29830cbb1290e404f24c73af91c5d8d631ce7e128691e9477556b540cd01ecd"
dependencies = [
"bitflags 2.5.0",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.1" version = "0.5.1"
@ -1568,10 +1703,12 @@ dependencies = [
"system-configuration", "system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-util",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"winreg", "winreg",
] ]
@ -1830,12 +1967,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -1870,6 +2001,15 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.5.0" version = "2.5.0"
@ -2374,6 +2514,19 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "wasm-streams"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.69" version = "0.3.69"

View file

@ -11,9 +11,11 @@ dotenvy = "0.15.7"
envy = { path = "crates/envy" } envy = { path = "crates/envy" }
flate2 = "1.0.30" flate2 = "1.0.30"
futures-lite = "2.3.0" futures-lite = "2.3.0"
governor = "0.6.3"
headers = "0.4.0" headers = "0.4.0"
hex = "0.4.3" hex = "0.4.3"
http = "1.1.0" http = "1.1.0"
humansize = "2.1.3"
mime = "0.3.17" mime = "0.3.17"
mime_guess = "2.0.4" mime_guess = "2.0.4"
once_cell = "1.19.0" once_cell = "1.19.0"
@ -23,12 +25,11 @@ pin-project = "1.1.5"
quick_cache = "0.5.1" quick_cache = "0.5.1"
rand = "0.8.5" rand = "0.8.5"
regex = "1.10.4" regex = "1.10.4"
reqwest = { version = "0.12.4", features = ["json"] } reqwest = { version = "0.12.4", features = ["json", "stream"] }
serde = { version = "1.0.203", features = ["derive"] } serde = { version = "1.0.203", features = ["derive"] }
serde-env = "0.1.1" serde-env = "0.1.1"
serde-hex = "0.1.0" serde-hex = "0.1.0"
serde_json = "1.0.117" serde_json = "1.0.117"
siphasher = "1.0.1"
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"] }

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 13.229 13.229"><g aria-label="AV" style="font-size:10.5833px;line-height:1.25;stroke-width:.264583"><path d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z" style="fill:#888;fill-opacity:1"/><path d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z" style="font-size:10.5833px;line-height:1.25;fill:#ddd;fill-opacity:1;stroke-width:.264583"/></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 13.229 13.229"><g aria-label="AV" style="stroke-width:.264583"><path d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z" style="fill:#888;fill-opacity:1"/><path d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z" style="fill:#ddd;fill-opacity:1;stroke-width:.264583"/></g></svg>

Before

Width:  |  Height:  |  Size: 619 B

After

Width:  |  Height:  |  Size: 545 B

View file

@ -1,4 +1,4 @@
use std::{ops::Bound, path::PathBuf, sync::Arc}; use std::{net::SocketAddr, ops::Bound, path::PathBuf, sync::Arc};
use async_zip::tokio::read::ZipEntryReader; use async_zip::tokio::read::ZipEntryReader;
use axum::{ use axum::{
@ -23,13 +23,13 @@ use tokio_util::{
use tower_http::trace::{DefaultOnResponse, TraceLayer}; use tower_http::trace::{DefaultOnResponse, TraceLayer};
use crate::{ use crate::{
artifact_api::{Artifact, ArtifactApi, ArtifactOrRun}, artifact_api::{Artifact, ArtifactApi},
cache::{Cache, CacheEntry, GetEntryResult, GetFileResult, GetFileResultFile, IndexEntry}, cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile, IndexEntry},
config::Config, config::Config,
error::{Error, Result}, error::{Error, Result},
gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN}, gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN},
query::Query, query::Query,
templates::{self, LinkItem}, templates::{self, ArtifactItem, LinkItem},
util::{self, InsertTypedHeader}, util::{self, InsertTypedHeader},
App, App,
}; };
@ -70,11 +70,13 @@ impl App {
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}");
let state = self.new_state();
let real_ip_header = state.i.cfg.load().real_ip_header.clone();
let router = Router::new() let router = Router::new()
// Prevent search indexing since artifactview serves temporary artifacts // Prevent search indexing since artifactview serves temporary artifacts
.route( .route(
"/robots.txt", "/robots.txt",
get(|| async { "User-agent: *\nDisallow: /\n" }), get(|| async { "# PLEASE dont scrape this website.\n# All of the data here is fetched from the public GitHub/Gitea APIs, this app is open source and it is not running on some Fortune 500 company server. \n\nUser-agent: *\nDisallow: /\n" }),
) )
// Put the API in the .well-known folder, since it is disabled for pages // Put the API in the .well-known folder, since it is disabled for pages
.route("/.well-known/api/artifacts", get(Self::get_artifacts)) .route("/.well-known/api/artifacts", get(Self::get_artifacts))
@ -87,16 +89,21 @@ impl App {
.route("/", get(Self::get_page)) .route("/", get(Self::get_page))
.route("/", post(Self::post_homepage)) .route("/", post(Self::post_homepage))
.fallback(get(Self::get_page)) .fallback(get(Self::get_page))
.with_state(self.new_state()) .with_state(state)
// Log requests // Log requests
.layer( .layer(
TraceLayer::new_for_http() TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| { .make_span_with(move |request: &Request<Body>| {
tracing::error_span!("request", url = util::full_url_from_request(request),) let ip = util::get_ip_address(request, real_ip_header.as_deref()).map(|ip| ip.to_string()).unwrap_or_default();
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)),
); );
axum::serve(listener, router).await?; axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(()) Ok(())
} }
@ -120,22 +127,26 @@ impl App {
let query = Query::from_subdomain(subdomain)?; let query = Query::from_subdomain(subdomain)?;
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();
let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?;
match query {
Query::Artifact(query) => {
let entry_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
let entry = entry_res.entry;
if entry_res.downloaded {
state.garbage_collect();
}
let res = state.i.cache.get_entry(&state.i.api, &query).await?;
match res {
GetEntryResult::Entry { entry, zip_path } => {
match entry.get_file(&path, uri.query().unwrap_or_default())? { match entry.get_file(&path, uri.query().unwrap_or_default())? {
GetFileResult::File(res) => { GetFileResult::File(res) => {
Self::serve_artifact_file(state, entry, zip_path, res, hdrs).await Self::serve_artifact_file(state, entry, entry_res.zip_path, res, hdrs)
.await
} }
GetFileResult::Listing(listing) => { 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());
} }
// TODO: store actual artifact names
let artifact_name = format!("A{}", query.artifact.unwrap());
let mut path_components = vec![ let mut path_components = vec![
LinkItem { LinkItem {
name: query.shortid(), name: query.shortid(),
@ -145,7 +156,7 @@ impl App {
.url_with_subdomain(&query.subdomain_with_artifact(None)), .url_with_subdomain(&query.subdomain_with_artifact(None)),
}, },
LinkItem { LinkItem {
name: artifact_name.to_owned(), name: entry.name.to_owned(),
url: "/".to_string(), url: "/".to_string(),
}, },
]; ];
@ -162,7 +173,8 @@ impl App {
let tmpl = templates::Listing { let tmpl = templates::Listing {
main_url: state.i.cfg.main_url(), main_url: state.i.cfg.main_url(),
version: templates::Version, version: templates::Version,
artifact_name: &artifact_name, run_url: &query.forge_url(),
artifact_name: &entry.name,
path_components, path_components,
n_dirs: listing.n_dirs, n_dirs: listing.n_dirs,
n_files: listing.n_files, n_files: listing.n_files,
@ -176,7 +188,9 @@ impl App {
} }
} }
} }
GetEntryResult::Artifacts(artifacts) => { Query::Run(query) => {
let artifacts = state.i.api.list(&query).await?;
if uri.path() != "/" { if uri.path() != "/" {
return Err(Error::NotFound("path".into())); return Err(Error::NotFound("path".into()));
} }
@ -185,11 +199,16 @@ impl App {
} }
let tmpl = templates::Selection { let tmpl = templates::Selection {
main_url: state.i.cfg.main_url(), main_url: state.i.cfg.main_url(),
version: templates::Version,
run_url: &query.forge_url(), run_url: &query.forge_url(),
run_name: &query.shortid(), run_name: &query.shortid(),
publisher: LinkItem {
name: query.user.to_owned(),
url: format!("https://{}/{}", query.host, query.user),
},
artifacts: artifacts artifacts: artifacts
.into_iter() .into_iter()
.map(|a| LinkItem::from_artifact(a, &query, &state.i.cfg)) .map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg))
.collect(), .collect(),
}; };
Ok(Response::builder() Ok(Response::builder()
@ -233,7 +252,7 @@ impl App {
// Dont serve files above the configured size limit // Dont serve files above the configured size limit
let lim = state.i.cfg.load().max_file_size; let lim = state.i.cfg.load().max_file_size;
if lim.is_some_and(|lim| file.uncompressed_size > lim) { if lim.is_some_and(|lim| file.uncompressed_size > lim.into()) {
return Err(Error::BadRequest( return Err(Error::BadRequest(
format!( format!(
"file too large (size: {}, limit: {})", "file too large (size: {}, limit: {})",
@ -246,27 +265,23 @@ 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));
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));
} }
if let Some(last_mod) = entry.last_modified {
resp = resp.typed_header(headers::LastModified::from(last_mod));
}
// handle if-(un)modified queries // handle if-(un)modified queries
if let Some(modified) = entry.last_modified { if let Some(if_unmodified_since) = hdrs.typed_get::<headers::IfUnmodifiedSince>() {
if let Some(if_unmodified_since) = hdrs.typed_get::<headers::IfUnmodifiedSince>() { if !if_unmodified_since.precondition_passes(entry.last_modified) {
if !if_unmodified_since.precondition_passes(modified) { return Ok(resp
return Ok(resp .status(StatusCode::PRECONDITION_FAILED)
.status(StatusCode::PRECONDITION_FAILED) .body(Body::empty())?);
.body(Body::empty())?);
}
} }
if let Some(if_modified_since) = hdrs.typed_get::<headers::IfModifiedSince>() { }
if !if_modified_since.is_modified(modified) { if let Some(if_modified_since) = hdrs.typed_get::<headers::IfModifiedSince>() {
return Ok(resp.status(StatusCode::NOT_MODIFIED).body(Body::empty())?); if !if_modified_since.is_modified(entry.last_modified) {
} return Ok(resp.status(StatusCode::NOT_MODIFIED).body(Body::empty())?);
} }
} }
@ -352,7 +367,7 @@ impl App {
Host(host): Host, Host(host): Host,
) -> Result<Json<Vec<Artifact>>> { ) -> Result<Json<Vec<Artifact>>> {
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)?.into_runquery();
let artifacts = state.i.api.list(&query).await?; let artifacts = state.i.api.list(&query).await?;
Ok(Json(artifacts)) Ok(Json(artifacts))
} }
@ -363,37 +378,25 @@ impl App {
Host(host): Host, Host(host): Host,
) -> Result<Json<Artifact>> { ) -> Result<Json<Artifact>> {
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)?.try_into_artifactquery()?;
if query.artifact.is_none() {
return Err(Error::BadRequest("no artifact specified".into()));
}
let artifact = state.i.api.fetch(&query).await?; let artifact = state.i.api.fetch(&query).await?;
match artifact { Ok(Json(artifact))
ArtifactOrRun::Artifact(artifact) => Ok(Json(artifact)),
ArtifactOrRun::Run(_) => unreachable!(),
}
} }
/// API endpoint to get a file listing /// API endpoint to get a file listing
async fn get_files( async fn get_files(
State(state): State<AppState>, State(state): State<AppState>,
Host(host): Host, Host(host): Host,
request: Request,
) -> Result<Json<Vec<IndexEntry>>> { ) -> Result<Json<Vec<IndexEntry>>> {
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 ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?;
let query = Query::from_subdomain(subdomain)?.try_into_artifactquery()?;
if query.artifact.is_none() { let entry_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
return Err(Error::BadRequest("no artifact specified".into())); if entry_res.downloaded {
state.garbage_collect();
} }
let files = entry_res.entry.get_files();
let res = state.i.cache.get_entry(&state.i.api, &query).await?;
let entry = match res {
GetEntryResult::Entry { entry, .. } => entry,
GetEntryResult::Artifacts(_) => unreachable!(),
};
let files = entry.get_files();
Ok(Json(files)) Ok(Json(files))
} }
} }
@ -407,4 +410,14 @@ impl AppState {
i: Arc::new(AppInner { cfg, cache, api }), i: Arc::new(AppInner { cfg, cache, api }),
} }
} }
/// Run garbage collection in the background if necessary
pub fn garbage_collect(&self) {
let state = self.clone();
tokio::spawn(async move {
if let Err(e) = state.i.cache.garbage_collect().await {
tracing::error!("error during garbage collect: {e}");
}
});
}
} }

View file

@ -1,34 +1,38 @@
//! API-Client to fetch CI artifacts from Github and Forgejo //! API-Client to fetch CI artifacts from Github and Forgejo
use std::{fs::File, io::Cursor, path::Path}; use std::path::Path;
use futures_lite::StreamExt;
use http::header; use http::header;
use quick_cache::sync::Cache as QuickCache;
use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Url}; use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Url};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::{fs::File, io::AsyncWriteExt};
use crate::{ use crate::{
config::Config, config::Config,
error::{Error, Result}, error::{Error, Result},
query::Query, query::{ArtifactQuery, QueryData},
}; };
pub struct ArtifactApi { pub struct ArtifactApi {
http: Client, http: Client,
cfg: Config, cfg: Config,
qc: QuickCache<String, Vec<Artifact>>,
} }
#[derive(Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Artifact { pub struct Artifact {
pub id: u64, pub id: u64,
pub name: String, pub name: String,
pub size: u64, pub size: u64,
pub expired: bool, pub expired: bool,
/// Artifact download URL used by the server
pub download_url: String, pub download_url: String,
} /// Artifact download URL shown to the user. If None, download_url is used
///
pub enum ArtifactOrRun { /// GitHub uses different download URLs for their API and their frontend.
Artifact(Artifact), pub user_download_url: Option<String>,
Run(Vec<Artifact>),
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -59,20 +63,24 @@ enum ForgejoArtifactStatus {
Expired, Expired,
} }
impl From<GithubArtifact> for Artifact { impl GithubArtifact {
fn from(value: GithubArtifact) -> Self { fn into_artifact<T>(self, query: &QueryData<T>) -> Artifact {
Self { Artifact {
id: value.id, id: self.id,
name: value.name, name: self.name,
size: value.size_in_bytes, size: self.size_in_bytes,
expired: value.expired, expired: self.expired,
download_url: value.archive_download_url, download_url: self.archive_download_url,
user_download_url: Some(format!(
"https://github.com/{}/{}/actions/runs/{}/artifacts/{}",
query.user, query.repo, query.run, self.id
)),
} }
} }
} }
impl ForgejoArtifact { impl ForgejoArtifact {
fn into_artifact(self, id: u64, query: &Query) -> Artifact { fn into_artifact<T>(self, id: u64, query: &QueryData<T>) -> Artifact {
Artifact { Artifact {
download_url: format!( download_url: format!(
"https://{}/{}/{}/actions/runs/{}/artifacts/{}", "https://{}/{}/{}/actions/runs/{}/artifacts/{}",
@ -82,6 +90,7 @@ impl ForgejoArtifact {
name: self.name, name: self.name,
size: self.size, size: self.size,
expired: matches!(self.status, ForgejoArtifactStatus::Expired), expired: matches!(self.status, ForgejoArtifactStatus::Expired),
user_download_url: None,
} }
} }
} }
@ -97,35 +106,36 @@ impl ArtifactApi {
)) ))
.build() .build()
.unwrap(), .unwrap(),
qc: QuickCache::new(cfg.load().mem_cache_size),
cfg, cfg,
} }
} }
pub async fn list(&self, query: &Query) -> Result<Vec<Artifact>> { pub async fn list<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
if query.is_github() { let subdomain = query.subdomain_with_artifact(None);
self.list_github(query).await self.qc
} else { .get_or_insert_async(&subdomain, async {
self.list_forgejo(query).await if query.is_github() {
} self.list_github(query).await
} else {
self.list_forgejo(query).await
}
})
.await
} }
pub async fn fetch(&self, query: &Query) -> Result<ArtifactOrRun> { pub async fn fetch(&self, query: &ArtifactQuery) -> Result<Artifact> {
if query.is_github() { if query.is_github() {
self.fetch_github(query).await self.fetch_github(query).await
} else { } else {
// Forgejo currently has no API for fetching single artifacts // Forgejo currently has no API for fetching single artifacts
let mut artifacts = self.list_forgejo(query).await?; let mut artifacts = self.list_forgejo(query).await?;
match query.artifact { let i = usize::try_from(query.artifact)?;
Some(artifact) => { if i == 0 || i > artifacts.len() {
let i = usize::try_from(artifact)?; return Err(Error::NotFound("artifact".into()));
if i == 0 || i > artifacts.len() {
return Err(Error::NotFound("artifact".into()));
}
Ok(ArtifactOrRun::Artifact(artifacts.swap_remove(i - 1)))
}
None => Ok(ArtifactOrRun::Run(artifacts)),
} }
Ok(artifacts.swap_remove(i - 1))
} }
} }
@ -136,7 +146,11 @@ impl ArtifactApi {
let lim = self.cfg.load().max_artifact_size; let lim = self.cfg.load().max_artifact_size;
let check_lim = |size: u64| { let check_lim = |size: u64| {
if lim.is_some_and(|lim| u32::try_from(size).map(|size| size > lim).unwrap_or(true)) { if lim.is_some_and(|lim| {
u32::try_from(size)
.map(|size| size > lim.into())
.unwrap_or(true)
}) {
Err(Error::BadRequest( Err(Error::BadRequest(
format!( format!(
"artifact too large (size: {}, limit: {})", "artifact too large (size: {}, limit: {})",
@ -165,15 +179,23 @@ impl ArtifactApi {
} }
let tmp_path = path.with_extension(format!("tmp.{:x}", rand::random::<u32>())); let tmp_path = path.with_extension(format!("tmp.{:x}", rand::random::<u32>()));
let mut file = File::create(&tmp_path)?;
let mut content = Cursor::new(resp.bytes().await?); {
std::io::copy(&mut content, &mut file)?; let mut file = File::create(&tmp_path).await?;
std::fs::rename(&tmp_path, path)?; let mut stream = resp.bytes_stream();
while let Some(item) = stream.next().await {
let mut chunk = item?;
file.write_all_buf(&mut chunk).await?;
}
}
tokio::fs::write(path.with_extension("name"), &artifact.name).await?;
tokio::fs::rename(&tmp_path, path).await?;
tracing::info!("Downloaded artifact from {}", artifact.download_url); tracing::info!("Downloaded artifact from {}", artifact.download_url);
Ok(()) Ok(())
} }
async fn list_forgejo(&self, query: &Query) -> Result<Vec<Artifact>> { async fn list_forgejo<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
let url = format!( let url = format!(
"https://{}/{}/{}/actions/runs/{}/artifacts", "https://{}/{}/{}/actions/runs/{}/artifacts",
query.host, query.user, query.repo, query.run query.host, query.user, query.repo, query.run
@ -198,7 +220,7 @@ impl ArtifactApi {
Ok(artifacts) Ok(artifacts)
} }
async fn list_github(&self, query: &Query) -> Result<Vec<Artifact>> { async fn list_github<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
let url = format!( let url = format!(
"https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts", "https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts",
query.user, query.repo, query.run query.user, query.repo, query.run
@ -212,28 +234,27 @@ impl ArtifactApi {
.json::<ArtifactsWrap<GithubArtifact>>() .json::<ArtifactsWrap<GithubArtifact>>()
.await?; .await?;
Ok(resp.artifacts.into_iter().map(Artifact::from).collect()) Ok(resp
.artifacts
.into_iter()
.map(|a| a.into_artifact(query))
.collect())
} }
async fn fetch_github(&self, query: &Query) -> Result<ArtifactOrRun> { async fn fetch_github(&self, query: &ArtifactQuery) -> Result<Artifact> {
match query.artifact { let url = format!(
Some(artifact) => { "https://api.github.com/repos/{}/{}/actions/artifacts/{}",
let url = format!( query.user, query.repo, query.artifact
"https://api.github.com/repos/{}/{}/actions/artifacts/{}", );
query.user, query.repo, artifact
);
let artifact = self let artifact = self
.get_github(url) .get_github(url)
.send() .send()
.await? .await?
.error_for_status()? .error_for_status()?
.json::<GithubArtifact>() .json::<GithubArtifact>()
.await?; .await?;
Ok(ArtifactOrRun::Artifact(artifact.into())) Ok(artifact.into_artifact(query))
}
None => Ok(ArtifactOrRun::Run(self.list_github(query).await?)),
}
} }
fn get_github<U: IntoUrl>(&self, url: U) -> RequestBuilder { fn get_github<U: IntoUrl>(&self, url: U) -> RequestBuilder {
@ -248,57 +269,39 @@ impl ArtifactApi {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{config::Config, query::Query}; use crate::{config::Config, query::ArtifactQuery};
use super::{ArtifactApi, ArtifactOrRun}; use super::ArtifactApi;
#[tokio::test] #[tokio::test]
async fn fetch_forgejo() { async fn fetch_forgejo() {
let query = Query { let query = ArtifactQuery {
host: "code.thetadev.de".to_owned(), host: "code.thetadev.de".to_owned(),
user: "HSA".to_owned(), user: "HSA".to_owned(),
repo: "Visitenbuch".to_owned(), repo: "Visitenbuch".to_owned(),
run: 32, run: 32,
artifact: Some(1), artifact: 1,
}; };
let api = ArtifactApi::new(Config::default()); let api = ArtifactApi::new(Config::default());
let res = api.fetch(&query).await.unwrap(); let res = api.fetch(&query).await.unwrap();
if let ArtifactOrRun::Artifact(res) = res { assert_eq!(res.id, 1);
assert_eq!(res.name, "playwright-report"); assert_eq!(res.size, 574292);
assert_eq!(
res.download_url,
"https://code.thetadev.de/HSA/Visitenbuch/actions/runs/32/artifacts/playwright-report"
);
assert_eq!(res.id, 1);
assert_eq!(res.size, 574292);
} else {
panic!("got run");
}
} }
#[tokio::test] #[tokio::test]
async fn fetch_github() { async fn fetch_github() {
let query = Query { let query = ArtifactQuery {
host: "github.com".to_owned(), host: "github.com".to_owned(),
user: "actions".to_owned(), user: "actions".to_owned(),
repo: "upload-artifact".to_owned(), repo: "upload-artifact".to_owned(),
run: 8805345396, run: 8805345396,
artifact: Some(1440556464), artifact: 1440556464,
}; };
let api = ArtifactApi::new(Config::default()); let api = ArtifactApi::new(Config::default());
let res = api.fetch(&query).await.unwrap(); let res = api.fetch(&query).await.unwrap();
if let ArtifactOrRun::Artifact(res) = res { assert_eq!(res.id, 1440556464);
assert_eq!(res.name, "Artifact-Wildcard-macos-latest"); assert_eq!(res.size, 334);
assert_eq!(
res.download_url,
"https://api.github.com/repos/actions/upload-artifact/actions/artifacts/1440556464/zip"
);
assert_eq!(res.id, 1440556464);
assert_eq!(res.size, 334);
} else {
panic!("got run");
}
} }
} }

View file

@ -1,12 +1,16 @@
use std::{ use std::{
borrow::Cow, borrow::Cow,
collections::{BTreeMap, HashMap}, collections::{BTreeMap, HashMap},
fs::FileTimes,
net::IpAddr,
num::{NonZeroU32, NonZeroUsize},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
time::{Duration, SystemTime}, time::{Duration, SystemTime},
}; };
use async_zip::{tokio::read::fs::ZipFileReader, Compression}; use async_zip::{tokio::read::fs::ZipFileReader, Compression};
use governor::{Quota, RateLimiter};
use http::StatusCode; use http::StatusCode;
use mime::Mime; use mime::Mime;
use path_macro::path; use path_macro::path;
@ -15,21 +19,29 @@ use serde::Serialize;
use serde_hex::{SerHex, Strict}; use serde_hex::{SerHex, Strict};
use crate::{ use crate::{
artifact_api::{Artifact, ArtifactApi, ArtifactOrRun}, artifact_api::ArtifactApi,
config::Config, config::Config,
error::{Error, Result}, error::{Error, Result},
query::Query, query::ArtifactQuery,
util, util::{self, IgnoreFileNotFound},
}; };
pub struct Cache { pub struct Cache {
cfg: Config, cfg: Config,
qc: QuickCache<[u8; 16], Arc<CacheEntry>>, qc: QuickCache<String, Arc<CacheEntry>>,
lim_download: Option<
governor::DefaultKeyedRateLimiter<
IpAddr,
governor::middleware::NoOpMiddleware<governor::clock::QuantaInstant>,
>,
>,
lim_gc: governor::DefaultDirectRateLimiter,
} }
pub struct CacheEntry { pub struct CacheEntry {
pub files: HashMap<String, FileEntry>, pub files: HashMap<String, FileEntry>,
pub last_modified: Option<SystemTime>, pub name: String,
pub last_modified: SystemTime,
} }
#[derive(Clone)] #[derive(Clone)]
@ -41,12 +53,10 @@ pub struct FileEntry {
pub compression: Compression, pub compression: Compression,
} }
pub enum GetEntryResult { pub struct GetEntryResult {
Entry { pub entry: Arc<CacheEntry>,
entry: Arc<CacheEntry>, pub zip_path: PathBuf,
zip_path: PathBuf, pub downloaded: bool,
},
Artifacts(Vec<Artifact>),
} }
pub enum GetFileResult { pub enum GetFileResult {
@ -78,70 +88,143 @@ pub struct Listing {
pub struct ListingEntry { pub struct ListingEntry {
pub name: String, pub name: String,
pub url: String, pub url: String,
pub size: u32, pub size: Size,
pub crc32: String, pub crc32: String,
pub is_dir: bool, pub is_dir: bool,
} }
pub struct Size(pub u32);
impl Cache { impl Cache {
pub fn new(cfg: Config) -> Self { pub fn new(cfg: Config) -> Self {
Self { Self {
cfg, qc: QuickCache::new(cfg.load().mem_cache_size),
qc: QuickCache::new(50), lim_download: cfg
}
}
pub fn get_path(&self, query: &Query) -> PathBuf {
path!(self.cfg.load().cache_dir / format!("{}.zip", hex::encode(query.siphash())))
}
pub async fn get_entry(&self, api: &ArtifactApi, query: &Query) -> Result<GetEntryResult> {
if query.artifact.is_some() {
let hash = query.siphash();
let zip_path = path!(self.cfg.load().cache_dir / format!("{}.zip", hex::encode(hash)));
if !zip_path.is_file() {
let artifact = api.fetch(query).await?;
let artifact = match artifact {
ArtifactOrRun::Artifact(artifact) => artifact,
ArtifactOrRun::Run(_) => unreachable!(),
};
api.download(&artifact, &zip_path).await?;
}
let timeout = self
.cfg
.load() .load()
.zip_timeout_ms .limit_artifacts_per_min
.map(|t| Duration::from_millis(t.into())); .map(|lim| RateLimiter::keyed(Quota::per_minute(lim))),
let mut entry = self lim_gc: RateLimiter::direct(Quota::per_hour(NonZeroU32::MIN)),
.qc cfg,
.get_or_insert_async(&hash, async {
Ok::<_, Error>(Arc::new(CacheEntry::new(&zip_path, timeout).await?))
})
.await?;
// Verify if the cached entry is fresh
let meta = tokio::fs::metadata(&zip_path).await?;
if meta.modified().ok() != entry.last_modified {
tracing::info!("cached file {zip_path:?} changed");
entry = Arc::new(CacheEntry::new(&zip_path, timeout).await?);
self.qc.insert(hash, entry.clone());
}
Ok(GetEntryResult::Entry { entry, zip_path })
} else {
let run = api.fetch(query).await?;
let artifacts = match run {
ArtifactOrRun::Artifact(_) => unreachable!(),
ArtifactOrRun::Run(run) => run,
};
Ok(GetEntryResult::Artifacts(artifacts))
} }
} }
pub async fn get_entry(
&self,
api: &ArtifactApi,
query: &ArtifactQuery,
ip: &IpAddr,
) -> Result<GetEntryResult> {
let subdomain = query.subdomain();
let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip"));
let downloaded = !zip_path.is_file();
if downloaded {
let artifact = api.fetch(query).await?;
if let Some(limiter) = &self.lim_download {
limiter.check_key(ip)?;
}
api.download(&artifact, &zip_path).await?;
}
let timeout = self
.cfg
.load()
.zip_timeout_ms
.map(|t| Duration::from_millis(u32::from(t).into()));
let max_file_count = self.cfg.load().max_file_count;
let mut entry = self
.qc
.get_or_insert_async(&subdomain, async {
Ok::<_, Error>(Arc::new(
CacheEntry::new(&zip_path, timeout, max_file_count, query.artifact).await?,
))
})
.await?;
// Verify if the cached entry is fresh
let metadata = tokio::fs::metadata(&zip_path).await?;
let modified = metadata
.modified()
.map_err(|_| Error::Internal("no file modified time".into()))?;
let accessed = metadata
.accessed()
.map_err(|_| Error::Internal("no file accessed time".into()))?;
if modified != entry.last_modified {
tracing::info!("cached file {zip_path:?} changed");
entry = Arc::new(
CacheEntry::new(&zip_path, timeout, max_file_count, query.artifact).await?,
);
self.qc.insert(subdomain, entry.clone());
}
// Update last_accessed time if older than 30min
// some systems may have access time disabled and we need it to keep track of stale artifacts
let now = SystemTime::now();
if now
.duration_since(accessed)
.map_err(|e| Error::Internal(e.to_string().into()))?
> Duration::from_secs(1800)
{
let file = std::fs::File::open(&zip_path)?;
file.set_times(FileTimes::new().set_accessed(now))?;
}
Ok(GetEntryResult {
entry,
zip_path,
downloaded,
})
}
pub async fn garbage_collect(&self) -> Result<()> {
if self.lim_gc.check().is_err() {
return Ok(());
}
tracing::info!("starting garbage collect");
let now = SystemTime::now();
let max_age = Duration::from_secs(u64::from(u32::from(self.cfg.load().max_age_h)) * 3600);
let mut n = 0;
let mut rd = tokio::fs::read_dir(&self.cfg.load().cache_dir).await?;
while let Some(entry) = rd.next_entry().await? {
if entry.file_type().await?.is_file()
&& entry.path().extension().is_some_and(|ext| ext == "zip")
{
let accessed = entry
.metadata()
.await?
.accessed()
.map_err(|_| Error::Internal("no file accessed time".into()))?;
if now
.duration_since(accessed)
.map_err(|e| Error::Internal(e.to_string().into()))?
> max_age
{
let path = entry.path();
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
self.qc.remove(name);
}
tokio::fs::remove_file(path.with_extension("name"))
.await
.ignore_file_not_found()?;
tokio::fs::remove_file(&path)
.await
.ignore_file_not_found()?;
n += 1;
}
}
}
tracing::info!("garbage collect finished: {n} artifacts removed");
Ok(())
}
} }
impl CacheEntry { impl CacheEntry {
async fn new(zip_path: &Path, timeout: Option<Duration>) -> Result<Self> { async fn new(
zip_path: &Path,
timeout: Option<Duration>,
max_file_count: Option<NonZeroUsize>,
artifact: u64,
) -> Result<Self> {
let meta = tokio::fs::metadata(&zip_path).await?; let meta = tokio::fs::metadata(&zip_path).await?;
let zip_fut = ZipFileReader::new(&zip_path); let zip_fut = ZipFileReader::new(&zip_path);
let zip = match timeout { let zip = match timeout {
@ -149,6 +232,16 @@ impl CacheEntry {
None => zip_fut.await?, None => zip_fut.await?,
}; };
if max_file_count.is_some_and(|lim| zip.file().entries().len() > lim.into()) {
return Err(Error::BadRequest("artifact contains too many files".into()));
}
let name_path = zip_path.with_extension("name");
let name = tokio::fs::read_to_string(name_path)
.await
.ok()
.unwrap_or_else(|| format!("A{artifact}"));
Ok(Self { Ok(Self {
files: zip files: zip
.file() .file()
@ -167,7 +260,10 @@ impl CacheEntry {
)) ))
}) })
.collect(), .collect(),
last_modified: meta.modified().ok(), name,
last_modified: meta
.modified()
.map_err(|_| Error::Internal("no file modified time".into()))?,
}) })
} }
@ -279,7 +375,7 @@ impl CacheEntry {
directories.push(ListingEntry { directories.push(ListingEntry {
name: n.to_owned(), name: n.to_owned(),
url: format!("{n}{path}"), url: format!("{n}{path}"),
size: 0, size: Size(0),
crc32: "-".to_string(), crc32: "-".to_string(),
is_dir: true, is_dir: true,
}); });
@ -287,7 +383,7 @@ impl CacheEntry {
files.push(ListingEntry { files.push(ListingEntry {
name: n.to_owned(), name: n.to_owned(),
url: format!("{n}{path}"), url: format!("{n}{path}"),
size: entry.uncompressed_size, size: Size(entry.uncompressed_size),
crc32: hex::encode(entry.crc32.to_le_bytes()), crc32: hex::encode(entry.crc32.to_le_bytes()),
is_dir: false, is_dir: false,
}); });
@ -297,9 +393,9 @@ impl CacheEntry {
// Sort by size // Sort by size
if col == b'S' { if col == b'S' {
if rev { if rev {
files.sort_by(|a, b| b.size.cmp(&a.size)); files.sort_by(|a, b| b.size.0.cmp(&a.size.0));
} else { } else {
files.sort_by_key(|f| f.size); files.sort_by_key(|f| f.size.0);
} }
} }

View file

@ -1,4 +1,5 @@
use std::{ use std::{
num::{NonZeroU32, NonZeroUsize},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
}; };
@ -20,14 +21,40 @@ struct ConfigInner {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct ConfigData { pub struct ConfigData {
/// Folder where the downloaded artifacts are stored
pub cache_dir: PathBuf, pub cache_dir: PathBuf,
/// Root domain under which the server is available
///
/// The individual artifacts are served under `<subdomain>.<root_domain>`
pub root_domain: String, pub root_domain: String,
/// Set to true if the server is not available under HTTPS
pub no_https: bool, pub no_https: bool,
pub max_artifact_size: Option<u32>, /// Maximum artifact (ZIP) size to be downloaded
pub max_file_size: Option<u32>, pub max_artifact_size: Option<NonZeroU32>,
pub max_age_h: Option<u16>, /// Maximum file size to be served
pub zip_timeout_ms: Option<u32>, pub max_file_size: Option<NonZeroU32>,
/// Maximum file count within a ZIP file
pub max_file_count: Option<NonZeroUsize>,
/// Maximum age in hours after which artifacts are deleted
pub max_age_h: NonZeroU32,
/// Maximum time in milliseconds for reading a zip file index
pub zip_timeout_ms: Option<NonZeroU32>,
/// GitHub API token for downloading GitHub artifacts
///
/// Using a fine-grained token with public read permissions is recommended.
pub github_token: Option<String>, pub github_token: Option<String>,
/// Number of artifact indexes to keep in memory
pub mem_cache_size: usize,
/// Get the client IP address from a HTTP request header
///
/// If Artifactview is exposed to the network directly, this option
/// has to be unset. If you are using a reverse proxy the proxy needs to
/// be configured to send the actual client IP as a request header.
///
/// For most proxies this header is `x-forwarded-for`.
pub real_ip_header: Option<String>,
/// Limit the amount of downloaded artifacts per IP address and minute
pub limit_artifacts_per_min: Option<NonZeroU32>,
} }
impl Default for ConfigData { impl Default for ConfigData {
@ -36,11 +63,15 @@ impl Default for ConfigData {
cache_dir: Path::new("/tmp/artifactview").into(), cache_dir: Path::new("/tmp/artifactview").into(),
root_domain: "localhost:3000".to_string(), root_domain: "localhost:3000".to_string(),
no_https: false, no_https: false,
max_artifact_size: Some(100_000_000), max_artifact_size: Some(NonZeroU32::new(100_000_000).unwrap()),
max_file_size: Some(100_000_000), max_file_size: Some(NonZeroU32::new(100_000_000).unwrap()),
max_age_h: Some(12), max_file_count: Some(NonZeroUsize::new(10_000).unwrap()),
zip_timeout_ms: Some(1000), max_age_h: NonZeroU32::new(12).unwrap(),
zip_timeout_ms: Some(NonZeroU32::new(1000).unwrap()),
github_token: None, github_token: None,
mem_cache_size: 50,
real_ip_header: None,
limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()),
} }
} }
} }

View file

@ -37,6 +37,8 @@ pub enum Error {
Timeout(#[from] tokio::time::error::Elapsed), Timeout(#[from] tokio::time::error::Elapsed),
#[error("method not allowed")] #[error("method not allowed")]
MethodNotAllowed, MethodNotAllowed,
#[error("you are fetching new artifacts too fast, please wait a minute and try again")]
Ratelimit,
} }
impl From<reqwest::Error> for Error { impl From<reqwest::Error> for Error {
@ -60,6 +62,12 @@ impl From<url::ParseError> for Error {
} }
} }
impl From<governor::NotUntil<governor::clock::QuantaInstant>> for Error {
fn from(_: governor::NotUntil<governor::clock::QuantaInstant>) -> Self {
Self::Ratelimit
}
}
impl Error { impl Error {
pub fn status(&self) -> StatusCode { pub fn status(&self) -> StatusCode {
match self { match self {
@ -67,6 +75,7 @@ impl Error {
Error::NotFound(_) | Error::Inaccessible | Error::Expired => StatusCode::NOT_FOUND, Error::NotFound(_) | Error::Inaccessible | Error::Expired => StatusCode::NOT_FOUND,
Error::HttpClient(_, status) => *status, Error::HttpClient(_, status) => *status,
Error::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED, Error::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
Error::Ratelimit => StatusCode::TOO_MANY_REQUESTS,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }

View file

@ -2,13 +2,21 @@ use std::{fmt::Write, hash::Hash};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::{Captures, Regex}; use regex::{Captures, Regex};
use siphasher::sip128::{Hasher128, SipHasher};
use url::Url; use url::Url;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
#[derive(Debug, PartialEq, Eq)]
pub enum Query {
Artifact(ArtifactQuery),
Run(RunQuery),
}
pub type RunQuery = QueryData<()>;
pub type ArtifactQuery = QueryData<u64>;
#[derive(Debug, PartialEq, Eq, Hash)] #[derive(Debug, PartialEq, Eq, Hash)]
pub struct Query { pub struct QueryData<T> {
/// Forge host /// Forge host
pub host: String, pub host: String,
/// User/org name (case-insensitive) /// User/org name (case-insensitive)
@ -17,8 +25,8 @@ pub struct Query {
pub repo: String, pub repo: String,
/// CI run id /// CI run id
pub run: u64, pub run: u64,
/// Artifact id (unique for every run) // Optional selected artifact
pub artifact: Option<u64>, pub artifact: 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());
@ -35,15 +43,26 @@ impl Query {
return Err(Error::InvalidUrl); return Err(Error::InvalidUrl);
} }
Ok(Self { let host = decode_domain(segments[0], '.');
host: Self::decode_domain(segments[0], '.'), let user = decode_domain(segments[1], '-');
user: Self::decode_domain(segments[1], '-'), let repo = decode_domain(segments[2], '-');
repo: Self::decode_domain(segments[2], '-'), let run = run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?;
run: run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?,
artifact: match run_and_artifact.get(1) { Ok(match run_and_artifact.get(1) {
Some(x) => Some(x.parse().ok().ok_or(Error::InvalidUrl)?), Some(x) => Self::Artifact(QueryData {
None => None, host,
}, user,
repo,
run,
artifact: x.parse().ok().ok_or(Error::InvalidUrl)?,
}),
None => Self::Run(QueryData {
host,
user,
repo,
run,
artifact: (),
}),
}) })
} }
@ -78,25 +97,56 @@ impl Query {
.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 { Ok(Self::Run(RunQuery {
host: host.to_owned(), host: host.to_owned(),
user: user.to_owned(), user: user.to_owned(),
repo: repo.to_owned(), repo: repo.to_owned(),
run, run,
artifact: None, artifact: (),
}) }))
} }
pub fn subdomain(&self) -> String { pub fn subdomain(&self) -> String {
self.subdomain_with_artifact(self.artifact) match self {
Query::Artifact(q) => q.subdomain(),
Query::Run(q) => q.subdomain(),
}
} }
pub fn into_runquery(self) -> RunQuery {
match self {
Query::Artifact(q) => q.into_runquery(),
Query::Run(q) => q,
}
}
pub fn try_into_artifactquery(self) -> Result<ArtifactQuery> {
match self {
Query::Artifact(q) => Ok(q),
Query::Run(_) => Err(Error::BadRequest("no artifact specified".into())),
}
}
}
impl ArtifactQuery {
pub fn subdomain(&self) -> String {
self.subdomain_with_artifact(Some(self.artifact))
}
}
impl RunQuery {
pub fn subdomain(&self) -> String {
self.subdomain_with_artifact(None)
}
}
impl<T> QueryData<T> {
pub fn subdomain_with_artifact(&self, artifact: Option<u64>) -> String { pub fn subdomain_with_artifact(&self, artifact: Option<u64>) -> String {
let mut res = format!( let mut res = format!(
"{}--{}--{}--{}", "{}--{}--{}--{}",
Self::encode_domain(&self.host, '.'), encode_domain(&self.host, '.'),
Self::encode_domain(&self.user, '-'), encode_domain(&self.user, '-'),
Self::encode_domain(&self.repo, '-'), encode_domain(&self.repo, '-'),
self.run, self.run,
); );
if let Some(artifact) = artifact { if let Some(artifact) = artifact {
@ -120,82 +170,86 @@ impl Query {
self.host == "github.com" self.host == "github.com"
} }
pub fn siphash(&self) -> [u8; 16] { pub fn into_runquery(self) -> RunQuery {
let mut h = SipHasher::new(); RunQuery {
self.hash(&mut h); host: self.host,
h.finish128().as_bytes() user: self.user,
} repo: self.repo,
run: self.run,
fn encode_domain(s: &str, bias: char) -> String { artifact: (),
// Check if the character at the given position is in the middle of the string
// and it is not followed by escape seq numbers or further escapable characters
let is_mid_single = |pos: usize| -> bool {
if pos == 0 || pos >= (s.len() - 1) {
return false;
}
let next_char = s[pos..].chars().nth(1).unwrap();
!('0'..='2').contains(&next_char) && !matches!(next_char, '-' | '.' | '_')
};
// Escape dashes
let mut buf = String::with_capacity(s.len());
let mut last_pos = 0;
for (pos, c) in s.match_indices('-') {
buf += &s[last_pos..pos];
if bias == '-' && is_mid_single(pos) {
buf.push('-');
} else {
buf += "-1";
}
last_pos = pos + c.len();
} }
buf += &s[last_pos..];
// Replace special chars [._]
let mut buf2 = String::with_capacity(buf.len());
last_pos = 0;
for (pos, c) in buf.match_indices(['.', '_']) {
buf2 += &buf[last_pos..pos];
let cchar = c.chars().next().unwrap();
if cchar == bias && is_mid_single(pos) {
buf2.push('-');
} else if cchar == '.' {
buf2 += "-0"
} else {
buf2 += "-2"
}
last_pos = pos + c.len();
}
buf2 += &buf[last_pos..];
buf2
} }
}
fn decode_domain(s: &str, bias: char) -> String { fn encode_domain(s: &str, bias: char) -> String {
static ESCAPE_PATTEN: Lazy<Regex> = Lazy::new(|| Regex::new("-([0-2])").unwrap()); // Check if the character at the given position is in the middle of the string
static SINGLE_DASHES: Lazy<Regex> = Lazy::new(|| Regex::new("-([^0-2-])").unwrap()); // and it is not followed by escape seq numbers or further escapable characters
let is_mid_single = |pos: usize| -> bool {
if pos == 0 || pos >= (s.len() - 1) {
return false;
}
let next_char = s[pos..].chars().nth(1).unwrap();
!('0'..='2').contains(&next_char) && !matches!(next_char, '-' | '.' | '_')
};
let repl = ESCAPE_PATTEN.replace_all(s, |c: &Captures| { // Escape dashes
match &c[1] { let mut buf = String::with_capacity(s.len());
"1" => "\0", // Temporary character (to be replaced with -) let mut last_pos = 0;
"0" => ".", for (pos, c) in s.match_indices('-') {
_ => "_", buf += &s[last_pos..pos];
} if bias == '-' && is_mid_single(pos) {
}); buf.push('-');
let repl2 = if bias == '-' {
repl
} else { } else {
SINGLE_DASHES.replace_all(&repl, |c: &Captures| bias.to_string() + &c[1]) buf += "-1";
}; }
last_pos = pos + c.len();
repl2.replace('\0', "-")
} }
buf += &s[last_pos..];
// Replace special chars [._]
let mut buf2 = String::with_capacity(buf.len());
last_pos = 0;
for (pos, c) in buf.match_indices(['.', '_']) {
buf2 += &buf[last_pos..pos];
let cchar = c.chars().next().unwrap();
if cchar == bias && is_mid_single(pos) {
buf2.push('-');
} else if cchar == '.' {
buf2 += "-0"
} else {
buf2 += "-2"
}
last_pos = pos + c.len();
}
buf2 += &buf[last_pos..];
buf2
}
fn decode_domain(s: &str, bias: char) -> String {
static ESCAPE_PATTEN: Lazy<Regex> = Lazy::new(|| Regex::new("-([0-2])").unwrap());
static SINGLE_DASHES: Lazy<Regex> = Lazy::new(|| Regex::new("-([^0-2-])").unwrap());
let repl = ESCAPE_PATTEN.replace_all(s, |c: &Captures| {
match &c[1] {
"1" => "\0", // Temporary character (to be replaced with -)
"0" => ".",
_ => "_",
}
});
let repl2 = if bias == '-' {
repl
} else {
SINGLE_DASHES.replace_all(&repl, |c: &Captures| bias.to_string() + &c[1])
};
repl2.replace('\0', "-")
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Query; use super::{ArtifactQuery, Query};
use proptest::prelude::*; use proptest::prelude::*;
use rstest::rstest; use rstest::rstest;
@ -206,7 +260,7 @@ mod tests {
#[case("_h--de.x-u", '.', "-2h-1-1de-x-1u")] #[case("_h--de.x-u", '.', "-2h-1-1de-x-1u")]
#[case("0-0", '-', "0-10")] #[case("0-0", '-', "0-10")]
fn encode_domain(#[case] s: &str, #[case] bias: char, #[case] expect: &str) { fn encode_domain(#[case] s: &str, #[case] bias: char, #[case] expect: &str) {
assert_eq!(Query::encode_domain(s, bias), expect); assert_eq!(super::encode_domain(s, bias), expect);
} }
#[rstest] #[rstest]
@ -215,14 +269,14 @@ mod tests {
#[case("-2h-1-1de-x-1u", '.', "_h--de.x-u")] #[case("-2h-1-1de-x-1u", '.', "_h--de.x-u")]
#[case("0-10", '-', "0-0")] #[case("0-10", '-', "0-0")]
fn decode_domain(#[case] s: &str, #[case] bias: char, #[case] expect: &str) { fn decode_domain(#[case] s: &str, #[case] bias: char, #[case] expect: &str) {
assert_eq!(Query::decode_domain(s, bias), expect); assert_eq!(super::decode_domain(s, bias), expect);
} }
proptest! { proptest! {
#[test] #[test]
fn pt_encode_domain_roundtrip(s in "[a-z0-9\\-_\\.]+") { fn pt_encode_domain_roundtrip(s in "[a-z0-9\\-_\\.]+") {
let enc = Query::encode_domain(&s, '-'); let enc = super::encode_domain(&s, '-');
let dec = Query::decode_domain(&enc, '-'); let dec = super::decode_domain(&enc, '-');
assert_eq!(dec, s); assert_eq!(dec, s);
assert!(!enc.contains("--"), "got: `{s}` -> `{enc}`"); assert!(!enc.contains("--"), "got: `{s}` -> `{enc}`");
} }
@ -234,27 +288,14 @@ mod tests {
let query = Query::from_subdomain(d1).unwrap(); let query = Query::from_subdomain(d1).unwrap();
assert_eq!( assert_eq!(
query, query,
Query { Query::Artifact(ArtifactQuery {
host: "github.com".to_owned(), host: "github.com".to_owned(),
user: "thetadev".to_owned(), user: "thetadev".to_owned(),
repo: "newpipe-extractor".to_owned(), repo: "newpipe-extractor".to_owned(),
run: 14, run: 14,
artifact: Some(123), artifact: 123
} })
); );
assert_eq!(query.subdomain(), d1); assert_eq!(query.subdomain(), d1);
} }
#[test]
fn siphash() {
let q = Query {
host: "github.com".to_owned(),
user: "thetadev".to_owned(),
repo: "newpipe-extractor".to_owned(),
run: 14,
artifact: Some(123),
};
let hash = q.siphash();
assert_eq!(hex::encode(hash), "e523468ef42c848155a43f40895dff5a");
}
} }

View file

@ -1,15 +1,14 @@
use crate::{artifact_api::Artifact, cache::ListingEntry, config::Config, query::Query}; use crate::{
artifact_api::Artifact,
cache::{ListingEntry, Size},
config::Config,
query::QueryData,
};
use yarte::{Render, Template}; use yarte::{Render, Template};
#[derive(Default)] #[derive(Default)]
pub struct Version; pub struct Version;
impl Render for Version {
fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(env!("CARGO_PKG_VERSION"))
}
}
#[derive(Template, Default)] #[derive(Template, Default)]
#[template(path = "index")] #[template(path = "index")]
pub struct Index { pub struct Index {
@ -27,9 +26,11 @@ pub struct Error<'a> {
#[template(path = "selection")] #[template(path = "selection")]
pub struct Selection<'a> { pub struct Selection<'a> {
pub main_url: &'a str, pub main_url: &'a str,
pub version: Version,
pub run_url: &'a str, pub run_url: &'a str,
pub run_name: &'a str, pub run_name: &'a str,
pub artifacts: Vec<LinkItem>, pub publisher: LinkItem,
pub artifacts: Vec<ArtifactItem>,
} }
#[derive(Template)] #[derive(Template)]
@ -37,6 +38,7 @@ pub struct Selection<'a> {
pub struct Listing<'a> { pub struct Listing<'a> {
pub main_url: &'a str, pub main_url: &'a str,
pub version: Version, pub version: Version,
pub run_url: &'a str,
pub artifact_name: &'a str, pub artifact_name: &'a str,
pub path_components: Vec<LinkItem>, pub path_components: Vec<LinkItem>,
pub n_dirs: usize, pub n_dirs: usize,
@ -50,11 +52,38 @@ pub struct LinkItem {
pub url: String, pub url: String,
} }
impl LinkItem { pub struct ArtifactItem {
pub fn from_artifact(artifact: Artifact, query: &Query, cfg: &Config) -> Self { pub name: String,
pub url: String,
pub size: Size,
pub expired: bool,
pub download_url: String,
}
impl ArtifactItem {
pub fn from_artifact<T>(artifact: Artifact, query: &QueryData<T>, cfg: &Config) -> Self {
Self { 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),
expired: artifact.expired,
download_url: artifact.user_download_url.unwrap_or(artifact.download_url),
} }
} }
} }
impl Render for Version {
fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(env!("CARGO_PKG_VERSION"))
}
}
impl Render for Size {
fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"{}",
humansize::SizeFormatter::new(self.0, humansize::DECIMAL),
)
}
}

View file

@ -1,7 +1,14 @@
use std::io::SeekFrom; use std::{
io::SeekFrom,
net::{IpAddr, SocketAddr},
str::FromStr,
};
use async_zip::error::ZipError; use async_zip::error::ZipError;
use axum::{extract::Request, http::HeaderMap}; use axum::{
extract::{ConnectInfo, Request},
http::HeaderMap,
};
use headers::{Header, HeaderMapExt}; use headers::{Header, HeaderMapExt};
use http::header; use http::header;
use mime_guess::Mime; use mime_guess::Mime;
@ -136,6 +143,42 @@ pub fn get_subdomain<'a>(host: &'a str, root_domain: &str) -> Result<&'a str> {
Ok(stripped.trim_end_matches('.')) Ok(stripped.trim_end_matches('.'))
} }
pub fn get_ip_address(request: &Request, real_ip_header: Option<&str>) -> Result<IpAddr> {
match real_ip_header.and_then(|header| {
request
.headers()
.get(header)
.and_then(|val| val.to_str().ok())
.and_then(|val| IpAddr::from_str(val).ok())
}) {
Some(from_header) => Ok(from_header),
None => {
let socket_addr = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.ok_or(Error::Internal("could get request ip address".into()))?
.0;
Ok(socket_addr.ip())
}
}
}
pub trait IgnoreFileNotFound {
fn ignore_file_not_found(self) -> core::result::Result<(), std::io::Error>;
}
impl<T> IgnoreFileNotFound for core::result::Result<T, std::io::Error> {
fn ignore_file_not_found(self) -> core::result::Result<(), std::io::Error> {
match self {
Ok(_) => Ok(()),
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Ok(()),
_ => todo!(),
},
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use http::{header, HeaderMap}; use http::{header, HeaderMap};

View file

@ -1,40 +1,46 @@
<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style> <style>
body { * { padding: 0; margin: 0; --color-secondary: #dedede; --color-text: #000;
background-color: #000; --color-text-light: #888; --color-border: #ccc; } body { font-family: sans-serif;
color: #ddd; text-rendering: optimizespeed; background-color: #f5f5f5; color:
text-align: center; var(--color-text); } a { color: #006ed3; text-decoration: none; } a:hover { color:
font-family: monospace; #319cff; } .card { display: flex; flex-direction: column; width: 90%; max-width:
font-size: 16px; 500px; align-items: center; } .input-row { display: flex; width: 100%; } .center {
width: 100%; width: 100%; display: flex; flex-direction: row; justify-content: center; } .light
margin: 0; { color: var(--color-text-light); } p { margin: 16px 0; } header { gap: 1em;
display: flex; padding-top: 10px; padding-bottom: 10px; background-color: #f2f2f2; } footer {
justify-content: center; padding: 40px 20px; font-size: 12px; text-align: center; } @media
} (prefers-color-scheme: dark) { * { --color-secondary: #082437; --color-border:
#212121; --color-text: #dddddd; } body { background-color: #101010; } header {
.card { background-color: #151515; } }
display: flex;
flex-direction: column;
width: 90%;
max-width: 500px;
align-items: center;
}
.dark {
color: #aaa;
}
</style> </style>
<title>Artifactview</title> <title>Artifactview</title>
</head> </head>
<body> <body>
<div class="card"> <header class="center">
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 13.229 13.229"><g aria-label="AV" style="font-size:10.5833px;line-height:1.25;stroke-width:.264583"><path d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z" style="fill:#888;fill-opacity:1"/><path d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z" style="font-size:10.5833px;line-height:1.25;fill:#ddd;fill-opacity:1;stroke-width:.264583"/></g></svg> <svg
<h1><span class="dark">Error</span> {{status}}</h1> xmlns="http://www.w3.org/2000/svg"
<p>{{msg}}</p> width="50"
height="50"
viewBox="0 0 13.229 13.229"
><g aria-label="AV" style="stroke-width:.264583"><path
d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
style="fill:var(--color-text-light);fill-opacity:1"
/><path
d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z"
style="fill:var(--color-text);fill-opacity:1;stroke-width:.264583"
/></g></svg>
</header>
<div class="center">
<div class="card">
<h1><span class="light">Error</span> {{status}}</h1>
<p>{{msg}}</p>
</div>
</div> </div>
</body> </body>
</html> </html>

View file

@ -1,98 +1,72 @@
<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style> <style>
body { * { padding: 0; margin: 0; --color-secondary: #dedede; --color-text: #000;
background-color: #000; --color-text-light: #888; --color-border: #ccc; } body { font-family: sans-serif;
color: #ddd; text-rendering: optimizespeed; background-color: #f5f5f5; color:
text-align: center; var(--color-text);} a { color: #006ed3; text-decoration: none; } a:hover { color:
font-family: monospace; #319cff; } .card { display: flex; flex-direction: column; width: 90%; max-width:
font-size: 16px; 500px; align-items: center; } .input-row { display: flex; width: 100%; } .center {
width: 100%; width: 100%; display: flex; flex-direction: row; justify-content: center; } .light
margin: 0; { color: var(--color-text-light); } input { color: inherit; font-size: 16px;
display: flex; height: 32px; border: 1px solid var(--color-border); padding: 4px 8px; } button {
justify-content: center; background-color: #006ed3; color: #fff; padding: 4px 8px; border: none; cursor:
} pointer; } button:hover { opacity: 0.7; } p { margin: 16px 0; } header { gap: 1em;
padding-top: 10px; padding-bottom: 10px; background-color: #f2f2f2; } footer {
input { padding: 40px 20px; font-size: 12px; text-align: center; } @media
background-color: transparent; (prefers-color-scheme: dark) { * { --color-text: #dddddd; --color-secondary:
color: inherit; #082437; --color-border: #212121; } body { background-color: #101010; } input
font-size: 16px; {background-color: #151515;} header { background-color: #151515; }}
border: none;
border-bottom: 2px solid #ddd;
outline: none;
}
button {
background-color: transparent;
color: inherit;
font-size: 16px;
font-weight: bold;
border: none;
outline: none;
border-radius: 4px;
cursor: pointer;
}
button:active {
color: #00bfff;
}
a {
color: inherit;
text-decoration: underline;
}
a:active {
color: #00bfff;
}
.card {
display: flex;
flex-direction: column;
width: 90%;
max-width: 500px;
align-items: center;
}
.flex-row {
display: flex;
width: 100%;
}
.footer {
margin-top: 1.5rem;
font-size: 14px;
color: #aaa;
}
</style> </style>
<title>Artifactview</title> <title>Artifactview</title>
</head> </head>
<body> <body>
<div class="card"> <header class="center">
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 13.229 13.229"><g aria-label="AV" style="font-size:10.5833px;line-height:1.25;stroke-width:.264583"><path d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z" style="fill:#888;fill-opacity:1"/><path d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z" style="font-size:10.5833px;line-height:1.25;fill:#ddd;fill-opacity:1;stroke-width:.264583"/></g></svg> <svg
<p>Enter a GitHub/Gitea/Forgejo Actions run url to browse CI artifacts</p> xmlns="http://www.w3.org/2000/svg"
<form method="POST" class="flex-row"> width="50"
<input height="50"
name="url" viewBox="0 0 13.229 13.229"
type="text" ><g aria-label="AV" style="stroke-width:.264583"><path
placeholder="codeberg.org/username/repo/actions/runs/42" d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
style="flex-grow: 1" style="fill:var(--color-text-light);fill-opacity:1"
/> /><path
<button type="submit">[&gt;]</button> d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z"
</form> style="fill:var(--color-text);fill-opacity:1;stroke-width:.264583"
<p class="footer"> /></g></svg>
Artifactview {{version}} </header>
<a <div class="center">
href="https://code.thetadev.de/ThetaDev/artifactview" <div class="card">
target="_blank" <p>Enter a GitHub/Gitea/Forgejo Actions run url to browse CI artifacts</p>
rel="noopener noreferrer" <form method="POST" class="input-row">
> <input
[Source code] name="url"
</a> type="text"
</p> placeholder="codeberg.org/username/repo/actions/runs/42"
style="flex-grow: 1"
/>
<button type="submit">Browse</button>
</form>
</div>
</div> </div>
<footer>
<a
href="https://code.thetadev.de/ThetaDev/artifactview"
target="_blank"
rel="noopener noreferrer"
>
Artifactview
</a>
{{version}}
<p class="light">
<b>Disclaimer:</b>
Artifactview does not host any websites, the data is fetched from the respective
software forge and is only stored temporarily on this server. The publisher of
the artifact is the only one responsible for the content. Most forges delete
artifacts after 90 days.
</p>
</footer>
</body> </body>
</html> </html>

View file

@ -1,82 +1,174 @@
<!DOCTYPE html>
<html> <html>
<head> <head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" /> <meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<style type="text/css"> <style type="text/css">
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text-light: gray;}body {font-family: sans-serif;text-rendering: optimizespeed;background-color: #f5f5f5;}a {color: #006ed3;text-decoration: none;}a:hover {color: #319cff;}#summary, header {padding: 0 20px;}header {display: flex;flex-direction: row;gap: 1em;padding-top: 25px;padding-bottom: 15px;background-color: #f2f2f2;}header h1 {font-size: 20px;font-weight: normal;white-space: nowrap;overflow-x: hidden;text-overflow: ellipsis;color: #999;}header h1 a {color: #000;margin: 0 4px;}footer a:hover, header h1 a:hover {text-decoration: underline;}header h1 a:first-child {margin: 0;}main {display: block;}.meta {font-size: 12px;font-family: Verdana, sans-serif;border-bottom: 1px solid #9c9c9c;padding-top: 10px;padding-bottom: 10px;}.meta-item {margin-right: 1em;}#filter {padding: 4px;border: 1px solid #ccc;}#list {width: 100%;border-collapse: collapse;}#list tr {border-bottom: 1px dashed #dadada;}#list tbody tr:hover {background-color: #ffffec;}#list td, #list th {text-align: left;padding: 10px 0;}#list th {padding-top: 15px;padding-bottom: 15px;font-size: 16px;white-space: nowrap;}#list th a {color: black;}#list th svg {vertical-align: middle;}#list td {white-space: nowrap;font-size: 14px;}#list td:nth-child(1), #list th:nth-child(1) {padding-left: 20px;width: 80%;}#list td:nth-child(2), #list th:nth-child(2) {text-align: right;padding: 0 20px;}#list td:nth-child(3), #list th:nth-child(3) {text-align: right;padding-right: 20px;}#list td:nth-child(1) svg {position: absolute;}#list td .goup, #list td .name {margin-left: 1.75em;word-break: break-all;overflow-wrap: break-word;white-space: pre-wrap;}footer {padding: 40px 20px;font-size: 12px;text-align: center;}@media (max-width: 600px) {td:nth-child(1) {width: auto;}td:nth-child(2), th:nth-child(2) {display: none;}h1 a {margin: 0;}#filter {max-width: 100px;}}@media (prefers-color-scheme: dark) {* {--color-secondary: #082437;--color-text-light: rgb(139, 157, 169);}body {background-color: #101010;color: #dddddd;}header {background-color: #151515;}#list tbody tr:hover {background-color: #252525;}#list th a, header h1 a {color: #dddddd;}a {color: #5796d1;text-decoration: none;}a:hover, h1 a:hover {color: #62b2fd;}#list tr {border-bottom: 1px dashed rgba(255, 255, 255, 0.12);}#filter {background-color: #151515;color: #ffffff;border: 1px solid #212121;}.meta {border-bottom: 1px solid #212121;}} * {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
</style> #000;--color-text-light: #888;}body {font-family: sans-serif;text-rendering:
<title> optimizespeed;background-color: #f5f5f5;color: var(--color-text);}a {color:
Index of {{artifact_name}} #006ed3;text-decoration: none;}a:hover {color: #319cff;}#summary, header {padding:
</title> 0 20px;}header {display: flex;flex-direction: row;gap: 1em;padding-top:
</head> 25px;padding-bottom: 15px;background-color: #f2f2f2;}header h1 {font-size:
20px;font-weight: normal;white-space: nowrap;overflow-x: hidden;text-overflow:
ellipsis;color: #999;}header h1 a {color: var(--color-text);margin: 0 4px;}footer
a:hover, header h1 a:hover {text-decoration: underline;}header h1 a:first-child
{margin: 0;}main {display: block;}.meta {font-size: 12px;font-family: Verdana,
sans-serif;border-bottom: 1px solid #9c9c9c;padding-top: 10px;padding-bottom:
10px;}.meta-item {margin-right: 1em;}#filter {padding: 4px;border: 1px solid
#ccc;}#list {width: 100%;border-collapse: collapse;}#list tr {border-bottom: 1px
dashed #dadada;}#list tbody tr:hover {background-color: #ffffec;}#list td, #list
th {text-align: left;padding: 10px 0;}#list th {padding-top: 15px;padding-bottom:
15px;font-size: 16px;white-space: nowrap;}#list th a {color:
var(--color-text);}#list th svg {vertical-align: middle;}#list td {white-space:
nowrap;font-size: 14px;}#list td:nth-child(1), #list th:nth-child(1)
{padding-left: 20px;width: 80%;}#list td:nth-child(2), #list th:nth-child(2)
{text-align: right;padding: 0 20px;}#list td:nth-child(3), #list th:nth-child(3)
{text-align: right;padding-right: 20px;}#list td:nth-child(1) svg {position:
absolute;}#list td .goup, #list td .name {margin-left: 1.75em;word-break:
break-all;overflow-wrap: break-word;white-space: pre-wrap;}footer {padding: 40px
20px;font-size: 12px;text-align: center;}@media (max-width: 600px)
{td:nth-child(1) {width: auto;}td:nth-child(2), th:nth-child(2) {display: none;}h1
a {margin: 0;}#filter {max-width: 100px;}}@media (prefers-color-scheme: dark) {*
{--color-secondary: #082437;--color-text: #dddddd;}
body {background-color: #101010;}header {background-color:
#151515;}#list tbody tr:hover {background-color: #252525;}a {color:
#5796d1;text-decoration: none;}a:hover, h1 a:hover {color: #62b2fd;}#list tr
{border-bottom: 1px dashed rgba(255, 255, 255, 0.12);}#filter {background-color:
#151515;color: #ffffff;border: 1px solid #212121;}.meta {border-bottom: 1px solid
#212121;}}
</style>
<title>
Index:
{{artifact_name}}
</title>
</head>
<body onload="initFilter()"> <body onload="initFilter()">
<svg xmlns="http://www.w3.org/2000/svg" height="0" width="0" style="position:absolute"><defs><g id="folder" fill-rule="nonzero" fill="none"><path d="M285.22 37.55h-142.6L110.9 0H31.7C14.25 0 0 16.9 0 37.55v75.1h316.92V75.1c0-20.65-14.26-37.55-31.7-37.55z" fill="#FFA000"/><path d="M285.22 36H31.7C14.25 36 0 50.28 0 67.74v158.7c0 17.47 14.26 31.75 31.7 31.75h253.5c17.44 0 31.7-14.3 31.7-31.75V67.75c0-17.47-14.26-31.75-31.7-31.75z" fill="#FFCA28"/></g><g id="file" stroke="#000" stroke-width="25" fill="#FFF" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"><path d="M13 24.12v274.76c0 6.16 5.87 11.12 13.17 11.12H239c7.3 0 13.17-4.96 13.17-11.12V136.15S132.6 13 128.37 13H26.17C18.87 13 13 17.96 13 24.12z"/><path d="M129.37 13 129 113.9c0 10.58 7.26 19.1 16.27 19.1H249L129.37 13z"/></g></defs></svg> <svg
xmlns="http://www.w3.org/2000/svg"
height="0"
width="0"
style="position:absolute"
><defs><g id="folder" fill-rule="nonzero" fill="none"><path
d="M285.22 37.55h-142.6L110.9 0H31.7C14.25 0 0 16.9 0 37.55v75.1h316.92V75.1c0-20.65-14.26-37.55-31.7-37.55z"
fill="#FFA000"
/><path
d="M285.22 36H31.7C14.25 36 0 50.28 0 67.74v158.7c0 17.47 14.26 31.75 31.7 31.75h253.5c17.44 0 31.7-14.3 31.7-31.75V67.75c0-17.47-14.26-31.75-31.7-31.75z"
fill="#FFCA28"
/></g><g
id="file"
stroke="#000"
stroke-width="25"
fill="#FFF"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
><path
d="M13 24.12v274.76c0 6.16 5.87 11.12 13.17 11.12H239c7.3 0 13.17-4.96 13.17-11.12V136.15S132.6 13 128.37 13H26.17C18.87 13 13 17.96 13 24.12z"
/><path
d="M129.37 13 129 113.9c0 10.58 7.26 19.1 16.27 19.1H249L129.37 13z"
/></g></defs></svg>
<header> <header>
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;"> <a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 13.229 13.229"><g aria-label="AV" style="font-size:10.5833px;line-height:1.25;stroke-width:.264583"><path d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z" style="fill:#888;fill-opacity:1"/><path d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z" style="font-size:10.5833px;line-height:1.25;fill:#ddd;fill-opacity:1;stroke-width:.264583"/></g></svg> <svg
</a> xmlns="http://www.w3.org/2000/svg"
<h1> width="32"
{{#each path_components}}<a href="{{this.url}}">{{this.name}}</a> /{{/each}} height="32"
</h1> viewBox="0 0 13.229 13.229"
</header> ><g
aria-label="AV"
style="stroke-width:.264583"
><path
d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
style="fill:var(--color-text-light);fill-opacity:1"
/><path
d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z"
style="fill:var(--color-text);fill-opacity:1;stroke-width:.264583"
/></g></svg>
</a>
<h1>
{{#each path_components}}<a href="{{this.url}}">{{this.name}}</a> /{{/each}}
</h1>
</header>
<main>
<div class="meta">
<div id="summary">
<span class="meta-item"><b>{{n_dirs}}</b> director{{#if n_dirs != 1}}ies{{else}}y{{/if}}</span>
<span class="meta-item"><b>{{n_files}}</b> file{{#if n_files != 1}}s{{/if}}</span>
<span class="meta-item"><a
href="{{run_url}}"
target="_blank"
rel="noopener noreferrer"
>CI run</a></span>
<span class="meta-item"><input
type="text"
placeholder="filter"
id="filter"
onkeyup="filter()"
/></span>
</div>
</div>
<div class="listing">
<table id="list" aria-describedby="summary">
<thead>
<tr>
<th><a href="?C=N&amp;O=A">Name</a>&nbsp;<a
href="?C=N&amp;O=D"
>&nbsp;&darr;&nbsp;</a></th>
<th><a href="?C=S&amp;O=A">Size</a>&nbsp;<a
href="?C=S&amp;O=D"
>&nbsp;&darr;&nbsp;</a></th>
<th>CRC32</th>
</tr>
</thead>
<tbody>
{{#if has_parent}}
<tr>
<td><a href=".."><span class="goup">Parent directory</span></a></td>
<td>&mdash;</td>
<td>&mdash;</td>
</tr>
{{/if}}
{{#each entries}}
<tr class="file">
<td>
<a href="{{this.name}}">
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 317 259"><use xlink:href="{{#if this.is_dir}}#folder{{else}}#file{{/if}}"></use></svg>
<span class="name">{{this.name}}</span>
</a>
</td>
<td>{{#if this.is_dir}}&mdash;{{else}}{{this.size}}{{/if}}</td>
<td>{{#if this.is_dir}}&mdash;{{else}}{{this.crc32}}{{/if}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</main>
<main> <footer>
<div class="meta"> Served with
<div id="summary"> <a
<span class="meta-item"><b>{{n_dirs}}</b> directories</span> href="https://code.thetadev.de/ThetaDev/artifactview"
<span class="meta-item"><b>{{n_files}}</b> files</span> target="_blank"
<span class="meta-item"><input type="text" placeholder="filter" id="filter" onkeyup="filter()"/></span> rel="noopener noreferrer"
</div> >Artifactview</a>
</div> {{version}}
<div class="listing"> </footer>
<table id="list" aria-describedby="summary"> <script>
<thead>
<tr>
<th><a href="?C=N&amp;O=A">Name</a>&nbsp;<a href="?C=N&amp;O=D">&nbsp;&darr;&nbsp;</a></th>
<th><a href="?C=S&amp;O=A">Size</a>&nbsp;<a href="?C=S&amp;O=D">&nbsp;&darr;&nbsp;</a></th>
<th>CRC32</th>
</tr>
</thead>
{{#if has_parent}}
<tbody>
<tr>
<td><a href=".."><span class="goup">Parent directory</span></a></td>
<td>&mdash;</td>
<td>&mdash;</td>
</tr>
{{/if}}
{{#each entries}}
<tr class="file">
<td>
<a href="{{this.name}}">
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 317 259"><use xlink:href="{{#if this.is_dir}}#folder{{else}}#file{{/if}}"></use></svg>
<span class="name">{{this.name}}</span>
</a>
</td>
<td>{{#if this.is_dir}}&mdash;{{else}}{{this.size}}{{/if}}</td>
<td>{{#if this.is_dir}}&mdash;{{else}}{{this.crc32}}{{/if}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</main>
<footer> // @license
Served with <a href="https://code.thetadev.de/ThetaDev/artifactview" target="_blank" rel="noopener noreferrer">Artifactview</a> {{version}} magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT var
</footer> 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> </script>
</body>
// @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>
</html> </html>

View file

@ -1,49 +1,186 @@
<!DOCTYPE html> <html>
<html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width" />
<style> <style type="text/css">
body { * {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
background-color: #000; #000;--color-text-light: #888;}body {font-family: sans-serif;text-rendering:
color: #ddd; optimizespeed;background-color: #f5f5f5;color: var(--color-text);}a {color:
text-align: center; #006ed3;text-decoration: none;}a:hover {color: #319cff;}#summary, header {padding:
font-family: monospace; 0 20px;}header {display: flex;flex-direction: row;gap: 1em;padding-top:
font-size: 16px; 25px;padding-bottom: 15px;background-color: #f2f2f2;}header h1 {font-size:
width: 100%; 20px;font-weight: normal;white-space: nowrap;overflow-x: hidden;text-overflow:
margin: 0; ellipsis;color: #999;}header h1 a {color: var(--color-text);margin: 0 4px;}footer
display: flex; a:hover, header h1 a:hover {text-decoration: underline;}header h1 a:first-child
justify-content: center; {margin: 0;}main {display: block;}.meta {font-size: 12px;font-family: Verdana,
} sans-serif;border-bottom: 1px solid #9c9c9c;padding-top: 10px;padding-bottom:
10px;}.meta-item {margin-right: 1em;}#filter {padding: 4px;border: 1px solid
a { #ccc;}#list {width: 100%;border-collapse: collapse;}#list tr {border-bottom: 1px
color: inherit; dashed #dadada;}#list tbody tr:hover {background-color: #ffffec;}#list td, #list
text-decoration: underline; th {text-align: left;padding: 10px 0;}#list th {padding-top: 15px;padding-bottom:
} 15px;font-size: 16px;white-space: nowrap;}#list th a {color:
var(--color-text);}#list th svg {vertical-align: middle;}#list td {white-space:
a:active { nowrap;font-size: 14px;}#list td:nth-child(1), #list th:nth-child(1)
color: #00bfff; {padding-left: 20px;width: 80%;}#list td:nth-child(2), #list th:nth-child(2)
} {text-align: right;padding: 0 20px;}#list td:nth-child(3), #list th:nth-child(3)
{text-align: right;padding-right: 20px;}#list td:nth-child(1) svg {position:
.card { absolute;}#list td .goup, #list td .name {margin-left: 1.75em;word-break:
display: flex; break-all;overflow-wrap: break-word;white-space: pre-wrap;}footer {padding: 40px
flex-direction: column; 20px;font-size: 12px;text-align: center;}p { margin: 16px 0; }.light{ color:
width: 90%; var(--color-text-light); } @media (max-width: 600px)
max-width: 500px; {td:nth-child(1) {width: auto;}td:nth-child(2), th:nth-child(2) {display: none;}h1
align-items: center; a {margin: 0;}#filter {max-width: 100px;}}.expired {filter: grayscale(100%);}
} @media (prefers-color-scheme: dark) {*{--color-secondary: #082437;--color-text: #dddddd;}
body {background-color: #101010;}header {background-color:
#151515;}#list tbody tr:hover {background-color: #252525;}a {color:
#5796d1;text-decoration: none;}a:hover, h1 a:hover {color: #62b2fd;}#list tr
{border-bottom: 1px dashed rgba(255, 255, 255, 0.12);}#filter {background-color:
#151515;color: #ffffff;border: 1px solid #212121;}.meta {border-bottom: 1px solid
#212121;}}
</style> </style>
<title>Artifactview</title> <title>
Artifacts:
{{run_name}}
</title>
</head> </head>
<body>
<div class="card"> <body onload="initFilter()">
<a href="{{main_url}}" aria-label="Back to main page" style="height: 50px;"> <svg
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 13.229 13.229"><g aria-label="AV" style="font-size:10.5833px;line-height:1.25;stroke-width:.264583"><path d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z" style="fill:#888;fill-opacity:1"/><path d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z" style="font-size:10.5833px;line-height:1.25;fill:#ddd;fill-opacity:1;stroke-width:.264583"/></g></svg> xmlns="http://www.w3.org/2000/svg"
height="0"
width="0"
style="position:absolute"
><defs><g id="folder" fill-rule="nonzero" fill="none"><path
d="M285.22 37.55h-142.6L110.9 0H31.7C14.25 0 0 16.9 0 37.55v75.1h316.92V75.1c0-20.65-14.26-37.55-31.7-37.55z"
fill="#FFA000"
/><path
d="M285.22 36H31.7C14.25 36 0 50.28 0 67.74v158.7c0 17.47 14.26 31.75 31.7 31.75h253.5c17.44 0 31.7-14.3 31.7-31.75V67.75c0-17.47-14.26-31.75-31.7-31.75z"
fill="#FFCA28"
/></g></defs></svg>
<header>
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 13.229 13.229"
><g
aria-label="AV"
style="stroke-width:.264583"
><path
d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
style="fill:#888;fill-opacity:1"
/><path
d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z"
style="fill:#ddd;fill-opacity:1;stroke-width:.264583"
/></g></svg>
</a> </a>
<p>CI artifacts for <a href="{{run_url}}" target="_blank" rel="noopener noreferrer">{{run_name}}</a>:</p> <h1>
{{#each artifacts}} <a href="/">{{run_name}}</a>
<a href="{{this.url}}">{{this.name}}</a> /
{{/each}} </h1>
</div> </header>
<main>
<div class="meta">
<div id="summary">
<span class="meta-item"><b>{{artifacts.len()}}</b> artifact{{#if artifacts.len() != 1}}s{{/if}}</span>
<span class="meta-item"><a
href="{{run_url}}"
target="_blank"
rel="noopener noreferrer"
>CI run</a></span>
<span class="meta-item"><input
type="text"
placeholder="filter"
id="filter"
onkeyup="filter()"
/></span>
</div>
</div>
<div class="listing">
<table id="list" aria-describedby="summary">
<thead>
<tr>
<th>Artifact</th>
<th>Size</th>
<th>Download</th>
</tr>
</thead>
<tbody>
{{#each artifacts}}
<tr class="file">
{{#if this.expired}}
<td>
<svg
class="expired"
width="1.5em"
height="1em"
version="1.1"
viewBox="0 0 317 259"
><use xlink:href="#folder"></use></svg>
<span class="name light">{{this.name}}</span>
</td>
{{else}}
<td>
<a href="{{this.url}}">
<svg
width="1.5em"
height="1em"
version="1.1"
viewBox="0 0 317 259"
><use xlink:href="#folder"></use></svg>
<span class="name">{{this.name}}</span>
</a>
</td>
{{/if}}
<td>{{this.size}}</td>
<td>
{{#if this.expired}}
&mdash;
{{else}}
<a href="{{this.download_url}}" rel="noopener noreferrer">Download</a>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</main>
<footer>
Served with
<a
href="https://code.thetadev.de/ThetaDev/artifactview"
target="_blank"
rel="noopener noreferrer"
>Artifactview</a>
{{version}}
<p class="light">
<b>Disclaimer:</b> Artifactview does not host any websites, the data is fetched
from the respective software forge and is only stored temporarily on this server.
The publisher of this artifact,
<a href="{{publisher.url}}" target="_blank" rel="noopener noreferrer">{{publisher.name}}</a>,
is the only one responsible for the content.
Most forges delete artifacts after 90 days.
</p>
</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
</script>
</body> </body>
</html> </html>