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}]
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

167
Cargo.lock generated
View file

@ -150,9 +150,11 @@ dependencies = [
"envy",
"flate2",
"futures-lite",
"governor",
"headers",
"hex",
"http",
"humansize",
"mime",
"mime_guess",
"once_cell",
@ -169,7 +171,6 @@ dependencies = [
"serde-env",
"serde-hex",
"serde_json",
"siphasher",
"thiserror",
"tokio",
"tokio-util",
@ -531,6 +532,19 @@ dependencies = [
"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]]
name = "deflate64"
version = "0.1.8"
@ -683,6 +697,21 @@ dependencies = [
"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]]
name = "futures-channel"
version = "0.3.30"
@ -690,6 +719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@ -698,6 +728,17 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "futures-io"
version = "0.3.30"
@ -717,6 +758,17 @@ dependencies = [
"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]]
name = "futures-sink"
version = "0.3.30"
@ -729,16 +781,28 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
@ -774,6 +838,26 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "h2"
version = "0.4.5"
@ -890,6 +974,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "humantime"
version = "2.1.0"
@ -1164,12 +1257,24 @@ dependencies = [
"tempfile",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nodrop"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -1373,6 +1478,12 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "portable-atomic"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0"
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -1424,6 +1535,21 @@ dependencies = [
"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]]
name = "quick-error"
version = "1.2.3"
@ -1490,6 +1616,15 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.5.1"
@ -1568,10 +1703,12 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"winreg",
]
@ -1830,12 +1967,6 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.9"
@ -1870,6 +2001,15 @@ dependencies = [
"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]]
name = "subtle"
version = "2.5.0"
@ -2374,6 +2514,19 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "web-sys"
version = "0.3.69"

View file

@ -11,9 +11,11 @@ dotenvy = "0.15.7"
envy = { path = "crates/envy" }
flate2 = "1.0.30"
futures-lite = "2.3.0"
governor = "0.6.3"
headers = "0.4.0"
hex = "0.4.3"
http = "1.1.0"
humansize = "2.1.3"
mime = "0.3.17"
mime_guess = "2.0.4"
once_cell = "1.19.0"
@ -23,12 +25,11 @@ pin-project = "1.1.5"
quick_cache = "0.5.1"
rand = "0.8.5"
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-env = "0.1.1"
serde-hex = "0.1.0"
serde_json = "1.0.117"
siphasher = "1.0.1"
thiserror = "1.0.61"
tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] }
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 axum::{
@ -23,13 +23,13 @@ use tokio_util::{
use tower_http::trace::{DefaultOnResponse, TraceLayer};
use crate::{
artifact_api::{Artifact, ArtifactApi, ArtifactOrRun},
cache::{Cache, CacheEntry, GetEntryResult, GetFileResult, GetFileResultFile, IndexEntry},
artifact_api::{Artifact, ArtifactApi},
cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile, IndexEntry},
config::Config,
error::{Error, Result},
gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN},
query::Query,
templates::{self, LinkItem},
templates::{self, ArtifactItem, LinkItem},
util::{self, InsertTypedHeader},
App,
};
@ -70,11 +70,13 @@ impl App {
let listener = tokio::net::TcpListener::bind(address).await?;
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()
// Prevent search indexing since artifactview serves temporary artifacts
.route(
"/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
.route("/.well-known/api/artifacts", get(Self::get_artifacts))
@ -87,16 +89,21 @@ impl App {
.route("/", get(Self::get_page))
.route("/", post(Self::post_homepage))
.fallback(get(Self::get_page))
.with_state(self.new_state())
.with_state(state)
// Log requests
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| {
tracing::error_span!("request", url = util::full_url_from_request(request),)
.make_span_with(move |request: &Request<Body>| {
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)),
);
axum::serve(listener, router).await?;
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
@ -120,22 +127,26 @@ impl App {
let query = Query::from_subdomain(subdomain)?;
let path = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy();
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())? {
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) => {
if !path.ends_with('/') {
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![
LinkItem {
name: query.shortid(),
@ -145,7 +156,7 @@ impl App {
.url_with_subdomain(&query.subdomain_with_artifact(None)),
},
LinkItem {
name: artifact_name.to_owned(),
name: entry.name.to_owned(),
url: "/".to_string(),
},
];
@ -162,7 +173,8 @@ impl App {
let tmpl = templates::Listing {
main_url: state.i.cfg.main_url(),
version: templates::Version,
artifact_name: &artifact_name,
run_url: &query.forge_url(),
artifact_name: &entry.name,
path_components,
n_dirs: listing.n_dirs,
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() != "/" {
return Err(Error::NotFound("path".into()));
}
@ -185,11 +199,16 @@ impl App {
}
let tmpl = templates::Selection {
main_url: state.i.cfg.main_url(),
version: templates::Version,
run_url: &query.forge_url(),
run_name: &query.shortid(),
publisher: LinkItem {
name: query.user.to_owned(),
url: format!("https://{}/{}", query.host, query.user),
},
artifacts: artifacts
.into_iter()
.map(|a| LinkItem::from_artifact(a, &query, &state.i.cfg))
.map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg))
.collect(),
};
Ok(Response::builder()
@ -233,7 +252,7 @@ impl App {
// Dont serve files above the configured size limit
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(
format!(
"file too large (size: {}, limit: {})",
@ -246,29 +265,25 @@ impl App {
let mut resp = Response::builder()
.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 {
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
if let Some(modified) = entry.last_modified {
if let Some(if_unmodified_since) = hdrs.typed_get::<headers::IfUnmodifiedSince>() {
if !if_unmodified_since.precondition_passes(modified) {
if !if_unmodified_since.precondition_passes(entry.last_modified) {
return Ok(resp
.status(StatusCode::PRECONDITION_FAILED)
.body(Body::empty())?);
}
}
if let Some(if_modified_since) = hdrs.typed_get::<headers::IfModifiedSince>() {
if !if_modified_since.is_modified(modified) {
if !if_modified_since.is_modified(entry.last_modified) {
return Ok(resp.status(StatusCode::NOT_MODIFIED).body(Body::empty())?);
}
}
}
let zip_file = File::open(&zip_path).await?;
let range = hdrs.typed_get::<headers::Range>();
@ -352,7 +367,7 @@ impl App {
Host(host): Host,
) -> Result<Json<Vec<Artifact>>> {
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?;
Ok(Json(artifacts))
}
@ -363,37 +378,25 @@ impl App {
Host(host): Host,
) -> Result<Json<Artifact>> {
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
let query = Query::from_subdomain(subdomain)?;
if query.artifact.is_none() {
return Err(Error::BadRequest("no artifact specified".into()));
}
let query = Query::from_subdomain(subdomain)?.try_into_artifactquery()?;
let artifact = state.i.api.fetch(&query).await?;
match artifact {
ArtifactOrRun::Artifact(artifact) => Ok(Json(artifact)),
ArtifactOrRun::Run(_) => unreachable!(),
}
Ok(Json(artifact))
}
/// API endpoint to get a file listing
async fn get_files(
State(state): State<AppState>,
Host(host): Host,
request: Request,
) -> Result<Json<Vec<IndexEntry>>> {
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
let query = Query::from_subdomain(subdomain)?;
if query.artifact.is_none() {
return Err(Error::BadRequest("no artifact specified".into()));
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()?;
let entry_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
if entry_res.downloaded {
state.garbage_collect();
}
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();
let files = entry_res.entry.get_files();
Ok(Json(files))
}
}
@ -407,4 +410,14 @@ impl AppState {
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
use std::{fs::File, io::Cursor, path::Path};
use std::path::Path;
use futures_lite::StreamExt;
use http::header;
use quick_cache::sync::Cache as QuickCache;
use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Url};
use serde::{Deserialize, Serialize};
use tokio::{fs::File, io::AsyncWriteExt};
use crate::{
config::Config,
error::{Error, Result},
query::Query,
query::{ArtifactQuery, QueryData},
};
pub struct ArtifactApi {
http: Client,
cfg: Config,
qc: QuickCache<String, Vec<Artifact>>,
}
#[derive(Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct Artifact {
pub id: u64,
pub name: String,
pub size: u64,
pub expired: bool,
/// Artifact download URL used by the server
pub download_url: String,
}
pub enum ArtifactOrRun {
Artifact(Artifact),
Run(Vec<Artifact>),
/// Artifact download URL shown to the user. If None, download_url is used
///
/// GitHub uses different download URLs for their API and their frontend.
pub user_download_url: Option<String>,
}
#[derive(Deserialize)]
@ -59,20 +63,24 @@ enum ForgejoArtifactStatus {
Expired,
}
impl From<GithubArtifact> for Artifact {
fn from(value: GithubArtifact) -> Self {
Self {
id: value.id,
name: value.name,
size: value.size_in_bytes,
expired: value.expired,
download_url: value.archive_download_url,
impl GithubArtifact {
fn into_artifact<T>(self, query: &QueryData<T>) -> Artifact {
Artifact {
id: self.id,
name: self.name,
size: self.size_in_bytes,
expired: self.expired,
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 {
fn into_artifact(self, id: u64, query: &Query) -> Artifact {
fn into_artifact<T>(self, id: u64, query: &QueryData<T>) -> Artifact {
Artifact {
download_url: format!(
"https://{}/{}/{}/actions/runs/{}/artifacts/{}",
@ -82,6 +90,7 @@ impl ForgejoArtifact {
name: self.name,
size: self.size,
expired: matches!(self.status, ForgejoArtifactStatus::Expired),
user_download_url: None,
}
}
}
@ -97,35 +106,36 @@ impl ArtifactApi {
))
.build()
.unwrap(),
qc: QuickCache::new(cfg.load().mem_cache_size),
cfg,
}
}
pub async fn list(&self, query: &Query) -> Result<Vec<Artifact>> {
pub async fn list<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
let subdomain = query.subdomain_with_artifact(None);
self.qc
.get_or_insert_async(&subdomain, async {
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() {
self.fetch_github(query).await
} else {
// Forgejo currently has no API for fetching single artifacts
let mut artifacts = self.list_forgejo(query).await?;
match query.artifact {
Some(artifact) => {
let i = usize::try_from(artifact)?;
let i = usize::try_from(query.artifact)?;
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 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(
format!(
"artifact too large (size: {}, limit: {})",
@ -165,15 +179,23 @@ impl ArtifactApi {
}
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)?;
std::fs::rename(&tmp_path, path)?;
{
let mut file = File::create(&tmp_path).await?;
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);
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!(
"https://{}/{}/{}/actions/runs/{}/artifacts",
query.host, query.user, query.repo, query.run
@ -198,7 +220,7 @@ impl ArtifactApi {
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!(
"https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts",
query.user, query.repo, query.run
@ -212,15 +234,17 @@ impl ArtifactApi {
.json::<ArtifactsWrap<GithubArtifact>>()
.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> {
match query.artifact {
Some(artifact) => {
async fn fetch_github(&self, query: &ArtifactQuery) -> Result<Artifact> {
let url = format!(
"https://api.github.com/repos/{}/{}/actions/artifacts/{}",
query.user, query.repo, artifact
query.user, query.repo, query.artifact
);
let artifact = self
@ -230,10 +254,7 @@ impl ArtifactApi {
.error_for_status()?
.json::<GithubArtifact>()
.await?;
Ok(ArtifactOrRun::Artifact(artifact.into()))
}
None => Ok(ArtifactOrRun::Run(self.list_github(query).await?)),
}
Ok(artifact.into_artifact(query))
}
fn get_github<U: IntoUrl>(&self, url: U) -> RequestBuilder {
@ -248,57 +269,39 @@ impl ArtifactApi {
#[cfg(test)]
mod tests {
use crate::{config::Config, query::Query};
use crate::{config::Config, query::ArtifactQuery};
use super::{ArtifactApi, ArtifactOrRun};
use super::ArtifactApi;
#[tokio::test]
async fn fetch_forgejo() {
let query = Query {
let query = ArtifactQuery {
host: "code.thetadev.de".to_owned(),
user: "HSA".to_owned(),
repo: "Visitenbuch".to_owned(),
run: 32,
artifact: Some(1),
artifact: 1,
};
let api = ArtifactApi::new(Config::default());
let res = api.fetch(&query).await.unwrap();
if let ArtifactOrRun::Artifact(res) = res {
assert_eq!(res.name, "playwright-report");
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]
async fn fetch_github() {
let query = Query {
let query = ArtifactQuery {
host: "github.com".to_owned(),
user: "actions".to_owned(),
repo: "upload-artifact".to_owned(),
run: 8805345396,
artifact: Some(1440556464),
artifact: 1440556464,
};
let api = ArtifactApi::new(Config::default());
let res = api.fetch(&query).await.unwrap();
if let ArtifactOrRun::Artifact(res) = res {
assert_eq!(res.name, "Artifact-Wildcard-macos-latest");
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::{
borrow::Cow,
collections::{BTreeMap, HashMap},
fs::FileTimes,
net::IpAddr,
num::{NonZeroU32, NonZeroUsize},
path::{Path, PathBuf},
sync::Arc,
time::{Duration, SystemTime},
};
use async_zip::{tokio::read::fs::ZipFileReader, Compression};
use governor::{Quota, RateLimiter};
use http::StatusCode;
use mime::Mime;
use path_macro::path;
@ -15,21 +19,29 @@ use serde::Serialize;
use serde_hex::{SerHex, Strict};
use crate::{
artifact_api::{Artifact, ArtifactApi, ArtifactOrRun},
artifact_api::ArtifactApi,
config::Config,
error::{Error, Result},
query::Query,
util,
query::ArtifactQuery,
util::{self, IgnoreFileNotFound},
};
pub struct Cache {
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 files: HashMap<String, FileEntry>,
pub last_modified: Option<SystemTime>,
pub name: String,
pub last_modified: SystemTime,
}
#[derive(Clone)]
@ -41,12 +53,10 @@ pub struct FileEntry {
pub compression: Compression,
}
pub enum GetEntryResult {
Entry {
entry: Arc<CacheEntry>,
zip_path: PathBuf,
},
Artifacts(Vec<Artifact>),
pub struct GetEntryResult {
pub entry: Arc<CacheEntry>,
pub zip_path: PathBuf,
pub downloaded: bool,
}
pub enum GetFileResult {
@ -78,33 +88,40 @@ pub struct Listing {
pub struct ListingEntry {
pub name: String,
pub url: String,
pub size: u32,
pub size: Size,
pub crc32: String,
pub is_dir: bool,
}
pub struct Size(pub u32);
impl Cache {
pub fn new(cfg: Config) -> Self {
Self {
qc: QuickCache::new(cfg.load().mem_cache_size),
lim_download: cfg
.load()
.limit_artifacts_per_min
.map(|lim| RateLimiter::keyed(Quota::per_minute(lim))),
lim_gc: RateLimiter::direct(Quota::per_hour(NonZeroU32::MIN)),
cfg,
qc: QuickCache::new(50),
}
}
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() {
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?;
let artifact = match artifact {
ArtifactOrRun::Artifact(artifact) => artifact,
ArtifactOrRun::Run(_) => unreachable!(),
};
if let Some(limiter) = &self.lim_download {
limiter.check_key(ip)?;
}
api.download(&artifact, &zip_path).await?;
}
@ -112,36 +129,102 @@ impl Cache {
.cfg
.load()
.zip_timeout_ms
.map(|t| Duration::from_millis(t.into()));
.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(&hash, async {
Ok::<_, Error>(Arc::new(CacheEntry::new(&zip_path, timeout).await?))
.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 meta = tokio::fs::metadata(&zip_path).await?;
if meta.modified().ok() != entry.last_modified {
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).await?);
self.qc.insert(hash, entry.clone());
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 { 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))
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 {
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 zip_fut = ZipFileReader::new(&zip_path);
let zip = match timeout {
@ -149,6 +232,16 @@ impl CacheEntry {
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 {
files: zip
.file()
@ -167,7 +260,10 @@ impl CacheEntry {
))
})
.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 {
name: n.to_owned(),
url: format!("{n}{path}"),
size: 0,
size: Size(0),
crc32: "-".to_string(),
is_dir: true,
});
@ -287,7 +383,7 @@ impl CacheEntry {
files.push(ListingEntry {
name: n.to_owned(),
url: format!("{n}{path}"),
size: entry.uncompressed_size,
size: Size(entry.uncompressed_size),
crc32: hex::encode(entry.crc32.to_le_bytes()),
is_dir: false,
});
@ -297,9 +393,9 @@ impl CacheEntry {
// Sort by size
if col == b'S' {
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 {
files.sort_by_key(|f| f.size);
files.sort_by_key(|f| f.size.0);
}
}

View file

@ -1,4 +1,5 @@
use std::{
num::{NonZeroU32, NonZeroUsize},
path::{Path, PathBuf},
sync::Arc,
};
@ -20,14 +21,40 @@ struct ConfigInner {
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct ConfigData {
/// Folder where the downloaded artifacts are stored
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,
/// Set to true if the server is not available under HTTPS
pub no_https: bool,
pub max_artifact_size: Option<u32>,
pub max_file_size: Option<u32>,
pub max_age_h: Option<u16>,
pub zip_timeout_ms: Option<u32>,
/// Maximum artifact (ZIP) size to be downloaded
pub max_artifact_size: Option<NonZeroU32>,
/// Maximum file size to be served
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>,
/// 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 {
@ -36,11 +63,15 @@ impl Default for ConfigData {
cache_dir: Path::new("/tmp/artifactview").into(),
root_domain: "localhost:3000".to_string(),
no_https: false,
max_artifact_size: Some(100_000_000),
max_file_size: Some(100_000_000),
max_age_h: Some(12),
zip_timeout_ms: Some(1000),
max_artifact_size: Some(NonZeroU32::new(100_000_000).unwrap()),
max_file_size: Some(NonZeroU32::new(100_000_000).unwrap()),
max_file_count: Some(NonZeroUsize::new(10_000).unwrap()),
max_age_h: NonZeroU32::new(12).unwrap(),
zip_timeout_ms: Some(NonZeroU32::new(1000).unwrap()),
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),
#[error("method not allowed")]
MethodNotAllowed,
#[error("you are fetching new artifacts too fast, please wait a minute and try again")]
Ratelimit,
}
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 {
pub fn status(&self) -> StatusCode {
match self {
@ -67,6 +75,7 @@ impl Error {
Error::NotFound(_) | Error::Inaccessible | Error::Expired => StatusCode::NOT_FOUND,
Error::HttpClient(_, status) => *status,
Error::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
Error::Ratelimit => StatusCode::TOO_MANY_REQUESTS,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}

View file

@ -2,13 +2,21 @@ use std::{fmt::Write, hash::Hash};
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use siphasher::sip128::{Hasher128, SipHasher};
use url::Url;
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)]
pub struct Query {
pub struct QueryData<T> {
/// Forge host
pub host: String,
/// User/org name (case-insensitive)
@ -17,8 +25,8 @@ pub struct Query {
pub repo: String,
/// CI run id
pub run: u64,
/// Artifact id (unique for every run)
pub artifact: Option<u64>,
// Optional selected artifact
pub artifact: T,
}
static RE_REPO_NAME: Lazy<Regex> = Lazy::new(|| Regex::new("^[A-z0-9\\-_\\.]+$").unwrap());
@ -35,15 +43,26 @@ impl Query {
return Err(Error::InvalidUrl);
}
Ok(Self {
host: Self::decode_domain(segments[0], '.'),
user: Self::decode_domain(segments[1], '-'),
repo: Self::decode_domain(segments[2], '-'),
run: run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?,
artifact: match run_and_artifact.get(1) {
Some(x) => Some(x.parse().ok().ok_or(Error::InvalidUrl)?),
None => None,
},
let host = decode_domain(segments[0], '.');
let user = decode_domain(segments[1], '-');
let repo = decode_domain(segments[2], '-');
let run = run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?;
Ok(match run_and_artifact.get(1) {
Some(x) => Self::Artifact(QueryData {
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())
.ok_or(Error::BadRequest("no run ID".into()))?;
Ok(Self {
Ok(Self::Run(RunQuery {
host: host.to_owned(),
user: user.to_owned(),
repo: repo.to_owned(),
run,
artifact: None,
})
artifact: (),
}))
}
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 {
let mut res = format!(
"{}--{}--{}--{}",
Self::encode_domain(&self.host, '.'),
Self::encode_domain(&self.user, '-'),
Self::encode_domain(&self.repo, '-'),
encode_domain(&self.host, '.'),
encode_domain(&self.user, '-'),
encode_domain(&self.repo, '-'),
self.run,
);
if let Some(artifact) = artifact {
@ -120,13 +170,18 @@ impl Query {
self.host == "github.com"
}
pub fn siphash(&self) -> [u8; 16] {
let mut h = SipHasher::new();
self.hash(&mut h);
h.finish128().as_bytes()
pub fn into_runquery(self) -> RunQuery {
RunQuery {
host: self.host,
user: self.user,
repo: self.repo,
run: self.run,
artifact: (),
}
}
}
fn encode_domain(s: &str, bias: char) -> String {
fn encode_domain(s: &str, bias: char) -> String {
// 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 {
@ -169,9 +224,9 @@ impl Query {
buf2 += &buf[last_pos..];
buf2
}
}
fn decode_domain(s: &str, bias: char) -> String {
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());
@ -190,12 +245,11 @@ impl Query {
};
repl2.replace('\0', "-")
}
}
#[cfg(test)]
mod tests {
use super::Query;
use super::{ArtifactQuery, Query};
use proptest::prelude::*;
use rstest::rstest;
@ -206,7 +260,7 @@ mod tests {
#[case("_h--de.x-u", '.', "-2h-1-1de-x-1u")]
#[case("0-0", '-', "0-10")]
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]
@ -215,14 +269,14 @@ mod tests {
#[case("-2h-1-1de-x-1u", '.', "_h--de.x-u")]
#[case("0-10", '-', "0-0")]
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! {
#[test]
fn pt_encode_domain_roundtrip(s in "[a-z0-9\\-_\\.]+") {
let enc = Query::encode_domain(&s, '-');
let dec = Query::decode_domain(&enc, '-');
let enc = super::encode_domain(&s, '-');
let dec = super::decode_domain(&enc, '-');
assert_eq!(dec, s);
assert!(!enc.contains("--"), "got: `{s}` -> `{enc}`");
}
@ -234,27 +288,14 @@ mod tests {
let query = Query::from_subdomain(d1).unwrap();
assert_eq!(
query,
Query {
Query::Artifact(ArtifactQuery {
host: "github.com".to_owned(),
user: "thetadev".to_owned(),
repo: "newpipe-extractor".to_owned(),
run: 14,
artifact: Some(123),
}
artifact: 123
})
);
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};
#[derive(Default)]
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)]
#[template(path = "index")]
pub struct Index {
@ -27,9 +26,11 @@ pub struct Error<'a> {
#[template(path = "selection")]
pub struct Selection<'a> {
pub main_url: &'a str,
pub version: Version,
pub run_url: &'a str,
pub run_name: &'a str,
pub artifacts: Vec<LinkItem>,
pub publisher: LinkItem,
pub artifacts: Vec<ArtifactItem>,
}
#[derive(Template)]
@ -37,6 +38,7 @@ pub struct Selection<'a> {
pub struct Listing<'a> {
pub main_url: &'a str,
pub version: Version,
pub run_url: &'a str,
pub artifact_name: &'a str,
pub path_components: Vec<LinkItem>,
pub n_dirs: usize,
@ -50,11 +52,38 @@ pub struct LinkItem {
pub url: String,
}
impl LinkItem {
pub fn from_artifact(artifact: Artifact, query: &Query, cfg: &Config) -> Self {
pub struct ArtifactItem {
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 {
name: artifact.name,
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 axum::{extract::Request, http::HeaderMap};
use axum::{
extract::{ConnectInfo, Request},
http::HeaderMap,
};
use headers::{Header, HeaderMapExt};
use http::header;
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('.'))
}
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)]
mod tests {
use http::{header, HeaderMap};

View file

@ -1,40 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
background-color: #000;
color: #ddd;
text-align: center;
font-family: monospace;
font-size: 16px;
width: 100%;
margin: 0;
display: flex;
justify-content: center;
}
.card {
display: flex;
flex-direction: column;
width: 90%;
max-width: 500px;
align-items: center;
}
.dark {
color: #aaa;
}
* { padding: 0; margin: 0; --color-secondary: #dedede; --color-text: #000;
--color-text-light: #888; --color-border: #ccc; } body { font-family: sans-serif;
text-rendering: optimizespeed; background-color: #f5f5f5; color:
var(--color-text); } a { color: #006ed3; text-decoration: none; } a:hover { color:
#319cff; } .card { display: flex; flex-direction: column; width: 90%; max-width:
500px; align-items: center; } .input-row { display: flex; width: 100%; } .center {
width: 100%; display: flex; flex-direction: row; justify-content: center; } .light
{ color: var(--color-text-light); } p { margin: 16px 0; } header { gap: 1em;
padding-top: 10px; padding-bottom: 10px; background-color: #f2f2f2; } footer {
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 {
background-color: #151515; } }
</style>
<title>Artifactview</title>
</head>
<body>
<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="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">
<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>
<h1><span class="dark">Error</span> {{status}}</h1>
<h1><span class="light">Error</span> {{status}}</h1>
<p>{{msg}}</p>
</div>
</div>
</body>
</html>

View file

@ -1,98 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
background-color: #000;
color: #ddd;
text-align: center;
font-family: monospace;
font-size: 16px;
width: 100%;
margin: 0;
display: flex;
justify-content: center;
}
input {
background-color: transparent;
color: inherit;
font-size: 16px;
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;
}
* { padding: 0; margin: 0; --color-secondary: #dedede; --color-text: #000;
--color-text-light: #888; --color-border: #ccc; } body { font-family: sans-serif;
text-rendering: optimizespeed; background-color: #f5f5f5; color:
var(--color-text);} a { color: #006ed3; text-decoration: none; } a:hover { color:
#319cff; } .card { display: flex; flex-direction: column; width: 90%; max-width:
500px; align-items: center; } .input-row { display: flex; width: 100%; } .center {
width: 100%; display: flex; flex-direction: row; justify-content: center; } .light
{ color: var(--color-text-light); } input { color: inherit; font-size: 16px;
height: 32px; border: 1px solid var(--color-border); padding: 4px 8px; } button {
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 {
padding: 40px 20px; font-size: 12px; text-align: center; } @media
(prefers-color-scheme: dark) { * { --color-text: #dddddd; --color-secondary:
#082437; --color-border: #212121; } body { background-color: #101010; } input
{background-color: #151515;} header { background-color: #151515; }}
</style>
<title>Artifactview</title>
</head>
<body>
<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="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">
<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>
<p>Enter a GitHub/Gitea/Forgejo Actions run url to browse CI artifacts</p>
<form method="POST" class="flex-row">
<form method="POST" class="input-row">
<input
name="url"
type="text"
placeholder="codeberg.org/username/repo/actions/runs/42"
style="flex-grow: 1"
/>
<button type="submit">[&gt;]</button>
<button type="submit">Browse</button>
</form>
<p class="footer">
Artifactview {{version}}
</div>
</div>
<footer>
<a
href="https://code.thetadev.de/ThetaDev/artifactview"
target="_blank"
rel="noopener noreferrer"
>
[Source code]
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>
</div>
</footer>
</body>
</html>

View file

@ -1,55 +1,137 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<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;}}
</style>
<title>
Index of {{artifact_name}}
</title>
</head>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<style type="text/css">
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
#000;--color-text-light: #888;}body {font-family: sans-serif;text-rendering:
optimizespeed;background-color: #f5f5f5;color: var(--color-text);}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: 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()">
<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>
<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>
<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="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>
</a>
<h1>
<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: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> directories</span>
<span class="meta-item"><b>{{n_files}}</b> files</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>
{{#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">
</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>
@ -58,25 +140,35 @@ Index of {{artifact_name}}
</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>
</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}}
</footer>
<footer>
Served with
<a
href="https://code.thetadev.de/ThetaDev/artifactview"
target="_blank"
rel="noopener noreferrer"
>Artifactview</a>
{{version}}
</footer>
<script>
<script>
// @license
magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT var
filterEl=document.getElementById("filter");function
initFilter(){if(!filterEl.value){var filterParam=new
URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function
filter(){var q=filterEl.value.trim().toLowerCase();var
elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var
nameEl=el.querySelector("td");var
nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
// @license-end
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
// @license-end
</script>
</body>
</script>
</body>
</html>

View file

@ -1,49 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
background-color: #000;
color: #ddd;
text-align: center;
font-family: monospace;
font-size: 16px;
width: 100%;
margin: 0;
display: flex;
justify-content: center;
}
a {
color: inherit;
text-decoration: underline;
}
a:active {
color: #00bfff;
}
.card {
display: flex;
flex-direction: column;
width: 90%;
max-width: 500px;
align-items: center;
}
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<style type="text/css">
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
#000;--color-text-light: #888;}body {font-family: sans-serif;text-rendering:
optimizespeed;background-color: #f5f5f5;color: var(--color-text);}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: 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;}p { margin: 16px 0; }.light{ color:
var(--color-text-light); } @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;}}.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>
<title>Artifactview</title>
<title>
Artifacts:
{{run_name}}
</title>
</head>
<body>
<div class="card">
<a href="{{main_url}}" aria-label="Back to main page" style="height: 50px;">
<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>
<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></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>
<p>CI artifacts for <a href="{{run_url}}" target="_blank" rel="noopener noreferrer">{{run_name}}</a>:</p>
{{#each artifacts}}
<a href="{{this.url}}">{{this.name}}</a>
{{/each}}
<h1>
<a href="/">{{run_name}}</a>
/
</h1>
</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>
</html>