Compare commits
11 commits
a604e4472d
...
e9e708b833
Author | SHA1 | Date | |
---|---|---|---|
e9e708b833 | |||
16abfeff00 | |||
84bf3bdbdc | |||
a405f0733c | |||
c2f644d2ac | |||
09144712bf | |||
901e1a979c | |||
068c5adcf8 | |||
d96480ab12 | |||
d267c824a4 | |||
6e4a76586a |
16 changed files with 1209 additions and 581 deletions
|
@ -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
167
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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 |
115
src/app.rs
115
src/app.rs
|
@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
190
src/cache.rs
190
src/cache.rs
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
133
src/query.rs
133
src/query.rs
|
@ -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,10 +170,15 @@ 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 {
|
||||
|
@ -191,11 +246,10 @@ 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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
47
src/util.rs
47
src/util.rs
|
@ -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};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">[>]</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>
|
||||
|
|
|
@ -1,47 +1,129 @@
|
|||
<!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;}}
|
||||
* {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 of {{artifact_name}}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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&O=A">Name</a> <a href="?C=N&O=D"> ↓ </a></th>
|
||||
<th><a href="?C=S&O=A">Size</a> <a href="?C=S&O=D"> ↓ </a></th>
|
||||
<th><a href="?C=N&O=A">Name</a> <a
|
||||
href="?C=N&O=D"
|
||||
> ↓ </a></th>
|
||||
<th><a href="?C=S&O=A">Size</a> <a
|
||||
href="?C=S&O=D"
|
||||
> ↓ </a></th>
|
||||
<th>CRC32</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{{#if has_parent}}
|
||||
<tbody>
|
||||
{{#if has_parent}}
|
||||
<tr>
|
||||
<td><a href=".."><span class="goup">Parent directory</span></a></td>
|
||||
<td>—</td>
|
||||
|
@ -66,15 +148,25 @@ Index of {{artifact_name}}
|
|||
</main>
|
||||
|
||||
<footer>
|
||||
Served with <a href="https://code.thetadev.de/ThetaDev/artifactview" target="_blank" rel="noopener noreferrer">Artifactview</a> {{version}}
|
||||
Served with
|
||||
<a
|
||||
href="https://code.thetadev.de/ThetaDev/artifactview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Artifactview</a>
|
||||
{{version}}
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
|
||||
|
||||
// @license
|
||||
magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT var
|
||||
filterEl=document.getElementById("filter");function
|
||||
initFilter(){if(!filterEl.value){var filterParam=new
|
||||
URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function
|
||||
filter(){var q=filterEl.value.trim().toLowerCase();var
|
||||
elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var
|
||||
nameEl=el.querySelector("td");var
|
||||
nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
|
||||
// @license-end
|
||||
|
||||
</script>
|
||||
|
|
|
@ -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}}
|
||||
—
|
||||
{{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>
|
||||
|
|
Loading…
Reference in a new issue