diff --git a/CHANGELOG.md b/CHANGELOG.md index 149b09e..dad4e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. +## [v0.3.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.2.0..v0.3.0) - 2024-06-18 + +### 🚀 Features + +- Add userscript - ([299f54f](https://codeberg.org/ThetaDev/artifactview/commit/299f54fd58b567884f76e1c36c7c1648d43fbc75)) + +### 🐛 Bug Fixes + +- Make icon visible on light background - ([7021980](https://codeberg.org/ThetaDev/artifactview/commit/70219805d0029bb5b1e0bd08f77f444949d5b9f6)) +- Redirect user to directory path when requesting index page - ([a8e173c](https://codeberg.org/ThetaDev/artifactview/commit/a8e173c8a921ab29f4e70b7abd5167fb87c6f609)) + + ## [v0.2.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.1.0..v0.2.0) - 2024-06-14 ### 🚀 Features diff --git a/Cargo.lock b/Cargo.lock index b6e4361..3e8e2e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ dependencies = [ [[package]] name = "artifactview" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async_zip", "axum", @@ -438,6 +438,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "buf-min" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d5698cf6842742ed64805705798f8b351fff53fa546fd45c52184bee58dc90" + [[package]] name = "bumpalo" version = "3.16.0" @@ -502,7 +508,9 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-targets 0.52.5", ] @@ -3160,6 +3168,18 @@ name = "v_htmlescape" version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" +dependencies = [ + "buf-min", +] + +[[package]] +name = "v_jsonescape" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8219cc464ba10c48c3231a6871f11d26d831c5c45a47467eea387ea7bb10e8" +dependencies = [ + "buf-min", +] [[package]] name = "valuable" @@ -3539,6 +3559,7 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfce1df93f3b16e5272221a559e60bbbaaa71dbc042a43996d223e51a690aab2" dependencies = [ + "buf-min", "yarte_derive", "yarte_helpers", ] @@ -3565,6 +3586,7 @@ dependencies = [ "proc-macro2", "quote", "syn 1.0.109", + "v_jsonescape", "yarte_codegen", "yarte_helpers", "yarte_hir", @@ -3577,13 +3599,18 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0d1076f8cee9541ea5ffbecd9102f751252c91f085e7d30a18a3ce805ebd3ee" dependencies = [ + "buf-min", + "chrono", "dtoa", "itoa", "prettyplease", + "ryu", "serde", + "serde_json", "syn 1.0.109", "toml", "v_htmlescape", + "v_jsonescape", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2ccdfe7..c8bc2e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "artifactview" -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = ["ThetaDev "] license = "MIT" @@ -72,7 +72,7 @@ tower-http = { version = "0.5.2", features = ["trace", "set-header"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" url = "2.5.0" -yarte = "0.15.7" +yarte = { version = "0.15.7", features = ["json"] } [build-dependencies] yarte_helpers = "0.15.8" diff --git a/Justfile b/Justfile index f4ecb96..29c63ca 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,5 @@ test: - cargo test + cargo nextest run --no-fail-fast compress-res: cd resources && zopfli *.css diff --git a/README.md b/README.md index e94622e..0a610f8 100644 --- a/README.md +++ b/README.md @@ -70,26 +70,27 @@ networks: Artifactview is configured using environment variables. -| Variable | Default | Description | -| ------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | 3000 | HTTP port | -| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts | -| `ROOT_DOMAIN` | localhost:3000 | Public hostname+port number under which artifactview is accessible. If this is configured incorrectly, artifactview will show the error message "host does not end with configured ROOT_DOMAIN" | -| `RUST_LOG` | info | Logging level | -| `NO_HTTPS` | false | Set to True if the website is served without HTTPS (used if testing artifactview without an ) | -| `MAX_ARTIFACT_SIZE` | 100000000 (100 MB) | Maximum size of the artifact zip file to be downloaded | -| `MAX_FILE_SIZE` | 100000000 (100 MB) | Maximum contained file size to be served | -| `MAX_FILE_COUNT` | 10000 | Maximum amount of files within a zip file | -| `MAX_AGE_H` | 12 | Maximum age in hours after which cached artifacts are deleted | -| `ZIP_TIMEOUT_MS` | 1000 | Maximum time in milliseconds for reading the index of a zip file. If this takes too long, the zip file is most likely excessively large or malicious (zip bomb) | -| `GITHUB_TOKEN` | - | GitHub API token for downloading artifacts. Using a fine-grained token with public read permissions is recommended | -| `MEM_CACHE_SIZE` | 50 | Artifactview keeps artifact metadata as well as the zip file indexes in memory to improve performance. The amount of cached items is adjustable. | -| `REAL_IP_HEADER` | - | 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`. | -| `LIMIT_ARTIFACTS_PER_MIN` | 5 | Limit the amount of downloaded artifacts per IP address and minute | -| `REPO_BLACKLIST` | - | List of sites/users/repos that can NOT be accessed. The blacklist takes precedence over the whitelist (repos included in both lists cannot be accessed)
Example: `github.com/evil-corp/world-destruction;codeberg.org/blackhat;example.org` | -| `REPO_WHITELIST` | - | List of sites/users/repos that can ONLY be accessed. If the whitelist is empty, it will be ignored and any repository can be accessed. Uses the same syntax as `REPO_BLACLIST`. | -| `SITE_ALIASES` | - | Aliases for sites to make URLs shorter
Example: `gh => github.com;cb => codeberg.org` | -| `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer | +| Variable | Default | Description | +| ------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | 3000 | HTTP port | +| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts | +| `ROOT_DOMAIN` | localhost:3000 | Public hostname+port number under which artifactview is accessible. If this is configured incorrectly, artifactview will show the error message "host does not end with configured ROOT_DOMAIN" | +| `RUST_LOG` | info | Logging level | +| `NO_HTTPS` | false | Set to True if the website is served without HTTPS (used if testing artifactview without an ) | +| `MAX_ARTIFACT_SIZE` | 100000000 (100 MB) | Maximum size of the artifact zip file to be downloaded | +| `MAX_FILE_SIZE` | 100000000 (100 MB) | Maximum contained file size to be served | +| `MAX_FILE_COUNT` | 10000 | Maximum amount of files within a zip file | +| `MAX_AGE_H` | 12 | Maximum age in hours after which cached artifacts are deleted | +| `ZIP_TIMEOUT_MS` | 1000 | Maximum time in milliseconds for reading the index of a zip file. If this takes too long, the zip file is most likely excessively large or malicious (zip bomb) | +| `GITHUB_TOKEN` | - | GitHub API token for downloading artifacts. Using a fine-grained token with public read permissions is recommended | +| `MEM_CACHE_SIZE` | 50 | Artifactview keeps artifact metadata as well as the zip file indexes in memory to improve performance. The amount of cached items is adjustable. | +| `REAL_IP_HEADER` | - | 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`. | +| `LIMIT_ARTIFACTS_PER_MIN` | 5 | Limit the amount of downloaded artifacts per IP address and minute | +| `REPO_BLACKLIST` | - | List of sites/users/repos that can NOT be accessed. The blacklist takes precedence over the whitelist (repos included in both lists cannot be accessed)
Example: `github.com/evil-corp/world-destruction;codeberg.org/blackhat;example.org` | +| `REPO_WHITELIST` | - | List of sites/users/repos that can ONLY be accessed. If the whitelist is empty, it will be ignored and any repository can be accessed. Uses the same syntax as `REPO_BLACKLIST`. | +| `SITE_ALIASES` | - | Aliases for sites to make URLs shorter
Example: `gh => github.com;cb => codeberg.org` | +| `SUGGESTED_SITES` | codeberg.org; github.com; gitea.com | List of suggested code forges (host only, without https://, separated by `;`). If repo_whitelist is empty, this value is used for the matched sites in the userscript. The first value is used in the placeholder URL on the home page. | +| `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer | ## Technical details diff --git a/resources/favicon.ico b/resources/favicon.ico index 8525003..6cff4b9 100644 Binary files a/resources/favicon.ico and b/resources/favicon.ico differ diff --git a/resources/favicon.xcf b/resources/favicon.xcf index dbb418a..aa00984 100644 Binary files a/resources/favicon.xcf and b/resources/favicon.xcf differ diff --git a/resources/style.css b/resources/style.css index 1516292..be5c3d0 100644 --- a/resources/style.css +++ b/resources/style.css @@ -172,7 +172,6 @@ p { width: 100%; } .center { - width: 100%; display: flex; flex-direction: row; justify-content: center; diff --git a/resources/style.css.gz b/resources/style.css.gz index 2efabdc..0d2e1f3 100644 Binary files a/resources/style.css.gz and b/resources/style.css.gz differ diff --git a/src/app.rs b/src/app.rs index 0e84191..8367c5a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,6 @@ -use std::{net::SocketAddr, ops::Bound, path::Path, str::FromStr, sync::Arc}; +use std::{ + collections::BTreeMap, net::SocketAddr, ops::Bound, path::Path, str::FromStr, sync::Arc, +}; use async_zip::tokio::read::ZipEntryReader; use axum::{ @@ -176,68 +178,73 @@ impl App { } match entry.get_file(&path, uri.query().unwrap_or_default()) { - Ok(GetFileResult::File(res)) => { - let qparams = uri - .query() - .and_then(|q| serde_urlencoded::from_str::(q).ok()) - .unwrap_or_default(); - if res.filename.is_some() { - if let Some(viewer) = qparams.viewer { - match Self::try_view_file( - &state, - &entry, - &entry_res.zip_path, - &query, - &res, - &viewer, - &path, - ) - .await - { - Ok(resp) => return Ok(resp), - Err(e) => { - tracing::error!("{e}") + Ok(gfr) => { + if gfr.index() && !path.ends_with('/') { + return Ok(Redirect::permanent(&format!("{path}/")).into_response()); + } + + match gfr { + GetFileResult::File(res) => { + let qparams = uri + .query() + .and_then(|q| serde_urlencoded::from_str::(q).ok()) + .unwrap_or_default(); + if res.filename.is_some() { + if let Some(viewer) = qparams.viewer { + match Self::try_view_file( + &state, + &entry, + &entry_res.zip_path, + &query, + &res, + &viewer, + &path, + ) + .await + { + Ok(resp) => return Ok(resp), + Err(e) => { + tracing::error!("{e}") + } + } } } + Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs) + .await + } + GetFileResult::Listing(listing) => { + let run_url = query.forge_url(); + let tmpl = templates::Listing { + main_url: state.i.cfg.main_url(), + run_url: &run_url, + artifact_name: &entry.name, + path_components: path_components( + &query, + state.i.cfg.main_url(), + &run_url, + &entry.name, + &path, + ), + n_dirs: listing.n_dirs, + n_files: listing.n_files, + has_parent: listing.has_parent, + publisher: query.publisher(), + viewer_max_size: state + .i + .cfg + .load() + .viewer_max_size + .map(u32::from) + .unwrap_or(u32::MAX), + entries: listing.entries, + }; + + Ok(Response::builder() + .typed_header(headers::ContentType::html()) + .cache() + .body(tmpl.to_string().into())?) } } - Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs).await - } - Ok(GetFileResult::Listing(listing)) => { - if !path.ends_with('/') { - return Ok(Redirect::to(&format!("{path}/")).into_response()); - } - - let run_url = query.forge_url(); - let tmpl = templates::Listing { - main_url: state.i.cfg.main_url(), - run_url: &run_url, - artifact_name: &entry.name, - path_components: path_components( - &query, - state.i.cfg.main_url(), - &run_url, - &entry.name, - &path, - ), - n_dirs: listing.n_dirs, - n_files: listing.n_files, - has_parent: listing.has_parent, - publisher: query.publisher(), - viewer_max_size: state - .i - .cfg - .load() - .viewer_max_size - .map(u32::from) - .unwrap_or(u32::MAX), - entries: listing.entries, - }; - - Ok(Response::builder() - .typed_header(headers::ContentType::html()) - .cache() - .body(tmpl.to_string().into())?) } Err(Error::NotFound(e)) => { if path == FAVICON_PATH { @@ -256,17 +263,62 @@ impl App { uri: Uri, hdrs: &HeaderMap, ) -> Result, Error> { - if uri.path() == FAVICON_PATH { - return Self::favicon(); - } - if uri.path() == STYLE_MAIN_PATH { - return Self::stylesheet(hdrs, STYLE_MAIN_BYTES, STYLE_MAIN_BYTES_GZ); - } - if uri.path() == STYLE_CONTENT_PATH { - return Self::stylesheet(hdrs, STYLE_CONTENT_BYTES, STYLE_CONTENT_BYTES_GZ); - } if uri.path() != "/" { - return Err(Error::NotFound("path".into())); + match uri.path() { + FAVICON_PATH => return Self::favicon(), + STYLE_MAIN_PATH => { + return Self::stylesheet(hdrs, STYLE_MAIN_BYTES, STYLE_MAIN_BYTES_GZ) + } + STYLE_CONTENT_PATH => { + return Self::stylesheet(hdrs, STYLE_CONTENT_BYTES, STYLE_CONTENT_BYTES_GZ) + } + "/artifactview.user.js" => { + let cfg = state.i.cfg.load(); + + let map_host = |h: &str| { + // Since GitHub uses Javascript for page changes, the userscript needs to be always loaded + if h == "github.com" { + "https://github.com/*".to_owned() + } else { + format!("https://{h}/*/runs/*") + } + }; + + let forge_urls = if cfg.repo_whitelist.is_empty() { + cfg.suggested_sites + .iter() + .map(|itm| map_host(itm)) + .collect::>() + } else { + cfg.repo_whitelist + .hosts() + .iter() + .map(|h| map_host(h)) + .collect::>() + }; + let aliases = cfg + .site_aliases + .iter() + .map(|(k, v)| (v.as_str(), k.as_str())) + .collect::>(); + + let tmpl = templates::Userscript { + main_url: state.i.cfg.main_url(), + root_domain: &cfg.root_domain, + no_https: cfg.no_https, + forge_urls, + aliases: &aliases, + }; + + return Ok(Response::builder() + .typed_header(headers::ContentType::from( + mime::APPLICATION_JAVASCRIPT_UTF_8, + )) + .cache() + .body(tmpl.to_string().into())?); + } + _ => return Err(Error::NotFound("path".into())), + } } #[derive(Deserialize)] @@ -318,6 +370,7 @@ impl App { .body( templates::Index { main_url: state.i.cfg.main_url(), + example_site: state.i.cfg.example_site(), } .to_string() .into(), diff --git a/src/cache.rs b/src/cache.rs index a2cb665..72611f9 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -72,6 +72,7 @@ pub struct GetFileResultFile { pub file: FileEntry, pub mime: Option, pub status: StatusCode, + pub index: bool, } #[derive(Serialize)] @@ -105,6 +106,17 @@ pub struct Size(pub u32); #[derive(Serialize)] pub struct Crc32(#[serde(with = "SerHexOpt::")] pub Option); +impl GetFileResult { + /// Return true if the result represents a directory index, so the client has to be redirected to the directory path + /// if the requested path does not end with a slash (otherwise resources on the index.html may not resolve properly) + pub fn index(&self) -> bool { + match self { + GetFileResult::File(f) => f.index, + GetFileResult::Listing(_) => true, + } + } +} + impl Cache { pub fn new(cfg: Config) -> Self { Self { @@ -298,21 +310,31 @@ impl CacheEntry { file: file.clone(), mime: util::path_mime(path), status: StatusCode::OK, + index: false, })); } else if util::site_path_ext(path).is_none() { index_path = Some(format!("{path}/index.html").into()); } - if let Some(file) = index_path - .and_then(|p: Cow| self.files.get(p.as_ref())) - .or_else(|| self.files.get("200.html")) - { - // index.html or SPA entrypoint + if let Some(file) = index_path.and_then(|p: Cow| self.files.get(p.as_ref())) { + // index.html return Ok(GetFileResult::File(GetFileResultFile { filename: None, file: file.clone(), mime: Some(mime::TEXT_HTML), status: StatusCode::OK, + index: true, + })); + } + + // SPA entrypoint + if let Some(file) = self.files.get("200.html") { + return Ok(GetFileResult::File(GetFileResultFile { + filename: None, + file: file.clone(), + mime: Some(mime::TEXT_HTML), + status: StatusCode::OK, + index: false, })); } @@ -348,6 +370,7 @@ impl CacheEntry { file: file.clone(), mime: Some(mime::TEXT_HTML), status: StatusCode::NOT_FOUND, + index: false, })); } diff --git a/src/config.rs b/src/config.rs index c3a6256..0c524f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,11 @@ pub struct ConfigData { pub repo_blacklist: QueryFilterList, /// List of sites/users/repos that can ONLY be accessed pub repo_whitelist: QueryFilterList, + /// List of suggested code forges (host only, without https://) + /// + /// If repo_whitelist is empty, this value is used for the matched sites in the userscript + /// as well as the url placeholder on the home page + pub suggested_sites: Vec, /// Aliases for sites (Example: `gh => github.com`) pub site_aliases: HashMap, /// Maximum file size for the viewer @@ -89,6 +94,11 @@ impl Default for ConfigData { limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()), repo_blacklist: QueryFilterList::default(), repo_whitelist: QueryFilterList::default(), + suggested_sites: vec![ + String::from("codeberg.org"), + String::from("github.com"), + String::from("gitea.com"), + ], site_aliases: HashMap::new(), viewer_max_size: Some(NonZeroU32::new(500_000).unwrap()), } @@ -154,6 +164,15 @@ impl Config { &self.i.main_url } + pub fn example_site(&self) -> &str { + self.i + .data + .repo_whitelist + .first_host() + .or_else(|| self.i.data.suggested_sites.first().map(|s| s.as_str())) + .unwrap_or("codeberg.org") + } + pub fn check_filterlist(&self, query: &ArtifactQuery) -> Result<()> { if !self.i.data.repo_blacklist.passes(query, true) { Err(Error::Forbidden("repository is blacklisted".into())) diff --git a/src/query.rs b/src/query.rs index d810b1c..ddf206c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, str::FromStr}; +use std::{ + collections::{BTreeSet, HashMap}, + str::FromStr, +}; use once_cell::sync::Lazy; use regex::{Captures, Regex}; @@ -244,11 +247,11 @@ impl From for RunQuery { 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 { - if pos == 0 || pos >= (s.len() - 1) { + let is_mid_single = |str: &str, pos: usize| -> bool { + if pos == 0 || pos >= (str.len() - 1) { return false; } - let next_char = s[pos..].chars().nth(1).unwrap(); + let next_char = str[pos..].chars().nth(1).unwrap(); !('0'..='2').contains(&next_char) && !matches!(next_char, '-' | '.' | '_') }; @@ -257,7 +260,7 @@ fn encode_domain(s: &str, bias: char) -> String { let mut last_pos = 0; for (pos, c) in s.match_indices('-') { buf += &s[last_pos..pos]; - if bias == '-' && is_mid_single(pos) { + if bias == '-' && is_mid_single(s, pos) { buf.push('-'); } else { buf += "-1"; @@ -272,7 +275,7 @@ fn encode_domain(s: &str, bias: char) -> String { for (pos, c) in buf.match_indices(['.', '_']) { buf2 += &buf[last_pos..pos]; let cchar = c.chars().next().unwrap(); - if cchar == bias && is_mid_single(pos) { + if cchar == bias && is_mid_single(&buf, pos) { buf2.push('-'); } else if cchar == '.' { buf2 += "-0" @@ -374,6 +377,18 @@ impl QueryFilterList { self.0.iter().any(|itm| itm.passes(query)) ^ blacklist } } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn first_host(&self) -> Option<&str> { + self.0.first().map(|f| f.host.as_str()) + } + + pub fn hosts(&self) -> BTreeSet<&str> { + self.0.iter().map(|f| f.host.as_str()).collect() + } } impl<'de> Deserialize<'de> for QueryFilterList { diff --git a/src/templates.rs b/src/templates.rs index ec90fe3..7af5f0e 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use crate::{ artifact_api::Artifact, cache::{Crc32, ListingEntry, Size}, @@ -11,6 +13,7 @@ use yarte::{Render, Template}; #[template(path = "index")] pub struct Index<'a> { pub main_url: &'a str, + pub example_site: &'a str, } #[derive(Template)] @@ -65,6 +68,16 @@ pub struct Junit { pub suites: TestSuites, } +#[derive(Template)] +#[template(path = "userscript")] +pub struct Userscript<'a> { + pub main_url: &'a str, + pub root_domain: &'a str, + pub no_https: bool, + pub forge_urls: Vec, + pub aliases: &'a BTreeMap<&'a str, &'a str>, +} + pub struct ViewerLink { pub id: &'static str, pub name: &'static str, diff --git a/templates/index.hbs b/templates/index.hbs index fcbe54e..ba12e5b 100644 --- a/templates/index.hbs +++ b/templates/index.hbs @@ -24,7 +24,7 @@ name="url" type="text" required - placeholder="codeberg.org/username/repo/actions/runs/42" + placeholder="{{example_site}}/user/repo/actions/runs/42" style="flex-grow: 1" /> @@ -40,6 +40,10 @@ Artifactview {{~crate::app::VERSION}} +

+ Install the Artifactview userscript for Greasemonkey/Violentmonkey + to add a "View artifact" button to your code forge. +

Disclaimer: Artifactview does not host any websites, the data is fetched from the respective diff --git a/templates/userscript.hbs b/templates/userscript.hbs new file mode 100644 index 0000000..3f1a15d --- /dev/null +++ b/templates/userscript.hbs @@ -0,0 +1,108 @@ +// ==UserScript== +// @name Artifactview +// @version 0.1.0 +// @description Adds a "View artifact" button to GitHub/Gitea/Forgejo CI artifacts +// @author ThetaDev +// @icon {{{main_url}}}/favicon.ico +// @homepageURL {{{main_url}}} +// @run-at document-idle +// @grant none +// @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163 +{{~#each forge_urls}} +// @match {{{this}}} +{{~/each}} +// ==/UserScript== + +// Copyright (C) 2024 ThetaDev, MIT License + +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +const AV_HOST = "{{{root_domain}}}"; +const NO_HTTPS = {{{no_https}}}; +const ALIASES = {{ @json_pretty aliases }}; + +function encodeDomain(s, bias) { + // 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 + const isMidSingle = (str, pos) => { + if (pos === 0) return false; + const nc = str[pos + 1]; + return nc && !nc.match(/^[0-2\-\._]$/) + }; + + // Escape dashes + let buf = ""; + let last_pos = 0; + while (true) { + let pos = s.indexOf("-", last_pos); + if (pos < 0) break; + buf += s.substring(last_pos, pos); + if (bias === "-" && isMidSingle(s, pos)) { + buf += "-"; + } else { + buf += "-1"; + } + last_pos = pos + 1; + } + buf += s.substring(last_pos); + + // Replace special chars [._] + let buf2 = ""; + last_pos = 0; + for (let i = 0; i < buf.length; i++) { + if (buf[i] === "." || buf[i] === "_") { + if (buf[i] === bias && isMidSingle(buf, i)) { + buf2 += "-"; + } else if (buf[i] == ".") { + buf2 += "-0"; + } else { + buf2 += "-2"; + } + } else { + buf2 += buf[i]; + } + } + return buf2; +} + +function queryURL(host, user, repo, run, artifact) { + const h = ALIASES[host] ?? encodeDomain(host, "."); + return `http${NO_HTTPS ? "" : "s"}://${h}--${encodeDomain(user, "-")}--${encodeDomain(repo, "-")}--${run}-${artifact}.${AV_HOST}`; +} + +const url = new URL(window.location.href); +const m = url.pathname.match(/^\/([\w\-\.]+)\/([\w\-\.]+)\/actions\/runs\/(\d+)/); +const ICON = ``; + +if (m) { + if (url.host === "github.com") { + const init = () => document.querySelectorAll(`a[data-test-selector="download-artifact-button"]:not([data-has-view-button="true"])`).forEach((elm) => { + const artifact = elm.getAttribute("href").match(/\d+$/)[0]; + elm.insertAdjacentHTML("beforebegin", `${ICON}`); + elm.setAttribute("data-has-view-button", "true"); + }); + document.addEventListener("ghmo:container", init); + init(); + } else { + const rav = document.getElementById("repo-action-view"); + new MutationObserver((changes, observer) => { + if (changes.find((c) => Array.from(c.addedNodes).find((n) => n.className === "job-artifacts"))) { + document.querySelectorAll(".job-artifacts-item").forEach((elm, i) => { + const delBtn = elm.querySelector(".job-artifacts-delete"); + if (delBtn) { + const wrapper = document.createElement("div"); + wrapper.classList.add("tw-flex", "tw-gap-4", "tw-justify-end"); + wrapper.innerHTML = `${ICON}`; + elm.insertAdjacentElement("beforeend", wrapper); + wrapper.appendChild(delBtn); + } + }); + observer.disconnect(); + } + }).observe(rav, { childList: true, subtree: true }); + } +} diff --git a/tests/tests.rs b/tests/tests.rs index cdf21e9..bc305c0 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -430,3 +430,25 @@ async fn compressed(server: TestAv) { let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); assert_eq!(buf, expect); } + +#[rstest] +#[tokio::test] +async fn userscript(server: TestAv) { + let resp = server.get("", "/artifactview.user.js").await; + resp.assert_status_ok(); + let script = resp.text(); + assert!(script.starts_with("// ==UserScript==\n")); +} + +#[rstest] +#[tokio::test] +/// Redirect user to the directory path if index.html or directory listing is served +async fn index_redirect(server: TestAv) { + let resp = server.get(S1, "/sites").await; + resp.assert_status(StatusCode::PERMANENT_REDIRECT); + assert_eq!(resp.header(header::LOCATION), "/sites/"); + + let resp = server.get(S1, "/junit").await; + resp.assert_status(StatusCode::PERMANENT_REDIRECT); + assert_eq!(resp.header(header::LOCATION), "/junit/"); +}