Compare commits
4 commits
2dbe3da892
...
55298b4617
Author | SHA1 | Date | |
---|---|---|---|
55298b4617 | |||
a8e173c8a9 | |||
299f54fd58 | |||
70219805d0 |
17 changed files with 402 additions and 106 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -3,6 +3,18 @@
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v0.2.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.1.0..v0.2.0) - 2024-06-14
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
29
Cargo.lock
generated
29
Cargo.lock
generated
|
@ -141,7 +141,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "artifactview"
|
name = "artifactview"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async_zip",
|
"async_zip",
|
||||||
"axum",
|
"axum",
|
||||||
|
@ -438,6 +438,12 @@ dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "buf-min"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22d5698cf6842742ed64805705798f8b351fff53fa546fd45c52184bee58dc90"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.16.0"
|
version = "3.16.0"
|
||||||
|
@ -502,7 +508,9 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-tzdata",
|
"android-tzdata",
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
"windows-targets 0.52.5",
|
"windows-targets 0.52.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -3160,6 +3168,18 @@ name = "v_htmlescape"
|
||||||
version = "0.15.8"
|
version = "0.15.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
|
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]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
|
@ -3539,6 +3559,7 @@ version = "0.15.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfce1df93f3b16e5272221a559e60bbbaaa71dbc042a43996d223e51a690aab2"
|
checksum = "dfce1df93f3b16e5272221a559e60bbbaaa71dbc042a43996d223e51a690aab2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"buf-min",
|
||||||
"yarte_derive",
|
"yarte_derive",
|
||||||
"yarte_helpers",
|
"yarte_helpers",
|
||||||
]
|
]
|
||||||
|
@ -3565,6 +3586,7 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
|
"v_jsonescape",
|
||||||
"yarte_codegen",
|
"yarte_codegen",
|
||||||
"yarte_helpers",
|
"yarte_helpers",
|
||||||
"yarte_hir",
|
"yarte_hir",
|
||||||
|
@ -3577,13 +3599,18 @@ version = "0.15.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0d1076f8cee9541ea5ffbecd9102f751252c91f085e7d30a18a3ce805ebd3ee"
|
checksum = "e0d1076f8cee9541ea5ffbecd9102f751252c91f085e7d30a18a3ce805ebd3ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"buf-min",
|
||||||
|
"chrono",
|
||||||
"dtoa",
|
"dtoa",
|
||||||
"itoa",
|
"itoa",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
|
"ryu",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
"toml",
|
"toml",
|
||||||
"v_htmlescape",
|
"v_htmlescape",
|
||||||
|
"v_jsonescape",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "artifactview"
|
name = "artifactview"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["ThetaDev <thetadev@magenta.de>"]
|
authors = ["ThetaDev <thetadev@magenta.de>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -72,7 +72,7 @@ tower-http = { version = "0.5.2", features = ["trace", "set-header"] }
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = "0.3.18"
|
tracing-subscriber = "0.3.18"
|
||||||
url = "2.5.0"
|
url = "2.5.0"
|
||||||
yarte = "0.15.7"
|
yarte = { version = "0.15.7", features = ["json"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
yarte_helpers = "0.15.8"
|
yarte_helpers = "0.15.8"
|
||||||
|
|
2
Justfile
2
Justfile
|
@ -1,5 +1,5 @@
|
||||||
test:
|
test:
|
||||||
cargo test
|
cargo nextest run --no-fail-fast
|
||||||
|
|
||||||
compress-res:
|
compress-res:
|
||||||
cd resources && zopfli *.css
|
cd resources && zopfli *.css
|
||||||
|
|
|
@ -71,7 +71,7 @@ networks:
|
||||||
Artifactview is configured using environment variables.
|
Artifactview is configured using environment variables.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | 3000 | HTTP port |
|
| `PORT` | 3000 | HTTP port |
|
||||||
| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts |
|
| `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" |
|
| `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" |
|
||||||
|
@ -87,8 +87,9 @@ Artifactview is configured using environment variables.
|
||||||
| `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`. |
|
| `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 |
|
| `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_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`. |
|
| `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` |
|
| `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 |
|
| `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer |
|
||||||
|
|
||||||
## Technical details
|
## Technical details
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 268 B After Width: | Height: | Size: 766 B |
Binary file not shown.
|
@ -172,7 +172,6 @@ p {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.center {
|
.center {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
Binary file not shown.
89
src/app.rs
89
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 async_zip::tokio::read::ZipEntryReader;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
@ -176,7 +178,13 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
match entry.get_file(&path, uri.query().unwrap_or_default()) {
|
match entry.get_file(&path, uri.query().unwrap_or_default()) {
|
||||||
Ok(GetFileResult::File(res)) => {
|
Ok(gfr) => {
|
||||||
|
if gfr.index() && !path.ends_with('/') {
|
||||||
|
return Ok(Redirect::permanent(&format!("{path}/")).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
match gfr {
|
||||||
|
GetFileResult::File(res) => {
|
||||||
let qparams = uri
|
let qparams = uri
|
||||||
.query()
|
.query()
|
||||||
.and_then(|q| serde_urlencoded::from_str::<FileQparams>(q).ok())
|
.and_then(|q| serde_urlencoded::from_str::<FileQparams>(q).ok())
|
||||||
|
@ -201,13 +209,10 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs).await
|
Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
Ok(GetFileResult::Listing(listing)) => {
|
GetFileResult::Listing(listing) => {
|
||||||
if !path.ends_with('/') {
|
|
||||||
return Ok(Redirect::to(&format!("{path}/")).into_response());
|
|
||||||
}
|
|
||||||
|
|
||||||
let run_url = query.forge_url();
|
let run_url = query.forge_url();
|
||||||
let tmpl = templates::Listing {
|
let tmpl = templates::Listing {
|
||||||
main_url: state.i.cfg.main_url(),
|
main_url: state.i.cfg.main_url(),
|
||||||
|
@ -239,6 +244,8 @@ impl App {
|
||||||
.cache()
|
.cache()
|
||||||
.body(tmpl.to_string().into())?)
|
.body(tmpl.to_string().into())?)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(Error::NotFound(e)) => {
|
Err(Error::NotFound(e)) => {
|
||||||
if path == FAVICON_PATH {
|
if path == FAVICON_PATH {
|
||||||
Self::favicon()
|
Self::favicon()
|
||||||
|
@ -256,17 +263,62 @@ impl App {
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
hdrs: &HeaderMap,
|
hdrs: &HeaderMap,
|
||||||
) -> Result<Response<Body>, Error> {
|
) -> 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() != "/" {
|
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)]
|
#[derive(Deserialize)]
|
||||||
|
@ -318,6 +370,7 @@ impl App {
|
||||||
.body(
|
.body(
|
||||||
templates::Index {
|
templates::Index {
|
||||||
main_url: state.i.cfg.main_url(),
|
main_url: state.i.cfg.main_url(),
|
||||||
|
example_site: state.i.cfg.example_site(),
|
||||||
}
|
}
|
||||||
.to_string()
|
.to_string()
|
||||||
.into(),
|
.into(),
|
||||||
|
|
33
src/cache.rs
33
src/cache.rs
|
@ -72,6 +72,7 @@ pub struct GetFileResultFile {
|
||||||
pub file: FileEntry,
|
pub file: FileEntry,
|
||||||
pub mime: Option<Mime>,
|
pub mime: Option<Mime>,
|
||||||
pub status: StatusCode,
|
pub status: StatusCode,
|
||||||
|
pub index: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
@ -105,6 +106,17 @@ pub struct Size(pub u32);
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct Crc32(#[serde(with = "SerHexOpt::<serde_hex::Strict>")] pub Option<u32>);
|
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 {
|
impl Cache {
|
||||||
pub fn new(cfg: Config) -> Self {
|
pub fn new(cfg: Config) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
@ -298,21 +310,31 @@ impl CacheEntry {
|
||||||
file: file.clone(),
|
file: file.clone(),
|
||||||
mime: util::path_mime(path),
|
mime: util::path_mime(path),
|
||||||
status: StatusCode::OK,
|
status: StatusCode::OK,
|
||||||
|
index: false,
|
||||||
}));
|
}));
|
||||||
} else if util::site_path_ext(path).is_none() {
|
} else if util::site_path_ext(path).is_none() {
|
||||||
index_path = Some(format!("{path}/index.html").into());
|
index_path = Some(format!("{path}/index.html").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(file) = index_path
|
if let Some(file) = index_path.and_then(|p: Cow<str>| self.files.get(p.as_ref())) {
|
||||||
.and_then(|p: Cow<str>| self.files.get(p.as_ref()))
|
// index.html
|
||||||
.or_else(|| self.files.get("200.html"))
|
|
||||||
{
|
|
||||||
// index.html or SPA entrypoint
|
|
||||||
return Ok(GetFileResult::File(GetFileResultFile {
|
return Ok(GetFileResult::File(GetFileResultFile {
|
||||||
filename: None,
|
filename: None,
|
||||||
file: file.clone(),
|
file: file.clone(),
|
||||||
mime: Some(mime::TEXT_HTML),
|
mime: Some(mime::TEXT_HTML),
|
||||||
status: StatusCode::OK,
|
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(),
|
file: file.clone(),
|
||||||
mime: Some(mime::TEXT_HTML),
|
mime: Some(mime::TEXT_HTML),
|
||||||
status: StatusCode::NOT_FOUND,
|
status: StatusCode::NOT_FOUND,
|
||||||
|
index: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,11 @@ pub struct ConfigData {
|
||||||
pub repo_blacklist: QueryFilterList,
|
pub repo_blacklist: QueryFilterList,
|
||||||
/// List of sites/users/repos that can ONLY be accessed
|
/// List of sites/users/repos that can ONLY be accessed
|
||||||
pub repo_whitelist: QueryFilterList,
|
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`)
|
/// Aliases for sites (Example: `gh => github.com`)
|
||||||
pub site_aliases: HashMap<String, String>,
|
pub site_aliases: HashMap<String, String>,
|
||||||
/// Maximum file size for the viewer
|
/// Maximum file size for the viewer
|
||||||
|
@ -89,6 +94,11 @@ impl Default for ConfigData {
|
||||||
limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()),
|
limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()),
|
||||||
repo_blacklist: QueryFilterList::default(),
|
repo_blacklist: QueryFilterList::default(),
|
||||||
repo_whitelist: 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(),
|
site_aliases: HashMap::new(),
|
||||||
viewer_max_size: Some(NonZeroU32::new(500_000).unwrap()),
|
viewer_max_size: Some(NonZeroU32::new(500_000).unwrap()),
|
||||||
}
|
}
|
||||||
|
@ -154,6 +164,15 @@ impl Config {
|
||||||
&self.i.main_url
|
&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<()> {
|
pub fn check_filterlist(&self, query: &ArtifactQuery) -> Result<()> {
|
||||||
if !self.i.data.repo_blacklist.passes(query, true) {
|
if !self.i.data.repo_blacklist.passes(query, true) {
|
||||||
Err(Error::Forbidden("repository is blacklisted".into()))
|
Err(Error::Forbidden("repository is blacklisted".into()))
|
||||||
|
|
27
src/query.rs
27
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 once_cell::sync::Lazy;
|
||||||
use regex::{Captures, Regex};
|
use regex::{Captures, Regex};
|
||||||
|
@ -244,11 +247,11 @@ impl From<ArtifactQuery> for RunQuery {
|
||||||
fn encode_domain(s: &str, bias: char) -> String {
|
fn encode_domain(s: &str, bias: char) -> String {
|
||||||
// Check if the character at the given position is in the middle of the string
|
// 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
|
// and it is not followed by escape seq numbers or further escapable characters
|
||||||
let is_mid_single = |pos: usize| -> bool {
|
let is_mid_single = |str: &str, pos: usize| -> bool {
|
||||||
if pos == 0 || pos >= (s.len() - 1) {
|
if pos == 0 || pos >= (str.len() - 1) {
|
||||||
return false;
|
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, '-' | '.' | '_')
|
!('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;
|
let mut last_pos = 0;
|
||||||
for (pos, c) in s.match_indices('-') {
|
for (pos, c) in s.match_indices('-') {
|
||||||
buf += &s[last_pos..pos];
|
buf += &s[last_pos..pos];
|
||||||
if bias == '-' && is_mid_single(pos) {
|
if bias == '-' && is_mid_single(s, pos) {
|
||||||
buf.push('-');
|
buf.push('-');
|
||||||
} else {
|
} else {
|
||||||
buf += "-1";
|
buf += "-1";
|
||||||
|
@ -272,7 +275,7 @@ fn encode_domain(s: &str, bias: char) -> String {
|
||||||
for (pos, c) in buf.match_indices(['.', '_']) {
|
for (pos, c) in buf.match_indices(['.', '_']) {
|
||||||
buf2 += &buf[last_pos..pos];
|
buf2 += &buf[last_pos..pos];
|
||||||
let cchar = c.chars().next().unwrap();
|
let cchar = c.chars().next().unwrap();
|
||||||
if cchar == bias && is_mid_single(pos) {
|
if cchar == bias && is_mid_single(&buf, pos) {
|
||||||
buf2.push('-');
|
buf2.push('-');
|
||||||
} else if cchar == '.' {
|
} else if cchar == '.' {
|
||||||
buf2 += "-0"
|
buf2 += "-0"
|
||||||
|
@ -374,6 +377,18 @@ impl QueryFilterList {
|
||||||
self.0.iter().any(|itm| itm.passes(query)) ^ blacklist
|
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 {
|
impl<'de> Deserialize<'de> for QueryFilterList {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
artifact_api::Artifact,
|
artifact_api::Artifact,
|
||||||
cache::{Crc32, ListingEntry, Size},
|
cache::{Crc32, ListingEntry, Size},
|
||||||
|
@ -11,6 +13,7 @@ use yarte::{Render, Template};
|
||||||
#[template(path = "index")]
|
#[template(path = "index")]
|
||||||
pub struct Index<'a> {
|
pub struct Index<'a> {
|
||||||
pub main_url: &'a str,
|
pub main_url: &'a str,
|
||||||
|
pub example_site: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
@ -65,6 +68,16 @@ pub struct Junit {
|
||||||
pub suites: TestSuites,
|
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 struct ViewerLink {
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
name="url"
|
name="url"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="codeberg.org/username/repo/actions/runs/42"
|
placeholder="{{example_site}}/user/repo/actions/runs/42"
|
||||||
style="flex-grow: 1"
|
style="flex-grow: 1"
|
||||||
/>
|
/>
|
||||||
<button class="btn" type="submit">Browse</button>
|
<button class="btn" type="submit">Browse</button>
|
||||||
|
@ -40,6 +40,10 @@
|
||||||
Artifactview
|
Artifactview
|
||||||
</a>
|
</a>
|
||||||
{{~crate::app::VERSION}}
|
{{~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">
|
<p class="light">
|
||||||
<b>Disclaimer:</b>
|
<b>Disclaimer:</b>
|
||||||
Artifactview does not host any websites, the data is fetched from the respective
|
Artifactview does not host any websites, the data is fetched from the respective
|
||||||
|
|
108
templates/userscript.hbs
Normal file
108
templates/userscript.hbs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -430,3 +430,25 @@ async fn compressed(server: TestAv) {
|
||||||
let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap();
|
let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap();
|
||||||
assert_eq!(buf, expect);
|
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/");
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue