Compare commits
8 commits
6a0171c62a
...
608a9f68f4
Author | SHA1 | Date | |
---|---|---|---|
608a9f68f4 | |||
4ebeb4b873 | |||
47f3ea1267 | |||
72c0e3af69 | |||
79ad3b9c24 | |||
eca80aaa8e | |||
607255931a | |||
5a54f0a7a6 |
30 changed files with 1987 additions and 667 deletions
|
@ -1,6 +1,5 @@
|
||||||
CACHE_DIR=/tmp/artifactview
|
NO_HTTPS=1
|
||||||
MAX_ARTIFACT_SIZE=100000000
|
|
||||||
MAX_AGE_H=12
|
|
||||||
# If you only want to access public repositories,
|
# If you only want to access public repositories,
|
||||||
# create a fine-grained token with Public Repositories (read-only) access
|
# create a fine-grained token with Public Repositories (read-only) access
|
||||||
GITHUB_TOKEN=github_pat_123456
|
# GITHUB_TOKEN=github_pat_123456
|
||||||
|
SITE_ALIASES=gh=>github.com;cb=>codeberg.org
|
||||||
|
|
15
.forgejo/workflows/artifact.yaml
Normal file
15
.forgejo/workflows/artifact.yaml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
name: Test artifact
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- ".forgejo/artifact.yaml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
artifact:
|
||||||
|
runs-on: cimaster-latest
|
||||||
|
steps:
|
||||||
|
- name: 👁️ Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Build artifact
|
|
@ -25,8 +25,6 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: 👁️ Checkout repository
|
- name: 👁️ Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0 # important to fetch tag logs
|
|
||||||
|
|
||||||
- name: ⚒️ Build application
|
- name: ⚒️ Build application
|
||||||
run: |
|
run: |
|
||||||
|
@ -49,11 +47,7 @@ jobs:
|
||||||
tar -cJf dist/artifactview-x86_64-${{ github.ref_name }}.tar.xz -C target/x86_64-unknown-linux-gnu/release artifactview
|
tar -cJf dist/artifactview-x86_64-${{ github.ref_name }}.tar.xz -C target/x86_64-unknown-linux-gnu/release artifactview
|
||||||
tar -cJf dist/artifactview-aarch64-${{ github.ref_name }}.tar.xz -C target/aarch64-unknown-linux-gnu/release artifactview
|
tar -cJf dist/artifactview-aarch64-${{ github.ref_name }}.tar.xz -C target/aarch64-unknown-linux-gnu/release artifactview
|
||||||
|
|
||||||
{
|
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' CHANGELOG.md >> "$GITHUB_ENV"
|
||||||
echo 'CHANGELOG<<END_OF_FILE'
|
|
||||||
git show -s --format=%N "${{ github.ref }}" | tail -n +4 | awk 'BEGIN{RS="-----BEGIN PGP SIGNATURE-----"} NR==1{printf $0}'
|
|
||||||
echo END_OF_FILE
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: 🎉 Publish release
|
- name: 🎉 Publish release
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
|
233
Cargo.lock
generated
233
Cargo.lock
generated
|
@ -146,6 +146,7 @@ dependencies = [
|
||||||
"async_zip",
|
"async_zip",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
"comrak",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"envy",
|
"envy",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
@ -171,6 +172,8 @@ dependencies = [
|
||||||
"serde-env",
|
"serde-env",
|
||||||
"serde-hex",
|
"serde-hex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"syntect",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
@ -264,7 +267,6 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper 1.0.1",
|
"sync_wrapper 1.0.1",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
|
@ -350,6 +352,15 @@ version = "1.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
@ -365,6 +376,12 @@ version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
@ -464,6 +481,22 @@ version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
|
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "comrak"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a972c8ec1be8065f7b597b5f7f5b3be535db780280644aebdcd1966decf58dc"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder",
|
||||||
|
"entities",
|
||||||
|
"memchr",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
|
"slug",
|
||||||
|
"typed-arena",
|
||||||
|
"unicode_categories",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "constant_time_eq"
|
name = "constant_time_eq"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
@ -526,6 +559,41 @@ dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.20.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.20.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn 2.0.66",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.20.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.66",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "5.5.3"
|
version = "5.5.3"
|
||||||
|
@ -554,6 +622,37 @@ dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_core"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.66",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_macro"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_core",
|
||||||
|
"syn 2.0.66",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.17"
|
version = "0.99.17"
|
||||||
|
@ -567,6 +666,12 @@ dependencies = [
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deunicode"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
|
@ -590,6 +695,12 @@ version = "1.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
|
checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "entities"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -1071,6 +1182,12 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
@ -1327,13 +1444,35 @@ version = "1.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onig"
|
||||||
|
version = "6.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"onig_sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onig_sys"
|
||||||
|
version = "69.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.64"
|
version = "0.10.64"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
|
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.5.0",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
|
@ -1533,7 +1672,7 @@ checksum = "31b476131c3c86cb68032fdc5cb6d5a1045e3e42d96b69fa599fd77701e1f5bf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
"bitflags",
|
"bitflags 2.5.0",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand",
|
||||||
|
@ -1632,7 +1771,7 @@ version = "11.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e29830cbb1290e404f24c73af91c5d8d631ce7e128691e9477556b540cd01ecd"
|
checksum = "e29830cbb1290e404f24c73af91c5d8d631ce7e128691e9477556b540cd01ecd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1641,7 +1780,7 @@ version = "0.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
|
checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1790,7 +1929,7 @@ version = "0.38.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
|
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.5.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
|
@ -1875,6 +2014,15 @@ version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.23"
|
version = "0.1.23"
|
||||||
|
@ -1896,7 +2044,7 @@ version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0"
|
checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.5.0",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
|
@ -2043,6 +2191,16 @@ dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slug"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4"
|
||||||
|
dependencies = [
|
||||||
|
"deunicode",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "0.6.14"
|
version = "0.6.14"
|
||||||
|
@ -2083,6 +2241,12 @@ dependencies = [
|
||||||
"lock_api",
|
"lock_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
@ -2123,6 +2287,26 @@ version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
|
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syntect"
|
||||||
|
version = "5.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1"
|
||||||
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"flate2",
|
||||||
|
"fnv",
|
||||||
|
"once_cell",
|
||||||
|
"onig",
|
||||||
|
"regex-syntax",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.10.1"
|
version = "3.10.1"
|
||||||
|
@ -2303,7 +2487,6 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2312,7 +2495,7 @@ version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.5.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
@ -2341,7 +2524,6 @@ version = "0.1.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
@ -2399,6 +2581,12 @@ version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-arena"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
|
@ -2453,6 +2641,12 @@ version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
|
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode_categories"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
@ -2519,6 +2713,16 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
@ -2648,6 +2852,15 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
17
Cargo.toml
17
Cargo.toml
|
@ -21,8 +21,15 @@ async_zip = { path = "crates/async_zip", features = [
|
||||||
"tokio-fs",
|
"tokio-fs",
|
||||||
"deflate",
|
"deflate",
|
||||||
] }
|
] }
|
||||||
axum = { version = "0.7.5", features = ["http2"] }
|
axum = { version = "0.7.5", default-features = false, features = [
|
||||||
|
"http1",
|
||||||
|
"http2",
|
||||||
|
"json",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
] }
|
||||||
axum-extra = { version = "0.9.3", features = ["typed-header"] }
|
axum-extra = { version = "0.9.3", features = ["typed-header"] }
|
||||||
|
comrak = { version = "0.24.1", default-features = false }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
envy = { path = "crates/envy" }
|
envy = { path = "crates/envy" }
|
||||||
flate2 = "1.0.30"
|
flate2 = "1.0.30"
|
||||||
|
@ -49,6 +56,14 @@ serde = { version = "1.0.203", features = ["derive"] }
|
||||||
serde-env = "0.1.1"
|
serde-env = "0.1.1"
|
||||||
serde-hex = "0.1.0"
|
serde-hex = "0.1.0"
|
||||||
serde_json = "1.0.117"
|
serde_json = "1.0.117"
|
||||||
|
serde_urlencoded = "0.7.1"
|
||||||
|
syntect = { version = "5.2.0", default-features = false, features = [
|
||||||
|
"parsing",
|
||||||
|
"default-syntaxes",
|
||||||
|
"default-themes",
|
||||||
|
"html",
|
||||||
|
"regex-onig",
|
||||||
|
] }
|
||||||
thiserror = "1.0.61"
|
thiserror = "1.0.61"
|
||||||
tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] }
|
tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] }
|
||||||
tokio-util = { version = "0.7.11", features = ["io"] }
|
tokio-util = { version = "0.7.11", features = ["io"] }
|
||||||
|
|
137
README.md
137
README.md
|
@ -6,35 +6,136 @@ Forgejo and GitHub's CI systems allow you to upload files and directories as
|
||||||
[artifacts](https://github.com/actions/upload-artifact). These can be downloaded as zip
|
[artifacts](https://github.com/actions/upload-artifact). These can be downloaded as zip
|
||||||
files. However there is no simple way to view individual files of an artifact.
|
files. However there is no simple way to view individual files of an artifact.
|
||||||
|
|
||||||
Artifactview is a small web application that can fetch these CI artifacts and serve
|
Artifactview is a small web application that fetches these CI artifacts and displays
|
||||||
their contents. If the artifact contains a website, it is displayed normally, if it consists
|
their contents.
|
||||||
of other files, a file listing is shown.
|
|
||||||
|
|
||||||
There is also full support for single page applications, placing a file named `200.html` in the
|
It offers full support for single page applications and custom 404 error pages.
|
||||||
root directory it will be returned in case no file exists for the requested path.
|
Single-page applications require a file named `200.html` placed in the root directory,
|
||||||
|
which will be served in case no file exists for the requested path. A custom 404 error
|
||||||
|
page is defined using a file named `404.html` in the root directory.
|
||||||
|
|
||||||
Alternatively, if a file named `404.html` exists in the root directory, it will be returned with
|
Artifactview displays a file listing if there is no `index.html` or fallback page
|
||||||
status code 404 if no file was found.
|
present, so you can browse artifacts that dont contain websites.
|
||||||
|
|
||||||
|
![Artifact file listing](resources/screenshotFiles.png)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
Artifactview accepts URLs in the given format: `<HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.example.com`
|
Open a Github/Gitea/Forgejo actions run with artifacts and paste its URL into the input
|
||||||
|
box on the main page. You can also pass the run URL with the `?url=` parameter.
|
||||||
|
|
||||||
|
Artifactview will show you a selection page where you will be able to choose the
|
||||||
|
artifact you want to browse.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
You can run artifactview using the docker image provided under
|
||||||
|
`thetadev256/artifactview:latest` or bare-metal using the provided binaries.
|
||||||
|
|
||||||
|
Artifactview is designed to run behind a reverse proxy since it does not support HTTPS
|
||||||
|
by itself. If you are using a reverse proxy, you have to set the `REAL_IP_HEADER` option
|
||||||
|
to the client IP address header name provided by the proxy (usually `x-forwarded-for`.
|
||||||
|
Otherwise artifactview will assume it is being accessed by only 1 client (the proxy
|
||||||
|
itself) and the rate limiter would count all users as one.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Here is an example setup with docker-compose, using Traefik as a reverse proxy:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
artifactview:
|
||||||
|
image: thetadev256/artifactview:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
environment:
|
||||||
|
ROOT_DOMAIN: av.thetadev.de
|
||||||
|
REAL_IP_HEADER: x-forwarded-for
|
||||||
|
GITHUB_TOKEN: github_pat_123456
|
||||||
|
REPO_WHITELIST: github.com;codeberg.org;code.thetadev.de
|
||||||
|
SITE_ALIASES: gh=>github.com;cb=>codeberg.org;th=>code.thetadev.de
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=proxy"
|
||||||
|
- "traefik.http.routers.artifactview.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.artifactview.rule=HostRegexp(`^[a-z0-9-]*.?av.thetadev.de$`)"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
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` |
|
||||||
|
|
||||||
|
## Technical details
|
||||||
|
|
||||||
|
### URL format
|
||||||
|
|
||||||
|
Artifactview uses URLs in the given format for accessing the individual artifacts:
|
||||||
|
`<HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.hostname`
|
||||||
|
|
||||||
Example: `https://github-com--theta-dev--example-project--4-11.example.com`
|
Example: `https://github-com--theta-dev--example-project--4-11.example.com`
|
||||||
|
|
||||||
## Security considerations
|
The reason for using subdomains instead of URL paths is that many websites expect to be
|
||||||
|
served from a separate subdomain and access resources using absolute paths. Using URLs
|
||||||
|
like `example.com/github.com/theta-dev/example-project/4/11/path/to/file` would make the
|
||||||
|
application easier to host, but it would not be possible to simply preview a
|
||||||
|
React/Vue/Svelte web project.
|
||||||
|
|
||||||
It is recommended to use the whitelist feature to limit Artifactview to access only trusted
|
Since domains only allow letters, numbers and dashes but repository names allow dots and
|
||||||
servers, users and organizations.
|
underscores, these escape sequences are used to access repositories with special
|
||||||
|
characters in their names.
|
||||||
|
|
||||||
|
- `-0` -> `.`
|
||||||
|
- `-1` -> `-`
|
||||||
|
- `-2` -> `_`
|
||||||
|
|
||||||
|
Another issue with using subdomains is that they are limited to a maximum of 63
|
||||||
|
characters. Most user and repository names are short enough for this not to become a
|
||||||
|
problem, but it could still happen that a CI run becomes inaccessible. Since the run ID
|
||||||
|
is incremented on each new CI run, it might even happen that Artifactview works fine at
|
||||||
|
the beginning of a project, but the subdomains exceed the length limit in the future.
|
||||||
|
|
||||||
|
That's why I added aliases for forge URLs. You can for example alias github.com as gh,
|
||||||
|
shaving 8 characters from the subdomain. This makes the subdomains short enogh that you
|
||||||
|
will be unlikely to hit the limit even with longer user/project names.
|
||||||
|
|
||||||
|
### Security considerations
|
||||||
|
|
||||||
|
It is recommended to use the whitelist feature to allow artifactview to access only
|
||||||
|
trusted servers, users and organizations.
|
||||||
|
|
||||||
Since many
|
Since many
|
||||||
[well-known URIs](https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml)
|
[well-known URIs](https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml)
|
||||||
are used to configure security-relevant properties of a website or are used to attest
|
are used to configure security-relevant properties of a website or attest ownership of a
|
||||||
ownership of a website (like `.well-known/acme-challenge` for issuing TLS certificates),
|
website (like `.well-known/acme-challenge` for issuing TLS certificates), Artifactview
|
||||||
Artifactview will serve no files from the `.well-known` folder.
|
will serve no files from the `.well-known` folder.
|
||||||
|
|
||||||
There is a configurable limit for both the maximum downloaded artifact size and the
|
There is a configurable limit for both the maximum downloaded artifact size and the
|
||||||
maximum size of individual files to be served (100MB by default).
|
maximum size of individual files to be served (100MB by default). Additionally there is
|
||||||
Additionally there is a configurable timeout for the zip file indexing operation.
|
a configurable timeout for the zip file indexing operation. These measures should
|
||||||
These measures should protect the server againt denial-of-service attacks like
|
protect the server againt denial-of-service attacks like overfilling the server drive or
|
||||||
overfilling the server drive or uploading zip bombs.
|
uploading zip bombs.
|
||||||
|
|
453
resources/content.css
Normal file
453
resources/content.css
Normal file
|
@ -0,0 +1,453 @@
|
||||||
|
/* Additional stylesheet for artifactview content viewer */
|
||||||
|
|
||||||
|
.viewer > pre {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, code {
|
||||||
|
color: #cccccc;
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markup {
|
||||||
|
margin: 20px 20px 0 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
}
|
||||||
|
.markup > :first-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
.markup > :last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
.markup h1,
|
||||||
|
.markup h2,
|
||||||
|
.markup h3,
|
||||||
|
.markup h4,
|
||||||
|
.markup h5,
|
||||||
|
.markup h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.markup h1 tt,
|
||||||
|
.markup h1 code,
|
||||||
|
.markup h2 tt,
|
||||||
|
.markup h2 code,
|
||||||
|
.markup h3 tt,
|
||||||
|
.markup h3 code,
|
||||||
|
.markup h4 tt,
|
||||||
|
.markup h4 code,
|
||||||
|
.markup h5 tt,
|
||||||
|
.markup h5 code,
|
||||||
|
.markup h6 tt,
|
||||||
|
.markup h6 code {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.markup h1 {
|
||||||
|
border-bottom: 1px solid var(--color-secondary);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
.markup h2 {
|
||||||
|
border-bottom: 1px solid var(--color-secondary);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
.markup h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
.markup h4 {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
.markup h5 {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.markup h6 {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.markup p,
|
||||||
|
.markup blockquote,
|
||||||
|
.markup details,
|
||||||
|
.markup ul,
|
||||||
|
.markup ol,
|
||||||
|
.markup dl,
|
||||||
|
.markup table,
|
||||||
|
.markup pre {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.markup hr {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
border: 0;
|
||||||
|
height: 4px;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.markup ul,
|
||||||
|
.markup ol {
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
.markup ul ul,
|
||||||
|
.markup ul ol,
|
||||||
|
.markup ol ol,
|
||||||
|
.markup ol ul {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.markup ol ol,
|
||||||
|
.markup ul ol {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
.markup li > p {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.markup li + li {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
.markup dl {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.markup dl dt {
|
||||||
|
font-size: 1em;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.markup dl dd {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.markup blockquote {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
border-left: 4px solid var(--color-secondary);
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
.markup blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.markup blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.markup table {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.markup table th {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.markup table th,
|
||||||
|
.markup table td {
|
||||||
|
border: 1px solid var(--color-secondary) !important;
|
||||||
|
padding: 6px 13px !important;
|
||||||
|
}
|
||||||
|
.markup table tr {
|
||||||
|
border-top: 1px solid var(--color-secondary);
|
||||||
|
}
|
||||||
|
.markup table tr:nth-child(2n) {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
.markup img,
|
||||||
|
.markup video {
|
||||||
|
box-sizing: initial;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.markup img[align="right"],
|
||||||
|
.markup video[align="right"] {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.markup img[align="left"],
|
||||||
|
.markup video[align="left"] {
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
.markup code {
|
||||||
|
white-space: break-spaces;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
font-size: 85%;
|
||||||
|
}
|
||||||
|
.markup code br {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.markup pre {
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
word-break: normal;
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
.markup pre code:before,
|
||||||
|
.markup pre code:after {
|
||||||
|
content: normal;
|
||||||
|
}
|
||||||
|
.markup .ui.list .list,
|
||||||
|
.markup ol.ui.list ol,
|
||||||
|
.markup ul.ui.list ul {
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* theme "Monokai++" generated by syntect */
|
||||||
|
.entity.name.function.preprocessor,
|
||||||
|
.meta.preprocessor.macro,
|
||||||
|
.storage.modifier.import,
|
||||||
|
.storage.type.generic,
|
||||||
|
.variable.parameter,
|
||||||
|
.punctuation.section.class.begin,
|
||||||
|
.punctuation.section.class.end {
|
||||||
|
color: #cccccc;
|
||||||
|
}
|
||||||
|
.invalid {
|
||||||
|
background-color: #e62a19;
|
||||||
|
}
|
||||||
|
.comment {
|
||||||
|
color: #696d70;
|
||||||
|
}
|
||||||
|
.string,
|
||||||
|
.string.quoted,
|
||||||
|
.punctuation.definition.string.begin,
|
||||||
|
.punctuation.definition.string.end {
|
||||||
|
color: #e6db74;
|
||||||
|
}
|
||||||
|
.string.regexp {
|
||||||
|
color: #49e0fd;
|
||||||
|
}
|
||||||
|
.constant.language,
|
||||||
|
.constant.numeric,
|
||||||
|
.support.variable.magic {
|
||||||
|
color: #ae81ff;
|
||||||
|
}
|
||||||
|
.constant.character,
|
||||||
|
.constant.other.placeholder,
|
||||||
|
.support.other.escape.special.regexp {
|
||||||
|
color: #e62a19;
|
||||||
|
}
|
||||||
|
.constant.other {
|
||||||
|
color: #fd971f;
|
||||||
|
}
|
||||||
|
.entity.name.variable.property,
|
||||||
|
.keyword,
|
||||||
|
.meta.preprocessor {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
.storage,
|
||||||
|
.support.constant,
|
||||||
|
.punctuation.section.class {
|
||||||
|
color: #49e0fd;
|
||||||
|
}
|
||||||
|
.keyword.type,
|
||||||
|
.storage.type,
|
||||||
|
.support.class,
|
||||||
|
.support.type,
|
||||||
|
.entity.name.type {
|
||||||
|
color: #2be98a;
|
||||||
|
}
|
||||||
|
.variable.language,
|
||||||
|
.variable.parameter.function.language.special,
|
||||||
|
.variable.other.member,
|
||||||
|
.variable.other.readwrite.member,
|
||||||
|
.entity.other.attribute-name,
|
||||||
|
.variable.parameter.function-call {
|
||||||
|
color: #fd971f;
|
||||||
|
}
|
||||||
|
.punctuation.accessor,
|
||||||
|
.punctuation.section.embedded,
|
||||||
|
.punctuation.separator,
|
||||||
|
.punctuation.definition.attribute,
|
||||||
|
.storage.type.function.arrow,
|
||||||
|
.punctuation.definition.template-expression,
|
||||||
|
.punctuation.definition.template-expression.begin,
|
||||||
|
.punctuation.definition.template-expression.end,
|
||||||
|
.punctuation.template-string.element.begin,
|
||||||
|
.punctuation.template-string.element.end {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
.punctuation.separator.parameters {
|
||||||
|
color: #fd971f;
|
||||||
|
}
|
||||||
|
.entity.name.tag {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
.entity.name.function,
|
||||||
|
.support.function,
|
||||||
|
.variable.function {
|
||||||
|
color: #b0ec38;
|
||||||
|
}
|
||||||
|
.markup.heading {
|
||||||
|
color: #f92672;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.markup.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.markup.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.markup.underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markup.quote {
|
||||||
|
color: #696d70;
|
||||||
|
}
|
||||||
|
.markup.inline,
|
||||||
|
.markup.raw.inline {
|
||||||
|
color: #ae81ff;
|
||||||
|
}
|
||||||
|
.keyword.operator.dereference.java,
|
||||||
|
.meta.preprocessor.haskell,
|
||||||
|
.punctuation.separator.java,
|
||||||
|
.meta.group.js,
|
||||||
|
.meta.group.go,
|
||||||
|
.punctuation.section.class.begin.python,
|
||||||
|
.support.variable.dom.js,
|
||||||
|
.constant.character.brace,
|
||||||
|
.constant.character.end,
|
||||||
|
.constant.character.paren,
|
||||||
|
.constant.character.quote,
|
||||||
|
.support.class.js,
|
||||||
|
.punctuation.section.group.begin.js,
|
||||||
|
.punctuation.section.group.end.js,
|
||||||
|
.meta.template.expression,
|
||||||
|
.meta.group.braces,
|
||||||
|
.source.groovy.embedded.source,
|
||||||
|
.punctuation.section.class.end.groovy,
|
||||||
|
.variable.other.bracket.shell,
|
||||||
|
.variable.other.readwrite.shell,
|
||||||
|
.meta.group.expansion.command.parens.shell,
|
||||||
|
.variable.other.normal.shell,
|
||||||
|
.string.interpolated.dollar.shell,
|
||||||
|
.meta.function.shell .punctuation.section.parens.begin.shell,
|
||||||
|
.meta.function.shell .punctuation.section.parens.end.shell,
|
||||||
|
.string.other.math.shell {
|
||||||
|
color: #cccccc;
|
||||||
|
}
|
||||||
|
.constant.other.symbol.prolog,
|
||||||
|
.support.function.be.latex,
|
||||||
|
.support.function.general.tex,
|
||||||
|
.support.function.section.latex,
|
||||||
|
.punctuation.dollar.js,
|
||||||
|
.punctuation.separator.parameters.python,
|
||||||
|
.support.function.definition.latex,
|
||||||
|
.constant.language.module.events,
|
||||||
|
.constant.language.module.http,
|
||||||
|
.constant.language.directive.module.main,
|
||||||
|
.constant.language.directive.module.events,
|
||||||
|
.constant.language.directive.module.http,
|
||||||
|
.variable.language.this.js,
|
||||||
|
.variable.parameter.option.shell,
|
||||||
|
.punctuation.definition.variable.shell,
|
||||||
|
.punctuation.section.expansion.parameter.begin.shell,
|
||||||
|
.punctuation.section.expansion.parameter.end.shell,
|
||||||
|
.punctuation.section.parens.begin.shell,
|
||||||
|
.punctuation.section.parens.end.shell,
|
||||||
|
.string.interpolated.dollar.shell .punctuation.definition.string.begin.shell,
|
||||||
|
.string.interpolated.dollar.shell .punctuation.definition.string.end.shell,
|
||||||
|
.string.other.math.shell .punctuation.definition.string.begin.shell,
|
||||||
|
.string.other.math.shell .punctuation.definition.string.end.shell,
|
||||||
|
.variable.language.special.self.python,
|
||||||
|
.variable.parameter.function.language.special.self.python {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
.entity.name.type.go,
|
||||||
|
.entity.name.type.namespace.php,
|
||||||
|
.meta.import.scala,
|
||||||
|
.punctuation.separator.inheritance.php,
|
||||||
|
.storage.type.js,
|
||||||
|
.support.other.module.haskell,
|
||||||
|
.support.other.namespace.use.php,
|
||||||
|
.variable.other.constant.ruby,
|
||||||
|
.entity.name.section.puppet,
|
||||||
|
.entity.name.function.decorator.python,
|
||||||
|
.keyword.other.rust {
|
||||||
|
color: #49e0fd;
|
||||||
|
}
|
||||||
|
.keyword.control.def.ruby,
|
||||||
|
.keyword.declaration.scala,
|
||||||
|
.keyword.declaration.stable.scala,
|
||||||
|
.keyword.declaration.volatile.scala,
|
||||||
|
.keyword.other.fn.rust,
|
||||||
|
.meta.structure.dictionary.key.json,
|
||||||
|
.storage.class.std.rust {
|
||||||
|
color: #2be98a;
|
||||||
|
}
|
||||||
|
.meta.function-call.object.php,
|
||||||
|
.meta.function-call.static.php,
|
||||||
|
.variable.other.makefile,
|
||||||
|
.variable.other.prolog,
|
||||||
|
.variable.other.property.js,
|
||||||
|
.support.variable.property.dom.js,
|
||||||
|
.meta.property.object.js,
|
||||||
|
.support.variable.property.js,
|
||||||
|
.variable.other.object.property.js,
|
||||||
|
.variable.other.property.cpp,
|
||||||
|
.meta.attribute.python {
|
||||||
|
color: #fd971f;
|
||||||
|
}
|
||||||
|
.meta.method.groovy,
|
||||||
|
.punctuation.definition.logical-expression.shell,
|
||||||
|
.meta.function-call.generic.python {
|
||||||
|
color: #b0ec38;
|
||||||
|
}
|
||||||
|
.constant.other.boolean.toml {
|
||||||
|
color: #ae81ff;
|
||||||
|
}
|
||||||
|
.string.other.link.title.markdown,
|
||||||
|
.string.other.link.description.markdown {
|
||||||
|
color: #49e0fd;
|
||||||
|
}
|
||||||
|
.beginning.punctuation.definition.list.markdown,
|
||||||
|
.punctuation.definition.list_item.markdown,
|
||||||
|
.punctuation.definition.list.markdown,
|
||||||
|
.punctuation.definition.heading.markdown,
|
||||||
|
.punctuation.definition.bold.markdown,
|
||||||
|
.punctuation.definition.italic.markdown,
|
||||||
|
.punctuation.definition.string.begin.markdown,
|
||||||
|
.punctuation.definition.string.end.markdown,
|
||||||
|
.punctuation.definition.bold.begin.markdown,
|
||||||
|
.punctuation.definition.bold.end.markdown,
|
||||||
|
.punctuation.definition.italic.begin.markdown,
|
||||||
|
.punctuation.definition.italic.end.markdown,
|
||||||
|
.punctuation.definition.heading.begin.markdown,
|
||||||
|
.punctuation.definition.heading.end.markdown,
|
||||||
|
.punctuation.definition.raw.begin.markdown,
|
||||||
|
.punctuation.definition.raw.end.markdown,
|
||||||
|
.punctuation.definition.metadata.markdown,
|
||||||
|
.punctuation.definition.raw.markdown,
|
||||||
|
.markup.underline.link.image.markdown,
|
||||||
|
.markup.underline.link.markdown {
|
||||||
|
color: #696d70;
|
||||||
|
}
|
||||||
|
.markup.deleted.diff {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
.markup.inserted.diff {
|
||||||
|
color: #2be98a;
|
||||||
|
}
|
||||||
|
.meta.diff.range.unified {
|
||||||
|
color: #ae81ff;
|
||||||
|
}
|
||||||
|
.markup.deleted.git_gutter {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
.markup.inserted.git_gutter {
|
||||||
|
color: #2be98a;
|
||||||
|
}
|
||||||
|
.markup.changed.git_gutter {
|
||||||
|
color: #ae81ff;
|
||||||
|
}
|
||||||
|
.markup.ignored.git_gutter {
|
||||||
|
color: #696d70;
|
||||||
|
}
|
||||||
|
.markup.untracked.git_gutter {
|
||||||
|
color: #696d70;
|
||||||
|
}
|
BIN
resources/screenshotFiles.png
Normal file
BIN
resources/screenshotFiles.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
229
resources/style.css
Normal file
229
resources/style.css
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
/* Stylesheet for all artifactview pages */
|
||||||
|
* {
|
||||||
|
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, a.selected {
|
||||||
|
color: #319cff;
|
||||||
|
}
|
||||||
|
header, #summary, .content {
|
||||||
|
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,
|
||||||
|
a.selected {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
header h1 a:first-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#summary, #summary > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
.metadata {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: Verdana, sans-serif;
|
||||||
|
border-bottom: 1px solid #9c9c9c;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
.query-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;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
@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;
|
||||||
|
--color-border: #212121;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #101010;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
background-color: #151515;
|
||||||
|
}
|
||||||
|
.query-input {
|
||||||
|
background-color: #151515;
|
||||||
|
}
|
||||||
|
#list tbody tr:hover {
|
||||||
|
background-color: #252525;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #5796d1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover,
|
||||||
|
h1 a:hover, a.selected {
|
||||||
|
color: #62b2fd;
|
||||||
|
}
|
||||||
|
#list tr {
|
||||||
|
border-bottom: 1px dashed rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
#filter {
|
||||||
|
background-color: #151515;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #212121;
|
||||||
|
}
|
||||||
|
.metadata {
|
||||||
|
border-bottom: 1px solid #212121;
|
||||||
|
}
|
||||||
|
}
|
330
src/app.rs
330
src/app.rs
|
@ -1,4 +1,4 @@
|
||||||
use std::{net::SocketAddr, ops::Bound, path::PathBuf, str::FromStr, sync::Arc};
|
use std::{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::{
|
||||||
|
@ -6,10 +6,11 @@ use axum::{
|
||||||
extract::{Host, Request, State},
|
extract::{Host, Request, State},
|
||||||
http::{Response, Uri},
|
http::{Response, Uri},
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect},
|
||||||
routing::{any, get, post},
|
routing::{any, get},
|
||||||
Form, Router,
|
Router,
|
||||||
};
|
};
|
||||||
use headers::HeaderMapExt;
|
use futures_lite::AsyncReadExt as LiteAsyncReadExt;
|
||||||
|
use headers::{ContentType, HeaderMapExt};
|
||||||
use http::{HeaderMap, StatusCode};
|
use http::{HeaderMap, StatusCode};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
|
@ -31,9 +32,10 @@ use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
error::Error,
|
error::Error,
|
||||||
gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN},
|
gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN},
|
||||||
query::Query,
|
query::{ArtifactQuery, Query, RunQuery},
|
||||||
templates::{self, ArtifactItem, LinkItem},
|
templates::{self, ArtifactItem, LinkItem},
|
||||||
util::{self, ErrorJson, ResponseBuilderExt},
|
util::{self, ErrorJson, ResponseBuilderExt},
|
||||||
|
viewer::Viewers,
|
||||||
App,
|
App,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -46,6 +48,7 @@ struct AppInner {
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
cache: Cache,
|
cache: Cache,
|
||||||
api: ArtifactApi,
|
api: ArtifactApi,
|
||||||
|
viewers: Viewers,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
|
@ -54,13 +57,22 @@ impl Default for App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Default, Deserialize)]
|
||||||
struct UrlForm {
|
struct FileQparams {
|
||||||
url: String,
|
viewer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAVICON_PATH: &str = "/favicon.ico";
|
const FAVICON_PATH: &str = "/favicon.ico";
|
||||||
|
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
// Stylesheets are saved with immutable cache header. If they are changed in the future,
|
||||||
|
// the number in the path should be incremented
|
||||||
|
pub(crate) const STYLE_MAIN_PATH: &str = "/style1.css";
|
||||||
|
pub(crate) const STYLE_CONTENT_PATH: &str = "/content1.css";
|
||||||
|
|
||||||
const FAVICON_BYTES: &[u8; 268] = include_bytes!("../resources/favicon.ico");
|
const FAVICON_BYTES: &[u8; 268] = include_bytes!("../resources/favicon.ico");
|
||||||
|
const STYLE_MAIN_BYTES: &[u8; 4057] = include_bytes!("../resources/style.css");
|
||||||
|
const STYLE_CONTENT_BYTES: &[u8; 10063] = include_bytes!("../resources/content.css");
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
@ -72,11 +84,16 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(&self) -> Result<(), Error> {
|
pub async fn run(&self) -> Result<(), Error> {
|
||||||
let address = "0.0.0.0:3000";
|
|
||||||
let listener = tokio::net::TcpListener::bind(address).await?;
|
|
||||||
tracing::info!("Listening on http://{address}");
|
|
||||||
|
|
||||||
let state = self.new_state()?;
|
let state = self.new_state()?;
|
||||||
|
|
||||||
|
let port = state.i.cfg.load().port;
|
||||||
|
let listener = tokio::net::TcpListener::bind(SocketAddr::new(
|
||||||
|
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||||
|
port,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
tracing::info!("Listening on port {port}");
|
||||||
|
|
||||||
let real_ip_header = state.i.cfg.load().real_ip_header.clone();
|
let real_ip_header = state.i.cfg.load().real_ip_header.clone();
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
// Prevent search indexing since artifactview serves temporary artifacts
|
// Prevent search indexing since artifactview serves temporary artifacts
|
||||||
|
@ -93,7 +110,6 @@ impl App {
|
||||||
.route("/.well-known/*path", any(|| async { Error::Inaccessible }))
|
.route("/.well-known/*path", any(|| async { Error::Inaccessible }))
|
||||||
// Serve artifact pages
|
// Serve artifact pages
|
||||||
.route("/", get(Self::get_page))
|
.route("/", get(Self::get_page))
|
||||||
.route("/", post(Self::post_homepage))
|
|
||||||
.fallback(get(Self::get_page))
|
.fallback(get(Self::get_page))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
// Log requests
|
// Log requests
|
||||||
|
@ -123,26 +139,14 @@ impl App {
|
||||||
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
||||||
|
|
||||||
if subdomain.is_empty() {
|
if subdomain.is_empty() {
|
||||||
// Main page
|
Self::get_homepage(state, uri).await
|
||||||
if uri.path() == FAVICON_PATH {
|
|
||||||
return Self::favicon();
|
|
||||||
}
|
|
||||||
if uri.path() != "/" {
|
|
||||||
return Err(Error::NotFound("path".into()));
|
|
||||||
}
|
|
||||||
Ok(Response::builder()
|
|
||||||
.typed_header(headers::ContentType::html())
|
|
||||||
.cache()
|
|
||||||
.body(templates::Index::default().to_string().into())?)
|
|
||||||
} else {
|
} else {
|
||||||
let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
||||||
state.i.cfg.check_filterlist(&query)?;
|
state.i.cfg.check_filterlist(&query)?;
|
||||||
let path = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy();
|
let path = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy();
|
||||||
let hdrs = request.headers();
|
let hdrs = request.headers();
|
||||||
let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?;
|
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_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
|
||||||
let entry = entry_res.entry;
|
let entry = entry_res.entry;
|
||||||
if entry_res.downloaded {
|
if entry_res.downloaded {
|
||||||
|
@ -151,52 +155,66 @@ 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(GetFileResult::File(res)) => {
|
||||||
Self::serve_artifact_file(state, entry, entry_res.zip_path, res, hdrs)
|
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
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => return Ok(resp),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("{e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs).await
|
||||||
}
|
}
|
||||||
Ok(GetFileResult::Listing(listing)) => {
|
Ok(GetFileResult::Listing(listing)) => {
|
||||||
if !path.ends_with('/') {
|
if !path.ends_with('/') {
|
||||||
return Ok(Redirect::to(&format!("{path}/")).into_response());
|
return Ok(Redirect::to(&format!("{path}/")).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut path_components = vec![
|
let run_url = query.forge_url();
|
||||||
LinkItem {
|
|
||||||
name: query.shortid(),
|
|
||||||
url: state
|
|
||||||
.i
|
|
||||||
.cfg
|
|
||||||
.url_with_subdomain(&query.subdomain_with_artifact(None)?),
|
|
||||||
},
|
|
||||||
LinkItem {
|
|
||||||
name: entry.name.to_owned(),
|
|
||||||
url: "/".to_string(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let mut buf = String::new();
|
|
||||||
for s in path.split('/').filter(|s| !s.is_empty()) {
|
|
||||||
buf.push('/');
|
|
||||||
buf += s;
|
|
||||||
path_components.push(LinkItem {
|
|
||||||
name: s.to_owned(),
|
|
||||||
url: buf.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmpl = templates::Listing {
|
let tmpl = templates::Listing {
|
||||||
main_url: state.i.cfg.main_url(),
|
main_url: state.i.cfg.main_url(),
|
||||||
version: templates::Version,
|
run_url: &run_url,
|
||||||
run_url: &query.forge_url(),
|
|
||||||
artifact_name: &entry.name,
|
artifact_name: &entry.name,
|
||||||
path_components,
|
path_components: path_components(
|
||||||
|
&query,
|
||||||
|
state.i.cfg.main_url(),
|
||||||
|
&run_url,
|
||||||
|
&entry.name,
|
||||||
|
&path,
|
||||||
|
),
|
||||||
n_dirs: listing.n_dirs,
|
n_dirs: listing.n_dirs,
|
||||||
n_files: listing.n_files,
|
n_files: listing.n_files,
|
||||||
has_parent: listing.has_parent,
|
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,
|
entries: listing.entries,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.typed_header(headers::ContentType::html())
|
.typed_header(headers::ContentType::html())
|
||||||
.cache_immutable()
|
.cache()
|
||||||
.body(tmpl.to_string().into())?)
|
.body(tmpl.to_string().into())?)
|
||||||
}
|
}
|
||||||
Err(Error::NotFound(e)) => {
|
Err(Error::NotFound(e)) => {
|
||||||
|
@ -209,67 +227,140 @@ impl App {
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Query::Run(query) => {
|
}
|
||||||
let artifacts = state.i.api.list(&query).await?;
|
|
||||||
|
|
||||||
|
async fn get_homepage(state: AppState, uri: Uri) -> Result<Response<Body>, Error> {
|
||||||
if uri.path() == FAVICON_PATH {
|
if uri.path() == FAVICON_PATH {
|
||||||
return Self::favicon();
|
return Self::favicon();
|
||||||
}
|
}
|
||||||
|
if uri.path() == STYLE_MAIN_PATH {
|
||||||
|
return Self::stylesheet(STYLE_MAIN_BYTES.as_slice());
|
||||||
|
}
|
||||||
|
if uri.path() == STYLE_CONTENT_PATH {
|
||||||
|
return Self::stylesheet(STYLE_CONTENT_BYTES.as_slice());
|
||||||
|
}
|
||||||
if uri.path() != "/" {
|
if uri.path() != "/" {
|
||||||
return Err(Error::NotFound("path".into()));
|
return Err(Error::NotFound("path".into()));
|
||||||
}
|
}
|
||||||
if artifacts.is_empty() {
|
|
||||||
return Err(Error::NotFound("artifacts".into()));
|
#[derive(Deserialize)]
|
||||||
|
struct Params {
|
||||||
|
url: String,
|
||||||
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(params) = uri
|
||||||
|
.query()
|
||||||
|
.and_then(|q| serde_urlencoded::from_str::<Params>(q).ok())
|
||||||
|
{
|
||||||
|
let query = RunQuery::from_forge_url(¶ms.url, &state.i.cfg.load().site_aliases)?;
|
||||||
|
let artifacts = state.i.api.list(&query).await?;
|
||||||
|
|
||||||
|
if artifacts.is_empty() {
|
||||||
|
Err(Error::NotFound("artifacts".into()))
|
||||||
|
} else if let Some(artifact) = params
|
||||||
|
.name
|
||||||
|
.and_then(|n| artifacts.iter().find(|a| a.name == n))
|
||||||
|
{
|
||||||
|
Ok(Redirect::to(
|
||||||
|
&state
|
||||||
|
.i
|
||||||
|
.cfg
|
||||||
|
.url_with_subdomain(&query.subdomain_with_artifact(artifact.id)),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
let tmpl = templates::Selection {
|
let tmpl = templates::Selection {
|
||||||
main_url: state.i.cfg.main_url(),
|
main_url: state.i.cfg.main_url(),
|
||||||
version: templates::Version,
|
|
||||||
run_url: &query.forge_url(),
|
run_url: &query.forge_url(),
|
||||||
run_name: &query.shortid(),
|
run_name: &query.shortid(),
|
||||||
publisher: LinkItem {
|
publisher: query.publisher(),
|
||||||
name: query.user.to_owned(),
|
|
||||||
url: format!("https://{}/{}", query.host, query.user),
|
|
||||||
},
|
|
||||||
artifacts: artifacts
|
artifacts: artifacts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg))
|
.map(|a| ArtifactItem::from_artifact(a, query.as_ref(), &state.i.cfg))
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Vec<_>>(),
|
||||||
};
|
};
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.typed_header(headers::ContentType::html())
|
.typed_header(headers::ContentType::html())
|
||||||
.cache()
|
.cache()
|
||||||
.body(tmpl.to_string().into())?)
|
.body(tmpl.to_string().into())?)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_homepage(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Host(host): Host,
|
|
||||||
Form(url): Form<UrlForm>,
|
|
||||||
) -> Result<Redirect, Error> {
|
|
||||||
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
|
||||||
|
|
||||||
if subdomain.is_empty() {
|
|
||||||
let query = Query::from_forge_url(&url.url, &state.i.cfg.load().site_aliases)?;
|
|
||||||
let subdomain = query.subdomain()?;
|
|
||||||
let target = format!(
|
|
||||||
"{}{}.{}",
|
|
||||||
state.i.cfg.url_proto(),
|
|
||||||
subdomain,
|
|
||||||
state.i.cfg.load().root_domain
|
|
||||||
);
|
|
||||||
Ok(Redirect::to(&target))
|
|
||||||
} else {
|
} else {
|
||||||
Err(Error::MethodNotAllowed)
|
Ok(Response::builder()
|
||||||
|
.typed_header(headers::ContentType::html())
|
||||||
|
.cache()
|
||||||
|
.body(
|
||||||
|
templates::Index {
|
||||||
|
main_url: state.i.cfg.main_url(),
|
||||||
}
|
}
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_view_file(
|
||||||
|
state: &AppState,
|
||||||
|
entry: &Arc<CacheEntry>,
|
||||||
|
zip_path: &Path,
|
||||||
|
query: &ArtifactQuery,
|
||||||
|
res: &GetFileResultFile,
|
||||||
|
viewer: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Response<Body>, Error> {
|
||||||
|
let file = &res.file;
|
||||||
|
let filename = res.filename.as_deref().unwrap_or_default();
|
||||||
|
|
||||||
|
// Dont try to view files above the configured size limit
|
||||||
|
let lim = state.i.cfg.load().viewer_max_size;
|
||||||
|
if lim.is_some_and(|lim| file.uncompressed_size > lim.into()) {
|
||||||
|
return Err(Error::ViewerNotApplicable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read decompressed file
|
||||||
|
let zip_file = File::open(&zip_path).await?;
|
||||||
|
let mut zip_reader = BufReader::new(zip_file);
|
||||||
|
util::seek_to_data_offset(&mut zip_reader, file.header_offset.into()).await?;
|
||||||
|
let mut reader = ZipEntryReader::new_with_owned(
|
||||||
|
zip_reader.compat(),
|
||||||
|
file.compression,
|
||||||
|
file.compressed_size.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut contents = String::new();
|
||||||
|
reader.read_to_string(&mut contents).await?;
|
||||||
|
|
||||||
|
let render_res = state.i.viewers.try_render(filename, viewer, &contents)?;
|
||||||
|
let run_url = query.forge_url();
|
||||||
|
|
||||||
|
let tmpl = templates::Preview {
|
||||||
|
main_url: state.i.cfg.main_url(),
|
||||||
|
run_url: &run_url,
|
||||||
|
filename,
|
||||||
|
path_components: path_components(
|
||||||
|
query,
|
||||||
|
state.i.cfg.main_url(),
|
||||||
|
&run_url,
|
||||||
|
&entry.name,
|
||||||
|
path.rsplit_once('/').map(|x| x.0).unwrap_or_default(),
|
||||||
|
),
|
||||||
|
publisher: query.publisher(),
|
||||||
|
lines: contents.lines().count(),
|
||||||
|
size: file.uncompressed_size.into(),
|
||||||
|
viewers: render_res.tmpl_viewers,
|
||||||
|
body: &render_res.html,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.typed_header(ContentType::html())
|
||||||
|
.typed_header(headers::LastModified::from(entry.last_modified))
|
||||||
|
.body(tmpl.to_string().into())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_artifact_file(
|
async fn serve_artifact_file(
|
||||||
state: AppState,
|
state: &AppState,
|
||||||
entry: Arc<CacheEntry>,
|
entry: Arc<CacheEntry>,
|
||||||
zip_path: PathBuf,
|
zip_path: &Path,
|
||||||
res: GetFileResultFile,
|
res: GetFileResultFile,
|
||||||
hdrs: &HeaderMap,
|
hdrs: &HeaderMap,
|
||||||
) -> Result<Response<Body>, Error> {
|
) -> Result<Response<Body>, Error> {
|
||||||
|
@ -393,9 +484,9 @@ impl App {
|
||||||
Host(host): Host,
|
Host(host): Host,
|
||||||
) -> Result<Response<Body>, ErrorJson> {
|
) -> Result<Response<Body>, ErrorJson> {
|
||||||
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
||||||
let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
||||||
state.i.cfg.check_filterlist(&query)?;
|
state.i.cfg.check_filterlist(&query)?;
|
||||||
let artifacts = state.i.api.list(&query.into_runquery()).await?;
|
let artifacts = state.i.api.list(&query.into()).await?;
|
||||||
Ok(Response::builder().cache().json(&artifacts)?)
|
Ok(Response::builder().cache().json(&artifacts)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -405,9 +496,9 @@ impl App {
|
||||||
Host(host): Host,
|
Host(host): Host,
|
||||||
) -> Result<Response<Body>, ErrorJson> {
|
) -> Result<Response<Body>, ErrorJson> {
|
||||||
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
||||||
let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
||||||
state.i.cfg.check_filterlist(&query)?;
|
state.i.cfg.check_filterlist(&query)?;
|
||||||
let artifact = state.i.api.fetch(&query.try_into_artifactquery()?).await?;
|
let artifact = state.i.api.fetch(&query).await?;
|
||||||
Ok(Response::builder().cache().json(&artifact)?)
|
Ok(Response::builder().cache().json(&artifact)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -419,13 +510,9 @@ impl App {
|
||||||
) -> Result<Response<Body>, ErrorJson> {
|
) -> Result<Response<Body>, ErrorJson> {
|
||||||
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
||||||
let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?;
|
let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?;
|
||||||
let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
|
||||||
state.i.cfg.check_filterlist(&query)?;
|
state.i.cfg.check_filterlist(&query)?;
|
||||||
let entry_res = state
|
let entry_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
|
||||||
.i
|
|
||||||
.cache
|
|
||||||
.get_entry(&state.i.api, &query.try_into_artifactquery()?, &ip)
|
|
||||||
.await?;
|
|
||||||
if entry_res.downloaded {
|
if entry_res.downloaded {
|
||||||
state.garbage_collect();
|
state.garbage_collect();
|
||||||
}
|
}
|
||||||
|
@ -442,6 +529,13 @@ impl App {
|
||||||
.cache_immutable()
|
.cache_immutable()
|
||||||
.body(FAVICON_BYTES.as_slice().into())?)
|
.body(FAVICON_BYTES.as_slice().into())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stylesheet(content: &'static [u8]) -> Result<Response<Body>, Error> {
|
||||||
|
Ok(Response::builder()
|
||||||
|
.typed_header(headers::ContentType::from(mime::TEXT_CSS))
|
||||||
|
.cache_immutable()
|
||||||
|
.body(content.into())?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
@ -450,7 +544,12 @@ impl AppState {
|
||||||
let cache = Cache::new(cfg.clone());
|
let cache = Cache::new(cfg.clone());
|
||||||
let api = ArtifactApi::new(cfg.clone());
|
let api = ArtifactApi::new(cfg.clone());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
i: Arc::new(AppInner { cfg, cache, api }),
|
i: Arc::new(AppInner {
|
||||||
|
cfg,
|
||||||
|
cache,
|
||||||
|
api,
|
||||||
|
viewers: Viewers::new(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -464,3 +563,32 @@ impl AppState {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn path_components(
|
||||||
|
query: &ArtifactQuery,
|
||||||
|
main_url: &str,
|
||||||
|
run_url: &str,
|
||||||
|
entry_name: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Vec<LinkItem> {
|
||||||
|
let mut path_components = vec![
|
||||||
|
LinkItem {
|
||||||
|
name: query.shortid(),
|
||||||
|
url: format!("{}/?url={}", main_url, run_url),
|
||||||
|
},
|
||||||
|
LinkItem {
|
||||||
|
name: entry_name.to_owned(),
|
||||||
|
url: "/".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut buf = String::new();
|
||||||
|
for s in path.split('/').filter(|s| !s.is_empty()) {
|
||||||
|
buf.push('/');
|
||||||
|
buf += s;
|
||||||
|
path_components.push(LinkItem {
|
||||||
|
name: s.to_owned(),
|
||||||
|
url: buf.clone() + "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
path_components
|
||||||
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ use tokio::{fs::File, io::AsyncWriteExt};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
query::{ArtifactQuery, QueryData},
|
query::{ArtifactQuery, Query, QueryRef, RunQuery},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ArtifactApi {
|
pub struct ArtifactApi {
|
||||||
|
@ -69,7 +69,7 @@ enum ForgejoArtifactStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GithubArtifact {
|
impl GithubArtifact {
|
||||||
fn into_artifact<T>(self, query: &QueryData<T>) -> Artifact {
|
fn into_artifact(self, query: QueryRef<'_>) -> Artifact {
|
||||||
Artifact {
|
Artifact {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
name: self.name,
|
name: self.name,
|
||||||
|
@ -85,7 +85,7 @@ impl GithubArtifact {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ForgejoArtifact {
|
impl ForgejoArtifact {
|
||||||
fn into_artifact<T>(self, id: u64, query: &QueryData<T>) -> Artifact {
|
fn into_artifact(self, id: u64, query: QueryRef<'_>) -> Artifact {
|
||||||
Artifact {
|
Artifact {
|
||||||
download_url: format!(
|
download_url: format!(
|
||||||
"https://{}/{}/{}/actions/runs/{}/artifacts/{}",
|
"https://{}/{}/{}/actions/runs/{}/artifacts/{}",
|
||||||
|
@ -116,14 +116,14 @@ impl ArtifactApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
|
pub async fn list(&self, query: &RunQuery) -> Result<Vec<Artifact>> {
|
||||||
let subdomain = query.subdomain_with_artifact(None)?;
|
let cache_key = query.cache_key();
|
||||||
self.qc
|
self.qc
|
||||||
.get_or_insert_async(&subdomain, async {
|
.get_or_insert_async(&cache_key, async {
|
||||||
if query.is_github() {
|
if query.is_github() {
|
||||||
self.list_github(query).await
|
self.list_github(query.as_ref()).await
|
||||||
} else {
|
} else {
|
||||||
self.list_forgejo(query).await
|
self.list_forgejo(query.as_ref()).await
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
@ -134,7 +134,7 @@ impl ArtifactApi {
|
||||||
self.fetch_github(query).await
|
self.fetch_github(query).await
|
||||||
} else {
|
} else {
|
||||||
// Forgejo currently has no API for fetching single artifacts
|
// Forgejo currently has no API for fetching single artifacts
|
||||||
let mut artifacts = self.list_forgejo(query).await?;
|
let mut artifacts = self.list_forgejo(query.as_ref()).await?;
|
||||||
|
|
||||||
let i = usize::try_from(query.artifact)?;
|
let i = usize::try_from(query.artifact)?;
|
||||||
if i == 0 || i > artifacts.len() {
|
if i == 0 || i > artifacts.len() {
|
||||||
|
@ -200,7 +200,7 @@ impl ArtifactApi {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_forgejo<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
|
async fn list_forgejo(&self, query: QueryRef<'_>) -> Result<Vec<Artifact>> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://{}/{}/{}/actions/runs/{}/artifacts",
|
"https://{}/{}/{}/actions/runs/{}/artifacts",
|
||||||
query.host, query.user, query.repo, query.run
|
query.host, query.user, query.repo, query.run
|
||||||
|
@ -225,7 +225,7 @@ impl ArtifactApi {
|
||||||
Ok(artifacts)
|
Ok(artifacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_github<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
|
async fn list_github(&self, query: QueryRef<'_>) -> Result<Vec<Artifact>> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts",
|
"https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts",
|
||||||
query.user, query.repo, query.run
|
query.user, query.repo, query.run
|
||||||
|
@ -253,7 +253,7 @@ impl ArtifactApi {
|
||||||
.await?
|
.await?
|
||||||
.json::<GithubArtifact>()
|
.json::<GithubArtifact>()
|
||||||
.await?;
|
.await?;
|
||||||
Ok(artifact.into_artifact(query))
|
Ok(artifact.into_artifact(query.as_ref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_github_error(resp: Response) -> Result<Response> {
|
async fn handle_github_error(resp: Response) -> Result<Response> {
|
||||||
|
@ -281,20 +281,19 @@ impl ArtifactApi {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{config::Config, query::ArtifactQuery};
|
use crate::{config::Config, query::ArtifactQuery};
|
||||||
|
|
||||||
use super::ArtifactApi;
|
use super::ArtifactApi;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fetch_forgejo() {
|
async fn fetch_forgejo() {
|
||||||
let query = ArtifactQuery {
|
let query = ArtifactQuery::from_subdomain(
|
||||||
host: "code.thetadev.de".to_owned(),
|
"code-thetadev-de--hsa--visitenbuch--32-1",
|
||||||
host_alias: None,
|
&HashMap::new(),
|
||||||
user: "HSA".to_owned(),
|
)
|
||||||
repo: "Visitenbuch".to_owned(),
|
.unwrap();
|
||||||
run: 32,
|
|
||||||
artifact: 1,
|
|
||||||
};
|
|
||||||
let api = ArtifactApi::new(Config::default());
|
let api = ArtifactApi::new(Config::default());
|
||||||
let res = api.fetch(&query).await.unwrap();
|
let res = api.fetch(&query).await.unwrap();
|
||||||
|
|
||||||
|
@ -304,14 +303,11 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fetch_github() {
|
async fn fetch_github() {
|
||||||
let query = ArtifactQuery {
|
let query = ArtifactQuery::from_subdomain(
|
||||||
host: "github.com".to_owned(),
|
"github-com--actions--upload-artifact--8805345396-1440556464",
|
||||||
host_alias: None,
|
&HashMap::new(),
|
||||||
user: "actions".to_owned(),
|
)
|
||||||
repo: "upload-artifact".to_owned(),
|
.unwrap();
|
||||||
run: 8805345396,
|
|
||||||
artifact: 1440556464,
|
|
||||||
};
|
|
||||||
let api = ArtifactApi::new(Config::default());
|
let api = ArtifactApi::new(Config::default());
|
||||||
let res = api.fetch(&query).await.unwrap();
|
let res = api.fetch(&query).await.unwrap();
|
||||||
|
|
||||||
|
|
16
src/cache.rs
16
src/cache.rs
|
@ -65,6 +65,7 @@ pub enum GetFileResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetFileResultFile {
|
pub struct GetFileResultFile {
|
||||||
|
pub filename: Option<String>,
|
||||||
pub file: FileEntry,
|
pub file: FileEntry,
|
||||||
pub mime: Option<Mime>,
|
pub mime: Option<Mime>,
|
||||||
pub status: StatusCode,
|
pub status: StatusCode,
|
||||||
|
@ -114,7 +115,7 @@ impl Cache {
|
||||||
query: &ArtifactQuery,
|
query: &ArtifactQuery,
|
||||||
ip: &IpAddr,
|
ip: &IpAddr,
|
||||||
) -> Result<GetEntryResult> {
|
) -> Result<GetEntryResult> {
|
||||||
let subdomain = query.subdomain_noalias();
|
let subdomain = query.cache_key();
|
||||||
let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip"));
|
let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip"));
|
||||||
let downloaded = !zip_path.is_file();
|
let downloaded = !zip_path.is_file();
|
||||||
if downloaded {
|
if downloaded {
|
||||||
|
@ -280,6 +281,7 @@ impl CacheEntry {
|
||||||
// 2. Site path + `/index.html`
|
// 2. Site path + `/index.html`
|
||||||
else if let Some(file) = self.files.get(path) {
|
else if let Some(file) = self.files.get(path) {
|
||||||
return Ok(GetFileResult::File(GetFileResultFile {
|
return Ok(GetFileResult::File(GetFileResultFile {
|
||||||
|
filename: path.rsplit('/').next().map(str::to_owned),
|
||||||
file: file.clone(),
|
file: file.clone(),
|
||||||
mime: util::path_mime(path),
|
mime: util::path_mime(path),
|
||||||
status: StatusCode::OK,
|
status: StatusCode::OK,
|
||||||
|
@ -294,6 +296,7 @@ impl CacheEntry {
|
||||||
{
|
{
|
||||||
// index.html or SPA entrypoint
|
// index.html or SPA entrypoint
|
||||||
return Ok(GetFileResult::File(GetFileResultFile {
|
return Ok(GetFileResult::File(GetFileResultFile {
|
||||||
|
filename: None,
|
||||||
file: file.clone(),
|
file: file.clone(),
|
||||||
mime: Some(mime::TEXT_HTML),
|
mime: Some(mime::TEXT_HTML),
|
||||||
status: StatusCode::OK,
|
status: StatusCode::OK,
|
||||||
|
@ -328,6 +331,7 @@ impl CacheEntry {
|
||||||
} else if let Some(file) = self.files.get("404.html") {
|
} else if let Some(file) = self.files.get("404.html") {
|
||||||
// Custom 404 error page
|
// Custom 404 error page
|
||||||
return Ok(GetFileResult::File(GetFileResultFile {
|
return Ok(GetFileResult::File(GetFileResultFile {
|
||||||
|
filename: None,
|
||||||
file: file.clone(),
|
file: file.clone(),
|
||||||
mime: Some(mime::TEXT_HTML),
|
mime: Some(mime::TEXT_HTML),
|
||||||
status: StatusCode::NOT_FOUND,
|
status: StatusCode::NOT_FOUND,
|
||||||
|
@ -375,7 +379,7 @@ impl CacheEntry {
|
||||||
directories.push(ListingEntry {
|
directories.push(ListingEntry {
|
||||||
name: n.to_owned(),
|
name: n.to_owned(),
|
||||||
url: format!("{n}{path}"),
|
url: format!("{n}{path}"),
|
||||||
size: Size(0),
|
size: 0.into(),
|
||||||
crc32: "-".to_string(),
|
crc32: "-".to_string(),
|
||||||
is_dir: true,
|
is_dir: true,
|
||||||
});
|
});
|
||||||
|
@ -383,7 +387,7 @@ impl CacheEntry {
|
||||||
files.push(ListingEntry {
|
files.push(ListingEntry {
|
||||||
name: n.to_owned(),
|
name: n.to_owned(),
|
||||||
url: format!("{n}{path}"),
|
url: format!("{n}{path}"),
|
||||||
size: Size(entry.uncompressed_size),
|
size: entry.uncompressed_size.into(),
|
||||||
crc32: hex::encode(entry.crc32.to_le_bytes()),
|
crc32: hex::encode(entry.crc32.to_le_bytes()),
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
});
|
});
|
||||||
|
@ -411,3 +415,9 @@ impl CacheEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<u32> for Size {
|
||||||
|
fn from(value: u32) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
query::{Query, QueryFilterList},
|
query::{ArtifactQuery, QueryFilterList},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
@ -27,6 +27,8 @@ struct ConfigInner {
|
||||||
pub struct ConfigData {
|
pub struct ConfigData {
|
||||||
/// Folder where the downloaded artifacts are stored
|
/// Folder where the downloaded artifacts are stored
|
||||||
pub cache_dir: PathBuf,
|
pub cache_dir: PathBuf,
|
||||||
|
/// Port number of the web server
|
||||||
|
pub port: u16,
|
||||||
/// Root domain under which the server is available
|
/// Root domain under which the server is available
|
||||||
///
|
///
|
||||||
/// The individual artifacts are served under `<subdomain>.<root_domain>`
|
/// The individual artifacts are served under `<subdomain>.<root_domain>`
|
||||||
|
@ -65,12 +67,15 @@ pub struct ConfigData {
|
||||||
pub repo_whitelist: QueryFilterList,
|
pub repo_whitelist: QueryFilterList,
|
||||||
/// 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
|
||||||
|
pub viewer_max_size: Option<NonZeroU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigData {
|
impl Default for ConfigData {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cache_dir: Path::new("/tmp/artifactview").into(),
|
cache_dir: Path::new("/tmp/artifactview").into(),
|
||||||
|
port: 3000,
|
||||||
root_domain: "localhost:3000".to_string(),
|
root_domain: "localhost:3000".to_string(),
|
||||||
no_https: false,
|
no_https: false,
|
||||||
max_artifact_size: Some(NonZeroU32::new(100_000_000).unwrap()),
|
max_artifact_size: Some(NonZeroU32::new(100_000_000).unwrap()),
|
||||||
|
@ -85,6 +90,7 @@ impl Default for ConfigData {
|
||||||
repo_blacklist: QueryFilterList::default(),
|
repo_blacklist: QueryFilterList::default(),
|
||||||
repo_whitelist: QueryFilterList::default(),
|
repo_whitelist: QueryFilterList::default(),
|
||||||
site_aliases: HashMap::new(),
|
site_aliases: HashMap::new(),
|
||||||
|
viewer_max_size: Some(NonZeroU32::new(100_000).unwrap()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -148,7 +154,7 @@ impl Config {
|
||||||
&self.i.main_url
|
&self.i.main_url
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_filterlist(&self, query: &Query) -> 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()))
|
||||||
} else if !self.i.data.repo_whitelist.passes(query, false) {
|
} else if !self.i.data.repo_whitelist.passes(query, false) {
|
||||||
|
|
|
@ -41,6 +41,10 @@ pub enum Error {
|
||||||
MethodNotAllowed,
|
MethodNotAllowed,
|
||||||
#[error("You are fetching new artifacts too fast, please wait a minute and try again")]
|
#[error("You are fetching new artifacts too fast, please wait a minute and try again")]
|
||||||
Ratelimit,
|
Ratelimit,
|
||||||
|
#[error("viewer: {0}")]
|
||||||
|
Viewer(Cow<'static, str>),
|
||||||
|
#[error("viewer not applicable")]
|
||||||
|
ViewerNotApplicable,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for Error {
|
impl From<reqwest::Error> for Error {
|
||||||
|
|
|
@ -7,5 +7,6 @@ mod gzip_reader;
|
||||||
mod query;
|
mod query;
|
||||||
mod templates;
|
mod templates;
|
||||||
mod util;
|
mod util;
|
||||||
|
mod viewer;
|
||||||
|
|
||||||
pub struct App;
|
pub struct App;
|
||||||
|
|
292
src/query.rs
292
src/query.rs
|
@ -1,4 +1,4 @@
|
||||||
use std::{collections::HashMap, fmt::Write, str::FromStr};
|
use std::{collections::HashMap, str::FromStr};
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::{Captures, Regex};
|
use regex::{Captures, Regex};
|
||||||
|
@ -6,53 +6,113 @@ use serde::{de::Visitor, Deserialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
|
templates::LinkItem,
|
||||||
util,
|
util,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum Query {
|
pub struct ArtifactQuery {
|
||||||
Artifact(ArtifactQuery),
|
|
||||||
Run(RunQuery),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type RunQuery = QueryData<()>;
|
|
||||||
pub type ArtifactQuery = QueryData<u64>;
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub struct QueryData<T> {
|
|
||||||
/// Forge host
|
/// Forge host
|
||||||
pub host: String,
|
pub host: String,
|
||||||
/// Host alias if the query was constructed using one
|
/// Host alias if the query was constructed using one
|
||||||
pub host_alias: Option<String>,
|
host_alias: Option<String>,
|
||||||
/// User/org name (case-insensitive)
|
/// User/org name (case-insensitive)
|
||||||
pub user: String,
|
pub user: String,
|
||||||
/// Repository name (case-insensitive)
|
/// Repository name (case-insensitive)
|
||||||
pub repo: String,
|
pub repo: String,
|
||||||
/// CI run id
|
/// CI run id
|
||||||
pub run: u64,
|
pub run: u64,
|
||||||
// Optional selected artifact
|
/// CI artifact id
|
||||||
pub artifact: T,
|
pub artifact: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct RunQuery {
|
||||||
|
/// Forge host
|
||||||
|
pub host: String,
|
||||||
|
/// Host alias if the query was constructed using one
|
||||||
|
host_alias: Option<String>,
|
||||||
|
/// User/org name (case-insensitive)
|
||||||
|
pub user: String,
|
||||||
|
/// Repository name (case-insensitive)
|
||||||
|
pub repo: String,
|
||||||
|
/// CI run id
|
||||||
|
pub run: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub struct QueryRef<'a> {
|
||||||
|
/// Forge host
|
||||||
|
pub host: &'a str,
|
||||||
|
/// Host alias if the query was constructed using one
|
||||||
|
host_alias: Option<&'a str>,
|
||||||
|
/// User/org name (case-insensitive)
|
||||||
|
pub user: &'a str,
|
||||||
|
/// Repository name (case-insensitive)
|
||||||
|
pub repo: &'a str,
|
||||||
|
/// CI run id
|
||||||
|
pub run: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Query {
|
||||||
|
fn as_ref(&self) -> QueryRef<'_>;
|
||||||
|
|
||||||
|
fn shortid(&self) -> String {
|
||||||
|
let q = self.as_ref();
|
||||||
|
format!("{}/{}#{}", q.user, q.repo, q.run)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forge_url(&self) -> String {
|
||||||
|
let q = self.as_ref();
|
||||||
|
format!(
|
||||||
|
"https://{}/{}/{}/actions/runs/{}",
|
||||||
|
q.host, q.user, q.repo, q.run
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_github(&self) -> bool {
|
||||||
|
self.as_ref().host == "github.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subdomain_with_artifact(&self, artifact: u64) -> String {
|
||||||
|
let q = self.as_ref();
|
||||||
|
let host = q.host_alias.unwrap_or(q.host);
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{}--{}--{}--{}-{}",
|
||||||
|
encode_domain(host, '.'),
|
||||||
|
encode_domain(q.user, '-'),
|
||||||
|
encode_domain(q.repo, '-'),
|
||||||
|
q.run,
|
||||||
|
artifact,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publisher(&self) -> LinkItem {
|
||||||
|
let q = self.as_ref();
|
||||||
|
LinkItem {
|
||||||
|
name: q.user.to_owned(),
|
||||||
|
url: format!("https://{}/{}", q.host, q.user),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static RE_REPO_NAME: Lazy<Regex> = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap());
|
static RE_REPO_NAME: Lazy<Regex> = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap());
|
||||||
|
|
||||||
impl Query {
|
impl ArtifactQuery {
|
||||||
pub fn from_subdomain(subdomain: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
pub fn from_subdomain(subdomain: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
||||||
let segments = subdomain.split("--").collect::<Vec<_>>();
|
let segments = subdomain.split("--").collect::<Vec<_>>();
|
||||||
if segments.len() != 4 {
|
if segments.len() != 4 {
|
||||||
return Err(Error::InvalidUrl);
|
return Err(Error::InvalidUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let run_and_artifact = segments[3].split('-').collect::<Vec<_>>();
|
|
||||||
if run_and_artifact.is_empty() || run_and_artifact.len() > 2 {
|
|
||||||
return Err(Error::InvalidUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut host = decode_domain(segments[0], '.');
|
let mut host = decode_domain(segments[0], '.');
|
||||||
let mut host_alias = None;
|
let mut host_alias = None;
|
||||||
let user = decode_domain(segments[1], '-');
|
let user = decode_domain(segments[1], '-');
|
||||||
let repo = decode_domain(segments[2], '-');
|
let repo = decode_domain(segments[2], '-');
|
||||||
let run = run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?;
|
let run_and_artifact = segments[3].split_once('-').ok_or(Error::InvalidUrl)?;
|
||||||
|
let run = run_and_artifact.0.parse().ok().ok_or(Error::InvalidUrl)?;
|
||||||
|
let artifact = run_and_artifact.1.parse().ok().ok_or(Error::InvalidUrl)?;
|
||||||
|
|
||||||
#[allow(clippy::assigning_clones)]
|
#[allow(clippy::assigning_clones)]
|
||||||
if let Some(alias) = aliases.get(&host) {
|
if let Some(alias) = aliases.get(&host) {
|
||||||
|
@ -60,26 +120,29 @@ impl Query {
|
||||||
host = alias.clone();
|
host = alias.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(match run_and_artifact.get(1) {
|
Ok(ArtifactQuery {
|
||||||
Some(x) => Self::Artifact(QueryData {
|
|
||||||
host,
|
host,
|
||||||
host_alias,
|
host_alias,
|
||||||
user,
|
user,
|
||||||
repo,
|
repo,
|
||||||
run,
|
run,
|
||||||
artifact: x.parse().ok().ok_or(Error::InvalidUrl)?,
|
artifact,
|
||||||
}),
|
|
||||||
None => Self::Run(QueryData {
|
|
||||||
host,
|
|
||||||
host_alias,
|
|
||||||
user,
|
|
||||||
repo,
|
|
||||||
run,
|
|
||||||
artifact: (),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cache_key(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}--{}--{}--{}-{}",
|
||||||
|
encode_domain(&self.host, '.'),
|
||||||
|
encode_domain(&self.user, '-'),
|
||||||
|
encode_domain(&self.repo, '-'),
|
||||||
|
self.run,
|
||||||
|
self.artifact,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunQuery {
|
||||||
pub fn from_forge_url(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
pub fn from_forge_url(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
||||||
let (host, mut path_segs) = util::parse_url(url)?;
|
let (host, mut path_segs) = util::parse_url(url)?;
|
||||||
|
|
||||||
|
@ -104,118 +167,74 @@ impl Query {
|
||||||
return Err(Error::BadRequest("invalid repository name".into()));
|
return Err(Error::BadRequest("invalid repository name".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let host = aliases
|
let host_alias = aliases
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, v)| *v == host)
|
.find(|(_, v)| *v == host)
|
||||||
.map(|(k, _)| k.to_owned())
|
.map(|(k, _)| k.to_owned());
|
||||||
.unwrap_or_else(|| host.to_owned());
|
|
||||||
|
|
||||||
let run = path_segs
|
let run = path_segs
|
||||||
.next()
|
.next()
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
.ok_or(Error::BadRequest("no run ID".into()))?;
|
.ok_or(Error::BadRequest("no run ID".into()))?;
|
||||||
|
|
||||||
Ok(Self::Run(RunQuery {
|
Ok(Self {
|
||||||
host,
|
host: host.to_owned(),
|
||||||
host_alias: None,
|
host_alias,
|
||||||
user,
|
user,
|
||||||
repo,
|
repo,
|
||||||
run,
|
run,
|
||||||
artifact: (),
|
})
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subdomain(&self) -> Result<String> {
|
pub fn cache_key(&self) -> String {
|
||||||
match self {
|
format!(
|
||||||
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) -> Result<String> {
|
|
||||||
self.subdomain_with_artifact(Some(self.artifact))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-shortened subdomain (used for cache storage)
|
|
||||||
pub fn subdomain_noalias(&self) -> String {
|
|
||||||
self._subdomain(Some(self.artifact), false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RunQuery {
|
|
||||||
pub fn subdomain(&self) -> Result<String> {
|
|
||||||
self.subdomain_with_artifact(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> QueryData<T> {
|
|
||||||
pub fn _subdomain(&self, artifact: Option<u64>, use_alias: bool) -> String {
|
|
||||||
let host = if use_alias {
|
|
||||||
self.host_alias.as_deref().unwrap_or(&self.host)
|
|
||||||
} else {
|
|
||||||
&self.host
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut res = format!(
|
|
||||||
"{}--{}--{}--{}",
|
"{}--{}--{}--{}",
|
||||||
encode_domain(host, '.'),
|
encode_domain(&self.host, '.'),
|
||||||
encode_domain(&self.user, '-'),
|
encode_domain(&self.user, '-'),
|
||||||
encode_domain(&self.repo, '-'),
|
encode_domain(&self.repo, '-'),
|
||||||
self.run,
|
self.run,
|
||||||
);
|
|
||||||
if let Some(artifact) = artifact {
|
|
||||||
write!(res, "-{artifact}").unwrap();
|
|
||||||
}
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subdomain_with_artifact(&self, artifact: Option<u64>) -> Result<String> {
|
|
||||||
let res = self._subdomain(artifact, true);
|
|
||||||
if res.len() > 63 {
|
|
||||||
return Err(Error::BadRequest("subdomain too long".into()));
|
|
||||||
}
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shortid(&self) -> String {
|
|
||||||
format!("{}/{}#{}", self.user, self.repo, self.run)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn forge_url(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"https://{}/{}/{}/actions/runs/{}",
|
|
||||||
self.host, self.user, self.repo, self.run
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_github(&self) -> bool {
|
|
||||||
self.host == "github.com"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_runquery(self) -> RunQuery {
|
impl Query for ArtifactQuery {
|
||||||
RunQuery {
|
fn as_ref(&self) -> QueryRef<'_> {
|
||||||
host: self.host,
|
QueryRef {
|
||||||
host_alias: self.host_alias,
|
host: &self.host,
|
||||||
user: self.user,
|
host_alias: self.host_alias.as_deref(),
|
||||||
repo: self.repo,
|
user: &self.user,
|
||||||
|
repo: &self.repo,
|
||||||
run: self.run,
|
run: self.run,
|
||||||
artifact: (),
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Query for RunQuery {
|
||||||
|
fn as_ref(&self) -> QueryRef<'_> {
|
||||||
|
QueryRef {
|
||||||
|
host: &self.host,
|
||||||
|
host_alias: self.host_alias.as_deref(),
|
||||||
|
user: &self.user,
|
||||||
|
repo: &self.repo,
|
||||||
|
run: self.run,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Query for QueryRef<'_> {
|
||||||
|
fn as_ref(&self) -> QueryRef<'_> {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ArtifactQuery> for RunQuery {
|
||||||
|
fn from(value: ArtifactQuery) -> Self {
|
||||||
|
Self {
|
||||||
|
host: value.host,
|
||||||
|
host_alias: value.host_alias,
|
||||||
|
user: value.user,
|
||||||
|
repo: value.repo,
|
||||||
|
run: value.run,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -325,14 +344,11 @@ impl FromStr for QueryFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueryFilter {
|
impl QueryFilter {
|
||||||
pub fn passes(&self, query: &Query) -> bool {
|
pub fn passes<Q: Query>(&self, query: &Q) -> bool {
|
||||||
let (host, user, repo) = match query {
|
let q = query.as_ref();
|
||||||
Query::Artifact(q) => (&q.host, &q.user, &q.repo),
|
self.host == q.host
|
||||||
Query::Run(q) => (&q.host, &q.user, &q.repo),
|
&& self.user.as_deref().map(|u| u == q.user).unwrap_or(true)
|
||||||
};
|
&& self.repo.as_deref().map(|r| r == q.repo).unwrap_or(true)
|
||||||
&self.host == host
|
|
||||||
&& self.user.as_deref().map(|u| u == user).unwrap_or(true)
|
|
||||||
&& self.repo.as_deref().map(|r| r == repo).unwrap_or(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -349,7 +365,7 @@ impl FromStr for QueryFilterList {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueryFilterList {
|
impl QueryFilterList {
|
||||||
pub fn passes(&self, query: &Query, blacklist: bool) -> bool {
|
pub fn passes(&self, query: &ArtifactQuery, blacklist: bool) -> bool {
|
||||||
if self.0.is_empty() {
|
if self.0.is_empty() {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
@ -388,9 +404,9 @@ impl<'de> Deserialize<'de> for QueryFilterList {
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{collections::HashMap, str::FromStr};
|
use std::{collections::HashMap, str::FromStr};
|
||||||
|
|
||||||
use crate::query::{QueryFilter, QueryFilterList};
|
use crate::query::{Query, QueryFilter, QueryFilterList};
|
||||||
|
|
||||||
use super::{ArtifactQuery, Query};
|
use super::ArtifactQuery;
|
||||||
|
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
|
@ -426,19 +442,19 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn query_from_subdomain() {
|
fn query_from_subdomain() {
|
||||||
let d1 = "github-com--thetadev--newpipe-extractor--14-123";
|
let d1 = "github-com--thetadev--newpipe-extractor--14-123";
|
||||||
let query = Query::from_subdomain(d1, &HashMap::new()).unwrap();
|
let query = ArtifactQuery::from_subdomain(d1, &HashMap::new()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
query,
|
query,
|
||||||
Query::Artifact(ArtifactQuery {
|
ArtifactQuery {
|
||||||
host: "github.com".to_owned(),
|
host: "github.com".to_owned(),
|
||||||
host_alias: None,
|
host_alias: None,
|
||||||
user: "thetadev".to_owned(),
|
user: "thetadev".to_owned(),
|
||||||
repo: "newpipe-extractor".to_owned(),
|
repo: "newpipe-extractor".to_owned(),
|
||||||
run: 14,
|
run: 14,
|
||||||
artifact: 123
|
artifact: 123
|
||||||
})
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(query.subdomain().unwrap(), d1);
|
assert_eq!(query.subdomain_with_artifact(query.artifact), d1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
|
|
@ -2,18 +2,14 @@ use crate::{
|
||||||
artifact_api::Artifact,
|
artifact_api::Artifact,
|
||||||
cache::{ListingEntry, Size},
|
cache::{ListingEntry, Size},
|
||||||
config::Config,
|
config::Config,
|
||||||
error::Result,
|
query::{Query, QueryRef},
|
||||||
query::QueryData,
|
|
||||||
};
|
};
|
||||||
use yarte::{Render, Template};
|
use yarte::{Render, Template};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Template)]
|
||||||
pub struct Version;
|
|
||||||
|
|
||||||
#[derive(Template, Default)]
|
|
||||||
#[template(path = "index")]
|
#[template(path = "index")]
|
||||||
pub struct Index {
|
pub struct Index<'a> {
|
||||||
pub version: Version,
|
pub main_url: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
@ -27,7 +23,6 @@ pub struct Error<'a> {
|
||||||
#[template(path = "selection")]
|
#[template(path = "selection")]
|
||||||
pub struct Selection<'a> {
|
pub struct Selection<'a> {
|
||||||
pub main_url: &'a str,
|
pub main_url: &'a str,
|
||||||
pub version: Version,
|
|
||||||
pub run_url: &'a str,
|
pub run_url: &'a str,
|
||||||
pub run_name: &'a str,
|
pub run_name: &'a str,
|
||||||
pub publisher: LinkItem,
|
pub publisher: LinkItem,
|
||||||
|
@ -38,16 +33,37 @@ pub struct Selection<'a> {
|
||||||
#[template(path = "listing")]
|
#[template(path = "listing")]
|
||||||
pub struct Listing<'a> {
|
pub struct Listing<'a> {
|
||||||
pub main_url: &'a str,
|
pub main_url: &'a str,
|
||||||
pub version: Version,
|
|
||||||
pub run_url: &'a str,
|
pub run_url: &'a str,
|
||||||
pub artifact_name: &'a str,
|
pub artifact_name: &'a str,
|
||||||
pub path_components: Vec<LinkItem>,
|
pub path_components: Vec<LinkItem>,
|
||||||
pub n_dirs: usize,
|
pub n_dirs: usize,
|
||||||
pub n_files: usize,
|
pub n_files: usize,
|
||||||
pub has_parent: bool,
|
pub has_parent: bool,
|
||||||
|
pub publisher: LinkItem,
|
||||||
|
pub viewer_max_size: u32,
|
||||||
pub entries: Vec<ListingEntry>,
|
pub entries: Vec<ListingEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "preview")]
|
||||||
|
pub struct Preview<'a> {
|
||||||
|
pub main_url: &'a str,
|
||||||
|
pub run_url: &'a str,
|
||||||
|
pub filename: &'a str,
|
||||||
|
pub path_components: Vec<LinkItem>,
|
||||||
|
pub publisher: LinkItem,
|
||||||
|
pub lines: usize,
|
||||||
|
pub size: Size,
|
||||||
|
pub viewers: Vec<ViewerLink>,
|
||||||
|
pub body: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ViewerLink {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct LinkItem {
|
pub struct LinkItem {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
@ -62,25 +78,15 @@ pub struct ArtifactItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArtifactItem {
|
impl ArtifactItem {
|
||||||
pub fn from_artifact<T>(
|
pub fn from_artifact(artifact: Artifact, query: QueryRef<'_>, cfg: &Config) -> Self {
|
||||||
artifact: Artifact,
|
Self {
|
||||||
query: &QueryData<T>,
|
|
||||||
cfg: &Config,
|
|
||||||
) -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
name: artifact.name,
|
name: artifact.name,
|
||||||
url: cfg.url_with_subdomain(&query.subdomain_with_artifact(Some(artifact.id))?),
|
url: cfg.url_with_subdomain(&query.subdomain_with_artifact(artifact.id)),
|
||||||
size: Size(artifact.size as u32),
|
size: Size(artifact.size as u32),
|
||||||
expired: artifact.expired,
|
expired: artifact.expired,
|
||||||
download_url: artifact.user_download_url.unwrap_or(artifact.download_url),
|
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 {
|
impl Render for Size {
|
||||||
|
|
83
src/viewer/code.rs
Normal file
83
src/viewer/code.rs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use syntect::{
|
||||||
|
html::{ClassStyle, ClassedHTMLGenerator},
|
||||||
|
parsing::SyntaxSet,
|
||||||
|
util::LinesWithEndings,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::Viewer;
|
||||||
|
|
||||||
|
pub struct CodeViewer {
|
||||||
|
ss: Arc<SyntaxSet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeViewer {
|
||||||
|
pub fn new(ss: Arc<SyntaxSet>) -> Self {
|
||||||
|
Self { ss }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewer for CodeViewer {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"code"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Code"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_applicable(&self, _filename: &str, _ext: &str) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_render(&self, _filename: &str, ext: &str, data: &str) -> Result<String, Error> {
|
||||||
|
let syntax = self
|
||||||
|
.ss
|
||||||
|
.find_syntax_by_extension(ext)
|
||||||
|
.ok_or(Error::ViewerNotApplicable)?;
|
||||||
|
|
||||||
|
let mut html_generator =
|
||||||
|
ClassedHTMLGenerator::new_with_class_style(syntax, &self.ss, ClassStyle::Spaced);
|
||||||
|
LinesWithEndings::from(data)
|
||||||
|
.try_for_each(|line| html_generator.parse_html_for_line_which_includes_newline(line))
|
||||||
|
.map_err(|e| Error::Viewer(e.to_string().into()))?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"<pre><code>{}</code></pre>",
|
||||||
|
html_generator.finalize()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
// use super::*;
|
||||||
|
|
||||||
|
/*
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{BufReader, BufWriter, Write},
|
||||||
|
};
|
||||||
|
use syntect::{highlighting::ThemeSet, html::css_for_theme_with_class_style};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_stylesheet() {
|
||||||
|
// let ts = ThemeSet::load_defaults();
|
||||||
|
|
||||||
|
let mut f = BufReader::new(File::open("Monokai.tmTheme").unwrap());
|
||||||
|
let dark_theme = ThemeSet::load_from_reader(&mut f).unwrap();
|
||||||
|
|
||||||
|
// create dark color scheme css
|
||||||
|
// let dark_theme = &ts.themes["Solarized (dark)"];
|
||||||
|
let css_dark_file = File::create("theme-dark.css").unwrap();
|
||||||
|
let mut css_dark_writer = BufWriter::new(&css_dark_file);
|
||||||
|
|
||||||
|
let css_dark = css_for_theme_with_class_style(&dark_theme, ClassStyle::Spaced).unwrap();
|
||||||
|
writeln!(css_dark_writer, "{}", css_dark).unwrap();
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
103
src/viewer/markdown.rs
Normal file
103
src/viewer/markdown.rs
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
use std::{collections::HashMap, io::Write, sync::Arc};
|
||||||
|
|
||||||
|
use comrak::adapters::SyntaxHighlighterAdapter;
|
||||||
|
use syntect::{
|
||||||
|
html::{ClassStyle, ClassedHTMLGenerator},
|
||||||
|
parsing::SyntaxSet,
|
||||||
|
util::LinesWithEndings,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::Viewer;
|
||||||
|
|
||||||
|
pub struct MarkdownViewer {
|
||||||
|
adapter: SyntectAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownViewer {
|
||||||
|
pub fn new(ss: Arc<SyntaxSet>) -> Self {
|
||||||
|
Self {
|
||||||
|
adapter: SyntectAdapter { ss },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewer for MarkdownViewer {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"md"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Markdown"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_applicable(&self, _filename: &str, ext: &str) -> bool {
|
||||||
|
ext == "md"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_render(&self, _filename: &str, _ext: &str, data: &str) -> Result<String, Error> {
|
||||||
|
let options = comrak::Options::default();
|
||||||
|
let mut plugins = comrak::Plugins::default();
|
||||||
|
plugins.render.codefence_syntax_highlighter = Some(&self.adapter);
|
||||||
|
|
||||||
|
let html = comrak::markdown_to_html_with_plugins(data, &options, &plugins);
|
||||||
|
|
||||||
|
Ok(format!("<div class=\"markup\">{html}</div>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyntectAdapter {
|
||||||
|
ss: Arc<SyntaxSet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyntaxHighlighterAdapter for SyntectAdapter {
|
||||||
|
fn write_highlighted(
|
||||||
|
&self,
|
||||||
|
output: &mut dyn Write,
|
||||||
|
lang: Option<&str>,
|
||||||
|
code: &str,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let fallback_syntax = "Plain Text";
|
||||||
|
|
||||||
|
let lang: &str = match lang {
|
||||||
|
Some(l) if !l.is_empty() => l,
|
||||||
|
_ => fallback_syntax,
|
||||||
|
};
|
||||||
|
|
||||||
|
let syntax = self.ss.find_syntax_by_token(lang).unwrap_or_else(|| {
|
||||||
|
self.ss
|
||||||
|
.find_syntax_by_first_line(code)
|
||||||
|
.unwrap_or_else(|| self.ss.find_syntax_plain_text())
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut html_generator =
|
||||||
|
ClassedHTMLGenerator::new_with_class_style(syntax, &self.ss, ClassStyle::Spaced);
|
||||||
|
|
||||||
|
if let Err(e) = LinesWithEndings::from(code)
|
||||||
|
.try_for_each(|line| html_generator.parse_html_for_line_which_includes_newline(line))
|
||||||
|
{
|
||||||
|
tracing::error!("rendering md code: {e}");
|
||||||
|
return output.write_all(code.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = html_generator.finalize();
|
||||||
|
output.write_all(html.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_pre_tag(
|
||||||
|
&self,
|
||||||
|
output: &mut dyn Write,
|
||||||
|
_attributes: HashMap<String, String>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
output.write_all(b"<pre>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_code_tag(
|
||||||
|
&self,
|
||||||
|
output: &mut dyn Write,
|
||||||
|
_attributes: HashMap<String, String>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
output.write_all(b"<code>")
|
||||||
|
}
|
||||||
|
}
|
88
src/viewer/mod.rs
Normal file
88
src/viewer/mod.rs
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use syntect::parsing::SyntaxSet;
|
||||||
|
|
||||||
|
use crate::{error::Error, templates::ViewerLink};
|
||||||
|
|
||||||
|
mod code;
|
||||||
|
mod markdown;
|
||||||
|
|
||||||
|
pub trait Viewer: Sync + Send {
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn is_applicable(&self, filename: &str, ext: &str) -> bool;
|
||||||
|
fn try_render(&self, filename: &str, ext: &str, data: &str) -> Result<String, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Viewers {
|
||||||
|
viewers: [Box<dyn Viewer>; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RenderRes {
|
||||||
|
pub html: String,
|
||||||
|
pub tmpl_viewers: Vec<ViewerLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewers {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let ss = Arc::new(SyntaxSet::load_defaults_newlines());
|
||||||
|
Self {
|
||||||
|
viewers: [
|
||||||
|
Box::new(markdown::MarkdownViewer::new(ss.clone())),
|
||||||
|
Box::new(code::CodeViewer::new(ss)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_render(&self, filename: &str, viewer: &str, data: &str) -> Result<RenderRes, Error> {
|
||||||
|
let ext = filename.rsplit('.').next().unwrap();
|
||||||
|
|
||||||
|
if !viewer.is_empty() && viewer != "1" {
|
||||||
|
if let Some(viewer) = self.viewers.iter().find(|v| v.id() == viewer) {
|
||||||
|
if viewer.is_applicable(filename, ext) {
|
||||||
|
return viewer
|
||||||
|
.try_render(filename, ext, data)
|
||||||
|
.map(|html| RenderRes {
|
||||||
|
html,
|
||||||
|
tmpl_viewers: self.tmpl_viewers(viewer.id(), filename, ext),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return Err(Error::ViewerNotApplicable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for viewer in self
|
||||||
|
.viewers
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.is_applicable(filename, ext))
|
||||||
|
{
|
||||||
|
match viewer.try_render(filename, ext, data) {
|
||||||
|
Ok(html) => {
|
||||||
|
return Ok(RenderRes {
|
||||||
|
html,
|
||||||
|
tmpl_viewers: self.tmpl_viewers(viewer.id(), filename, ext),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(Error::ViewerNotApplicable) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("could not render {filename}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::ViewerNotApplicable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmpl_viewers(&self, viewer: &str, filename: &str, ext: &str) -> Vec<ViewerLink> {
|
||||||
|
self.viewers
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.is_applicable(filename, ext))
|
||||||
|
.map(|v| ViewerLink {
|
||||||
|
id: v.id(),
|
||||||
|
name: v.name(),
|
||||||
|
selected: v.id() == viewer,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<style>
|
<style>
|
||||||
* { padding: 0; margin: 0; --color-secondary: #dedede; --color-text: #000;
|
* { padding: 0; margin: 0; --color-text: #000; --color-text-light: #888; } body {
|
||||||
--color-text-light: #888; --color-border: #ccc; } body { font-family: sans-serif;
|
font-family: sans-serif; text-rendering: optimizespeed; background-color: #f5f5f5;
|
||||||
text-rendering: optimizespeed; background-color: #f5f5f5; color:
|
color: var(--color-text); } a { color: #006ed3; text-decoration: none; } a:hover {
|
||||||
var(--color-text); } a { color: #006ed3; text-decoration: none; } a:hover { color:
|
color: #319cff; } .card { display: flex; flex-direction: column; width: 90%;
|
||||||
#319cff; } .card { display: flex; flex-direction: column; width: 90%; max-width:
|
max-width: 500px; align-items: center; } .center { width: 100%; display: flex;
|
||||||
500px; align-items: center; } .input-row { display: flex; width: 100%; } .center {
|
flex-direction: row; justify-content: center; } .light { color:
|
||||||
width: 100%; display: flex; flex-direction: row; justify-content: center; } .light
|
var(--color-text-light); } p { margin: 16px 0; } header { gap: 1em; padding-top:
|
||||||
{ color: var(--color-text-light); } p { margin: 16px 0; } header { gap: 1em;
|
10px; padding-bottom: 10px; background-color: #f2f2f2; } footer { padding: 40px
|
||||||
padding-top: 10px; padding-bottom: 10px; background-color: #f2f2f2; } footer {
|
20px; font-size: 12px; text-align: center; } @media (prefers-color-scheme: dark) {
|
||||||
padding: 40px 20px; font-size: 12px; text-align: center; } @media
|
* { --color-text: #dddddd; } body { background-color: #101010; } header {
|
||||||
(prefers-color-scheme: dark) { * { --color-secondary: #082437; --color-border:
|
|
||||||
#212121; --color-text: #dddddd; } body { background-color: #101010; } header {
|
|
||||||
background-color: #151515; } }
|
background-color: #151515; } }
|
||||||
</style>
|
</style>
|
||||||
<title>Artifactview</title>
|
<title>Artifactview</title>
|
||||||
|
|
|
@ -1,28 +1,6 @@
|
||||||
<html lang="en">
|
{{#> partial/header ~}}
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<style>
|
|
||||||
* { 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>
|
<title>Artifactview</title>
|
||||||
</head>
|
{{~/partial/header }}
|
||||||
<body>
|
|
||||||
<header class="center">
|
<header class="center">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
@ -40,10 +18,12 @@
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p>Enter a GitHub/Gitea/Forgejo Actions run url to browse CI artifacts</p>
|
<p>Enter a GitHub/Gitea/Forgejo Actions run url to browse CI artifacts</p>
|
||||||
<form method="POST" class="input-row">
|
<form method="GET" class="input-row">
|
||||||
<input
|
<input
|
||||||
|
class="query-input"
|
||||||
name="url"
|
name="url"
|
||||||
type="text"
|
type="text"
|
||||||
|
required
|
||||||
placeholder="codeberg.org/username/repo/actions/runs/42"
|
placeholder="codeberg.org/username/repo/actions/runs/42"
|
||||||
style="flex-grow: 1"
|
style="flex-grow: 1"
|
||||||
/>
|
/>
|
||||||
|
@ -59,7 +39,7 @@
|
||||||
>
|
>
|
||||||
Artifactview
|
Artifactview
|
||||||
</a>
|
</a>
|
||||||
{{version}}
|
{{~crate::app::VERSION}}
|
||||||
<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
|
||||||
|
|
|
@ -1,112 +1,20 @@
|
||||||
<html>
|
{{#> partial/header ~}}
|
||||||
<head>
|
<title>Index: {{artifact_name}}</title>
|
||||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
{{~/partial/header }}
|
||||||
<meta name="viewport" content="width=device-width" />
|
{{> partial/fileIcons }}
|
||||||
<style type="text/css">
|
|
||||||
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
|
|
||||||
#000;--color-text-light: #888;}body {font-family: sans-serif;text-rendering:
|
|
||||||
optimizespeed;background-color: #f5f5f5;color: var(--color-text);}a {color:
|
|
||||||
#006ed3;text-decoration: none;}a:hover {color: #319cff;}#summary, header {padding:
|
|
||||||
0 20px;}header {display: flex;flex-direction: row;gap: 1em;padding-top:
|
|
||||||
25px;padding-bottom: 15px;background-color: #f2f2f2;}header h1 {font-size:
|
|
||||||
20px;font-weight: normal;white-space: nowrap;overflow-x: hidden;text-overflow:
|
|
||||||
ellipsis;color: #999;}header h1 a {color: var(--color-text);margin: 0 4px;}footer
|
|
||||||
a:hover, header h1 a:hover {text-decoration: underline;}header h1 a:first-child
|
|
||||||
{margin: 0;}main {display: block;}.meta {font-size: 12px;font-family: Verdana,
|
|
||||||
sans-serif;border-bottom: 1px solid #9c9c9c;padding-top: 10px;padding-bottom:
|
|
||||||
10px;}.meta-item {margin-right: 1em;}#filter {padding: 4px;border: 1px solid
|
|
||||||
#ccc;}#list {width: 100%;border-collapse: collapse;}#list tr {border-bottom: 1px
|
|
||||||
dashed #dadada;}#list tbody tr:hover {background-color: #ffffec;}#list td, #list
|
|
||||||
th {text-align: left;padding: 10px 0;}#list th {padding-top: 15px;padding-bottom:
|
|
||||||
15px;font-size: 16px;white-space: nowrap;}#list th a {color:
|
|
||||||
var(--color-text);}#list th svg {vertical-align: middle;}#list td {white-space:
|
|
||||||
nowrap;font-size: 14px;}#list td:nth-child(1), #list th:nth-child(1)
|
|
||||||
{padding-left: 20px;width: 80%;}#list td:nth-child(2), #list th:nth-child(2)
|
|
||||||
{text-align: right;padding: 0 20px;}#list td:nth-child(3), #list th:nth-child(3)
|
|
||||||
{text-align: right;padding-right: 20px;}#list td:nth-child(1) svg {position:
|
|
||||||
absolute;}#list td .goup, #list td .name {margin-left: 1.75em;word-break:
|
|
||||||
break-all;overflow-wrap: break-word;white-space: pre-wrap;}footer {padding: 40px
|
|
||||||
20px;font-size: 12px;text-align: center;}@media (max-width: 600px)
|
|
||||||
{td:nth-child(1) {width: auto;}td:nth-child(2), th:nth-child(2) {display: none;}h1
|
|
||||||
a {margin: 0;}#filter {max-width: 100px;}}@media (prefers-color-scheme: dark) {*
|
|
||||||
{--color-secondary: #082437;--color-text: #dddddd;}
|
|
||||||
body {background-color: #101010;}header {background-color:
|
|
||||||
#151515;}#list tbody tr:hover {background-color: #252525;}a {color:
|
|
||||||
#5796d1;text-decoration: none;}a:hover, h1 a:hover {color: #62b2fd;}#list tr
|
|
||||||
{border-bottom: 1px dashed rgba(255, 255, 255, 0.12);}#filter {background-color:
|
|
||||||
#151515;color: #ffffff;border: 1px solid #212121;}.meta {border-bottom: 1px solid
|
|
||||||
#212121;}}
|
|
||||||
</style>
|
|
||||||
<title>
|
|
||||||
Index:
|
|
||||||
{{artifact_name}}
|
|
||||||
</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body onload="initFilter()">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="0"
|
|
||||||
width="0"
|
|
||||||
style="position:absolute"
|
|
||||||
><defs><g id="folder" fill-rule="nonzero" fill="none"><path
|
|
||||||
d="M285.22 37.55h-142.6L110.9 0H31.7C14.25 0 0 16.9 0 37.55v75.1h316.92V75.1c0-20.65-14.26-37.55-31.7-37.55z"
|
|
||||||
fill="#FFA000"
|
|
||||||
/><path
|
|
||||||
d="M285.22 36H31.7C14.25 36 0 50.28 0 67.74v158.7c0 17.47 14.26 31.75 31.7 31.75h253.5c17.44 0 31.7-14.3 31.7-31.75V67.75c0-17.47-14.26-31.75-31.7-31.75z"
|
|
||||||
fill="#FFCA28"
|
|
||||||
/></g><g
|
|
||||||
id="file"
|
|
||||||
stroke="#000"
|
|
||||||
stroke-width="25"
|
|
||||||
fill="#FFF"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><path
|
|
||||||
d="M13 24.12v274.76c0 6.16 5.87 11.12 13.17 11.12H239c7.3 0 13.17-4.96 13.17-11.12V136.15S132.6 13 128.37 13H26.17C18.87 13 13 17.96 13 24.12z"
|
|
||||||
/><path
|
|
||||||
d="M129.37 13 129 113.9c0 10.58 7.26 19.1 16.27 19.1H249L129.37 13z"
|
|
||||||
/></g></defs></svg>
|
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
{{> partial/logoLink }}
|
||||||
<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>
|
<h1>
|
||||||
{{#each path_components}}<a href="{{this.url}}">{{this.name}}</a> /{{/each}}
|
{{#each path_components}}<a href="{{url}}">{{name}}</a>{{/each}}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<div class="meta">
|
<div class="metadata">
|
||||||
<div id="summary">
|
<div id="summary">
|
||||||
<span class="meta-item"><b>{{n_dirs}}</b> director{{#if n_dirs != 1}}ies{{else}}y{{/if}}</span>
|
<span class="meta-item"><b>{{n_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"><b>{{n_files}}</b> file{{#if n_files != 1}}s{{/if}}</span>
|
||||||
<span class="meta-item"><a
|
<span class="meta-item"><a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a></span>
|
||||||
href="{{run_url}}"
|
<span class="meta-item"><input type="text" placeholder="filter" id="filter" onkeyup="filter()"/></span>
|
||||||
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>
|
</div>
|
||||||
<div class="listing">
|
<div class="listing">
|
||||||
|
@ -130,40 +38,34 @@
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
{{ let vms = viewer_max_size }}
|
||||||
{{#each entries}}
|
{{#each entries}}
|
||||||
<tr class="file">
|
<tr class="file">
|
||||||
<td>
|
<td>
|
||||||
<a href="{{this.name}}">
|
<a href="{{name}}{{#if !is_dir && size.0 <= vms }}?viewer=1{{/if}}">
|
||||||
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 317 259"><use xlink:href="{{#if this.is_dir}}#folder{{else}}#file{{/if}}"></use></svg>
|
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 317 259"><use xlink:href="{{#if is_dir}}#folder{{else}}#file{{/if}}"></use></svg>
|
||||||
<span class="name">{{this.name}}</span>
|
<span class="name">{{name}}</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{#if this.is_dir}}—{{else}}{{this.size}}{{/if}}</td>
|
<td>{{#if is_dir}}—{{else}}{{size}}{{/if}}</td>
|
||||||
<td>{{#if this.is_dir}}—{{else}}{{this.crc32}}{{/if}}</td>
|
<td>{{#if is_dir}}—{{else}}{{crc32}}{{/if}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{{#> partial/footer ~}}
|
||||||
<footer>
|
|
||||||
Served with
|
|
||||||
<a
|
|
||||||
href="https://codeberg.org/ThetaDev/artifactview"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>Artifactview</a>
|
|
||||||
{{version}}
|
|
||||||
</footer>
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
// @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"}})}
|
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" } }) }
|
||||||
|
document.addEventListener("DOMContentLoaded", initFilter);
|
||||||
|
|
||||||
// @license-end
|
// @license-end
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
{{~/partial/footer }}
|
||||||
</html>
|
|
||||||
|
|
18
templates/partial/fileIcons.hbs
Normal file
18
templates/partial/fileIcons.hbs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<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>
|
After Width: | Height: | Size: 927 B |
13
templates/partial/footer.hbs
Normal file
13
templates/partial/footer.hbs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<footer>
|
||||||
|
Served with <a href="https://codeberg.org/ThetaDev/artifactview" target="_blank" rel="noopener noreferrer">Artifactview</a> {{ crate::app::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>
|
||||||
|
{{> @partial-block }}
|
||||||
|
</body>
|
||||||
|
</html>
|
10
templates/partial/header.hbs
Normal file
10
templates/partial/header.hbs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" href="{{main_url}}{{ crate::app::STYLE_MAIN_PATH }}">
|
||||||
|
{{> @partial-block }}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
1
templates/partial/logo.hbs
Normal file
1
templates/partial/logo.hbs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="{{size}}" height="{{size}}" 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>
|
After Width: | Height: | Size: 589 B |
3
templates/partial/logoLink.hbs
Normal file
3
templates/partial/logoLink.hbs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
||||||
|
{{> ./logo size="32" }}
|
||||||
|
</a>
|
32
templates/preview.hbs
Normal file
32
templates/preview.hbs
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{{#> partial/header ~}}
|
||||||
|
<link rel="stylesheet" href="{{main_url}}{{ crate::app::STYLE_CONTENT_PATH }}">
|
||||||
|
<title>{{filename}}</title>
|
||||||
|
{{~/partial/header }}
|
||||||
|
<header>
|
||||||
|
{{> partial/logoLink }}
|
||||||
|
<h1>
|
||||||
|
{{#each path_components}}<a href="{{url}}">{{name}}</a> /{{/each}}
|
||||||
|
<span>{{filename}}</span>
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="metadata">
|
||||||
|
<div id="summary">
|
||||||
|
<div style="flex-grow: 1;">
|
||||||
|
<span><b>{{lines}}</b> line{{#if lines != 1}}s{{/if}}</span>
|
||||||
|
<span>{{size}}</span>
|
||||||
|
<a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{#each viewers}}<a {{#if selected}}class="selected"{{/if}} href="?viewer={{id}}">{{name}}</a>{{/each}}
|
||||||
|
<a href="{{filename}}">Raw</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="viewer">
|
||||||
|
{{{body}}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{#> partial/footer ~}}
|
||||||
|
{{~/partial/footer }}
|
|
@ -1,102 +1,20 @@
|
||||||
<html>
|
{{#> partial/header ~}}
|
||||||
<head>
|
<title>Artifacts: {{run_name}}</title>
|
||||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
{{~/partial/header }}
|
||||||
<meta name="viewport" content="width=device-width" />
|
{{> partial/fileIcons }}
|
||||||
<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>
|
|
||||||
Artifacts:
|
|
||||||
{{run_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></defs></svg>
|
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
{{> partial/logoLink }}
|
||||||
<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>
|
|
||||||
<h1>
|
<h1>
|
||||||
<a href="/">{{run_name}}</a>
|
<a href="/?url={{run_url}}">{{run_name}}</a>
|
||||||
/
|
/
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="meta">
|
<div class="metadata">
|
||||||
<div id="summary">
|
<div id="summary">
|
||||||
<span class="meta-item"><b>{{artifacts.len()}}</b> artifact{{#if artifacts.len() != 1}}s{{/if}}</span>
|
<span class="meta-item"><b>{{artifacts.len()}}</b> artifact{{#if artifacts.len() != 1}}s{{/if}}</span>
|
||||||
<span class="meta-item"><a
|
<span class="meta-item"><a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a></span>
|
||||||
href="{{run_url}}"
|
<span class="meta-item"><input type="text" placeholder="filter" id="filter" onkeyup="filter()"/></span>
|
||||||
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>
|
</div>
|
||||||
<div class="listing">
|
<div class="listing">
|
||||||
|
@ -111,7 +29,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{{#each artifacts}}
|
{{#each artifacts}}
|
||||||
<tr class="file">
|
<tr class="file">
|
||||||
{{#if this.expired}}
|
{{#if expired}}
|
||||||
<td>
|
<td>
|
||||||
<svg
|
<svg
|
||||||
class="expired"
|
class="expired"
|
||||||
|
@ -120,27 +38,27 @@
|
||||||
version="1.1"
|
version="1.1"
|
||||||
viewBox="0 0 317 259"
|
viewBox="0 0 317 259"
|
||||||
><use xlink:href="#folder"></use></svg>
|
><use xlink:href="#folder"></use></svg>
|
||||||
<span class="name light">{{this.name}}</span>
|
<span class="name light">{{name}}</span>
|
||||||
</td>
|
</td>
|
||||||
{{else}}
|
{{else}}
|
||||||
<td>
|
<td>
|
||||||
<a href="{{this.url}}">
|
<a href="{{url}}">
|
||||||
<svg
|
<svg
|
||||||
width="1.5em"
|
width="1.5em"
|
||||||
height="1em"
|
height="1em"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
viewBox="0 0 317 259"
|
viewBox="0 0 317 259"
|
||||||
><use xlink:href="#folder"></use></svg>
|
><use xlink:href="#folder"></use></svg>
|
||||||
<span class="name">{{this.name}}</span>
|
<span class="name">{{name}}</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<td>{{this.size}}</td>
|
<td>{{size}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{#if this.expired}}
|
{{#if expired}}
|
||||||
—
|
—
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="{{this.download_url}}" rel="noopener noreferrer">Download</a>
|
<a href="{{download_url}}" rel="noopener noreferrer">Download</a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -150,32 +68,17 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
{{#> partial/footer ~}}
|
||||||
Served with
|
|
||||||
<a
|
|
||||||
href="https://codeberg.org/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>
|
<script>
|
||||||
|
|
||||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
// @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"}})}
|
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" } }) }
|
||||||
|
document.addEventListener("DOMContentLoaded", initFilter);
|
||||||
|
|
||||||
// @license-end
|
// @license-end
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
{{~/partial/footer }}
|
||||||
</html>
|
|
||||||
|
|
Loading…
Reference in a new issue