Compare commits

...

4 commits

17 changed files with 402 additions and 106 deletions

View file

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

29
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package]
name = "artifactview"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
authors = ["ThetaDev <thetadev@magenta.de>"]
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"

View file

@ -1,5 +1,5 @@
test:
cargo test
cargo nextest run --no-fail-fast
compress-res:
cd resources && zopfli *.css

View file

@ -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<br />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.<br />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)<br />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<br />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<br />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.<br />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)<br />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<br />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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

View file

@ -172,7 +172,6 @@ p {
width: 100%;
}
.center {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;

Binary file not shown.

View file

@ -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::<FileQparams>(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::<FileQparams>(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<Response<Body>, 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::<Vec<_>>()
} else {
cfg.repo_whitelist
.hosts()
.iter()
.map(|h| map_host(h))
.collect::<Vec<_>>()
};
let aliases = cfg
.site_aliases
.iter()
.map(|(k, v)| (v.as_str(), k.as_str()))
.collect::<BTreeMap<_, _>>();
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(),

View file

@ -72,6 +72,7 @@ pub struct GetFileResultFile {
pub file: FileEntry,
pub mime: Option<Mime>,
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::<serde_hex::Strict>")] pub Option<u32>);
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<str>| 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<str>| 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,
}));
}

View file

@ -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<String>,
/// Aliases for sites (Example: `gh => github.com`)
pub site_aliases: HashMap<String, String>,
/// 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()))

View file

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

View file

@ -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<String>,
pub aliases: &'a BTreeMap<&'a str, &'a str>,
}
pub struct ViewerLink {
pub id: &'static str,
pub name: &'static str,

View file

@ -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"
/>
<button class="btn" type="submit">Browse</button>
@ -40,6 +40,10 @@
Artifactview
</a>
{{~crate::app::VERSION}}
<p class="light">
Install the <a href="/artifactview.user.js">Artifactview userscript</a> for <a href="https://addons.mozilla.org/de/firefox/addon/greasemonkey/" target="_blank" rel="noopener noreferrer">Greasemonkey</a>/<a href="https://violentmonkey.github.io/" target="_blank" rel="noopener noreferrer">Violentmonkey</a>
to add a "View artifact" button to your code forge.
</p>
<p class="light">
<b>Disclaimer:</b>
Artifactview does not host any websites, the data is fetched from the respective

108
templates/userscript.hbs Normal file
View file

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" class="svg ui text black job-artifacts-icon octicon Button-visual"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"></path></svg>`;
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", `<a href="${queryURL(url.host, m[1], m[2], m[3], artifact)}" title="View artifact" target="_blank" rel="noopener noreferrer" class="Button Button--iconOnly Button--invisible Button--medium">${ICON}</a>`);
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 = `<a href="${queryURL(url.host, m[1], m[2], m[3], i + 1)}" title="View artifact" target="_blank" rel="noopener noreferrer">${ICON}</a>`;
elm.insertAdjacentElement("beforeend", wrapper);
wrapper.appendChild(delBtn);
}
});
observer.disconnect();
}
}).observe(rav, { childList: true, subtree: true });
}
}

View file

@ -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/");
}