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
|
||||
MAX_ARTIFACT_SIZE=100000000
|
||||
MAX_AGE_H=12
|
||||
NO_HTTPS=1
|
||||
# If you only want to access public repositories,
|
||||
# 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:
|
||||
- name: 👁️ Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # important to fetch tag logs
|
||||
|
||||
- name: ⚒️ Build application
|
||||
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-aarch64-${{ github.ref_name }}.tar.xz -C target/aarch64-unknown-linux-gnu/release artifactview
|
||||
|
||||
{
|
||||
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"
|
||||
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' CHANGELOG.md >> "$GITHUB_ENV"
|
||||
|
||||
- name: 🎉 Publish release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
|
233
Cargo.lock
generated
233
Cargo.lock
generated
|
@ -146,6 +146,7 @@ dependencies = [
|
|||
"async_zip",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"comrak",
|
||||
"dotenvy",
|
||||
"envy",
|
||||
"flate2",
|
||||
|
@ -171,6 +172,8 @@ dependencies = [
|
|||
"serde-env",
|
||||
"serde-hex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"syntect",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
@ -264,7 +267,6 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 1.0.1",
|
||||
"tokio",
|
||||
"tower",
|
||||
|
@ -350,6 +352,15 @@ version = "1.6.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
|
@ -365,6 +376,12 @@ version = "0.6.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.5.0"
|
||||
|
@ -464,6 +481,22 @@ version = "1.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
|
@ -526,6 +559,41 @@ dependencies = [
|
|||
"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]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
|
@ -554,6 +622,37 @@ dependencies = [
|
|||
"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]]
|
||||
name = "derive_more"
|
||||
version = "0.99.17"
|
||||
|
@ -567,6 +666,12 @@ dependencies = [
|
|||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deunicode"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
@ -590,6 +695,12 @@ version = "1.0.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
|
||||
|
||||
[[package]]
|
||||
name = "entities"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.0"
|
||||
|
@ -1071,6 +1182,12 @@ dependencies = [
|
|||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.5.0"
|
||||
|
@ -1327,13 +1444,35 @@ version = "1.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "openssl"
|
||||
version = "0.10.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.5.0",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
|
@ -1533,7 +1672,7 @@ checksum = "31b476131c3c86cb68032fdc5cb6d5a1045e3e42d96b69fa599fd77701e1f5bf"
|
|||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags",
|
||||
"bitflags 2.5.0",
|
||||
"lazy_static",
|
||||
"num-traits",
|
||||
"rand",
|
||||
|
@ -1632,7 +1771,7 @@ version = "11.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e29830cbb1290e404f24c73af91c5d8d631ce7e128691e9477556b540cd01ecd"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1641,7 +1780,7 @@ version = "0.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1790,7 +1929,7 @@ version = "0.38.34"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.5.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
|
@ -1875,6 +2014,15 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "schannel"
|
||||
version = "0.1.23"
|
||||
|
@ -1896,7 +2044,7 @@ version = "2.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.5.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
|
@ -2043,6 +2191,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "smallvec"
|
||||
version = "0.6.14"
|
||||
|
@ -2083,6 +2241,12 @@ dependencies = [
|
|||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.5.0"
|
||||
|
@ -2123,6 +2287,26 @@ version = "1.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "tempfile"
|
||||
version = "3.10.1"
|
||||
|
@ -2303,7 +2487,6 @@ dependencies = [
|
|||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2312,7 +2495,7 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.5.0",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
|
@ -2341,7 +2524,6 @@ version = "0.1.40"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
@ -2399,6 +2581,12 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-arena"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.17.0"
|
||||
|
@ -2453,6 +2641,12 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode_categories"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
@ -2519,6 +2713,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
|
@ -2648,6 +2852,15 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
|
17
Cargo.toml
17
Cargo.toml
|
@ -21,8 +21,15 @@ async_zip = { path = "crates/async_zip", features = [
|
|||
"tokio-fs",
|
||||
"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"] }
|
||||
comrak = { version = "0.24.1", default-features = false }
|
||||
dotenvy = "0.15.7"
|
||||
envy = { path = "crates/envy" }
|
||||
flate2 = "1.0.30"
|
||||
|
@ -49,6 +56,14 @@ serde = { version = "1.0.203", features = ["derive"] }
|
|||
serde-env = "0.1.1"
|
||||
serde-hex = "0.1.0"
|
||||
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"
|
||||
tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] }
|
||||
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
|
||||
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
|
||||
their contents. If the artifact contains a website, it is displayed normally, if it consists
|
||||
of other files, a file listing is shown.
|
||||
Artifactview is a small web application that fetches these CI artifacts and displays
|
||||
their contents.
|
||||
|
||||
There is also full support for single page applications, placing a file named `200.html` in the
|
||||
root directory it will be returned in case no file exists for the requested path.
|
||||
It offers full support for single page applications and custom 404 error pages.
|
||||
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
|
||||
status code 404 if no file was found.
|
||||
Artifactview displays a file listing if there is no `index.html` or fallback page
|
||||
present, so you can browse artifacts that dont contain websites.
|
||||
|
||||
![Artifact file listing](resources/screenshotFiles.png)
|
||||
|
||||
## 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`
|
||||
|
||||
## 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
|
||||
servers, users and organizations.
|
||||
Since domains only allow letters, numbers and dashes but repository names allow dots and
|
||||
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
|
||||
[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
|
||||
ownership of a website (like `.well-known/acme-challenge` for issuing TLS certificates),
|
||||
Artifactview will serve no files from the `.well-known` folder.
|
||||
are used to configure security-relevant properties of a website or attest ownership of a
|
||||
website (like `.well-known/acme-challenge` for issuing TLS certificates), Artifactview
|
||||
will serve no files from the `.well-known` folder.
|
||||
|
||||
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).
|
||||
Additionally there is a configurable timeout for the zip file indexing operation.
|
||||
These measures should protect the server againt denial-of-service attacks like
|
||||
overfilling the server drive or uploading zip bombs.
|
||||
maximum size of individual files to be served (100MB by default). Additionally there is
|
||||
a configurable timeout for the zip file indexing operation. These measures should
|
||||
protect the server againt denial-of-service attacks like overfilling the server drive or
|
||||
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 axum::{
|
||||
|
@ -6,10 +6,11 @@ use axum::{
|
|||
extract::{Host, Request, State},
|
||||
http::{Response, Uri},
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::{any, get, post},
|
||||
Form, Router,
|
||||
routing::{any, get},
|
||||
Router,
|
||||
};
|
||||
use headers::HeaderMapExt;
|
||||
use futures_lite::AsyncReadExt as LiteAsyncReadExt;
|
||||
use headers::{ContentType, HeaderMapExt};
|
||||
use http::{HeaderMap, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use tokio::{
|
||||
|
@ -31,9 +32,10 @@ use crate::{
|
|||
config::Config,
|
||||
error::Error,
|
||||
gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN},
|
||||
query::Query,
|
||||
query::{ArtifactQuery, Query, RunQuery},
|
||||
templates::{self, ArtifactItem, LinkItem},
|
||||
util::{self, ErrorJson, ResponseBuilderExt},
|
||||
viewer::Viewers,
|
||||
App,
|
||||
};
|
||||
|
||||
|
@ -46,6 +48,7 @@ struct AppInner {
|
|||
cfg: Config,
|
||||
cache: Cache,
|
||||
api: ArtifactApi,
|
||||
viewers: Viewers,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
|
@ -54,13 +57,22 @@ impl Default for App {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UrlForm {
|
||||
url: String,
|
||||
#[derive(Default, Deserialize)]
|
||||
struct FileQparams {
|
||||
viewer: Option<String>,
|
||||
}
|
||||
|
||||
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 STYLE_MAIN_BYTES: &[u8; 4057] = include_bytes!("../resources/style.css");
|
||||
const STYLE_CONTENT_BYTES: &[u8; 10063] = include_bytes!("../resources/content.css");
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
|
@ -72,11 +84,16 @@ impl App {
|
|||
}
|
||||
|
||||
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 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 router = Router::new()
|
||||
// Prevent search indexing since artifactview serves temporary artifacts
|
||||
|
@ -93,7 +110,6 @@ impl App {
|
|||
.route("/.well-known/*path", any(|| async { Error::Inaccessible }))
|
||||
// Serve artifact pages
|
||||
.route("/", get(Self::get_page))
|
||||
.route("/", post(Self::post_homepage))
|
||||
.fallback(get(Self::get_page))
|
||||
.with_state(state)
|
||||
// Log requests
|
||||
|
@ -123,26 +139,14 @@ impl App {
|
|||
let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
|
||||
|
||||
if subdomain.is_empty() {
|
||||
// Main page
|
||||
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())?)
|
||||
Self::get_homepage(state, uri).await
|
||||
} 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)?;
|
||||
let path = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy();
|
||||
let hdrs = request.headers();
|
||||
let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?;
|
||||
|
||||
match query {
|
||||
Query::Artifact(query) => {
|
||||
let entry_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
|
||||
let entry = entry_res.entry;
|
||||
if entry_res.downloaded {
|
||||
|
@ -151,52 +155,66 @@ impl App {
|
|||
|
||||
match entry.get_file(&path, uri.query().unwrap_or_default()) {
|
||||
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
|
||||
{
|
||||
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)) => {
|
||||
if !path.ends_with('/') {
|
||||
return Ok(Redirect::to(&format!("{path}/")).into_response());
|
||||
}
|
||||
|
||||
let mut path_components = vec![
|
||||
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 run_url = query.forge_url();
|
||||
let tmpl = templates::Listing {
|
||||
main_url: state.i.cfg.main_url(),
|
||||
version: templates::Version,
|
||||
run_url: &query.forge_url(),
|
||||
run_url: &run_url,
|
||||
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_files: listing.n_files,
|
||||
has_parent: listing.has_parent,
|
||||
publisher: query.publisher(),
|
||||
viewer_max_size: state
|
||||
.i
|
||||
.cfg
|
||||
.load()
|
||||
.viewer_max_size
|
||||
.map(u32::from)
|
||||
.unwrap_or(u32::MAX),
|
||||
entries: listing.entries,
|
||||
};
|
||||
|
||||
Ok(Response::builder()
|
||||
.typed_header(headers::ContentType::html())
|
||||
.cache_immutable()
|
||||
.cache()
|
||||
.body(tmpl.to_string().into())?)
|
||||
}
|
||||
Err(Error::NotFound(e)) => {
|
||||
|
@ -209,67 +227,140 @@ impl App {
|
|||
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 {
|
||||
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() != "/" {
|
||||
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 {
|
||||
main_url: state.i.cfg.main_url(),
|
||||
version: templates::Version,
|
||||
run_url: &query.forge_url(),
|
||||
run_name: &query.shortid(),
|
||||
publisher: LinkItem {
|
||||
name: query.user.to_owned(),
|
||||
url: format!("https://{}/{}", query.host, query.user),
|
||||
},
|
||||
publisher: query.publisher(),
|
||||
artifacts: artifacts
|
||||
.into_iter()
|
||||
.map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
.map(|a| ArtifactItem::from_artifact(a, query.as_ref(), &state.i.cfg))
|
||||
.collect::<Vec<_>>(),
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.typed_header(headers::ContentType::html())
|
||||
.cache()
|
||||
.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 {
|
||||
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(
|
||||
state: AppState,
|
||||
state: &AppState,
|
||||
entry: Arc<CacheEntry>,
|
||||
zip_path: PathBuf,
|
||||
zip_path: &Path,
|
||||
res: GetFileResultFile,
|
||||
hdrs: &HeaderMap,
|
||||
) -> Result<Response<Body>, Error> {
|
||||
|
@ -393,9 +484,9 @@ impl App {
|
|||
Host(host): Host,
|
||||
) -> Result<Response<Body>, ErrorJson> {
|
||||
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)?;
|
||||
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)?)
|
||||
}
|
||||
|
||||
|
@ -405,9 +496,9 @@ impl App {
|
|||
Host(host): Host,
|
||||
) -> Result<Response<Body>, ErrorJson> {
|
||||
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)?;
|
||||
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)?)
|
||||
}
|
||||
|
||||
|
@ -419,13 +510,9 @@ impl App {
|
|||
) -> Result<Response<Body>, ErrorJson> {
|
||||
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 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)?;
|
||||
let entry_res = state
|
||||
.i
|
||||
.cache
|
||||
.get_entry(&state.i.api, &query.try_into_artifactquery()?, &ip)
|
||||
.await?;
|
||||
let entry_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
|
||||
if entry_res.downloaded {
|
||||
state.garbage_collect();
|
||||
}
|
||||
|
@ -442,6 +529,13 @@ impl App {
|
|||
.cache_immutable()
|
||||
.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 {
|
||||
|
@ -450,7 +544,12 @@ impl AppState {
|
|||
let cache = Cache::new(cfg.clone());
|
||||
let api = ArtifactApi::new(cfg.clone());
|
||||
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::{
|
||||
config::Config,
|
||||
error::{Error, Result},
|
||||
query::{ArtifactQuery, QueryData},
|
||||
query::{ArtifactQuery, Query, QueryRef, RunQuery},
|
||||
};
|
||||
|
||||
pub struct ArtifactApi {
|
||||
|
@ -69,7 +69,7 @@ enum ForgejoArtifactStatus {
|
|||
}
|
||||
|
||||
impl GithubArtifact {
|
||||
fn into_artifact<T>(self, query: &QueryData<T>) -> Artifact {
|
||||
fn into_artifact(self, query: QueryRef<'_>) -> Artifact {
|
||||
Artifact {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
|
@ -85,7 +85,7 @@ impl GithubArtifact {
|
|||
}
|
||||
|
||||
impl ForgejoArtifact {
|
||||
fn into_artifact<T>(self, id: u64, query: &QueryData<T>) -> Artifact {
|
||||
fn into_artifact(self, id: u64, query: QueryRef<'_>) -> Artifact {
|
||||
Artifact {
|
||||
download_url: format!(
|
||||
"https://{}/{}/{}/actions/runs/{}/artifacts/{}",
|
||||
|
@ -116,14 +116,14 @@ impl ArtifactApi {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn list<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
|
||||
let subdomain = query.subdomain_with_artifact(None)?;
|
||||
pub async fn list(&self, query: &RunQuery) -> Result<Vec<Artifact>> {
|
||||
let cache_key = query.cache_key();
|
||||
self.qc
|
||||
.get_or_insert_async(&subdomain, async {
|
||||
.get_or_insert_async(&cache_key, async {
|
||||
if query.is_github() {
|
||||
self.list_github(query).await
|
||||
self.list_github(query.as_ref()).await
|
||||
} else {
|
||||
self.list_forgejo(query).await
|
||||
self.list_forgejo(query.as_ref()).await
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -134,7 +134,7 @@ impl ArtifactApi {
|
|||
self.fetch_github(query).await
|
||||
} else {
|
||||
// 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)?;
|
||||
if i == 0 || i > artifacts.len() {
|
||||
|
@ -200,7 +200,7 @@ impl ArtifactApi {
|
|||
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!(
|
||||
"https://{}/{}/{}/actions/runs/{}/artifacts",
|
||||
query.host, query.user, query.repo, query.run
|
||||
|
@ -225,7 +225,7 @@ impl ArtifactApi {
|
|||
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!(
|
||||
"https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts",
|
||||
query.user, query.repo, query.run
|
||||
|
@ -253,7 +253,7 @@ impl ArtifactApi {
|
|||
.await?
|
||||
.json::<GithubArtifact>()
|
||||
.await?;
|
||||
Ok(artifact.into_artifact(query))
|
||||
Ok(artifact.into_artifact(query.as_ref()))
|
||||
}
|
||||
|
||||
async fn handle_github_error(resp: Response) -> Result<Response> {
|
||||
|
@ -281,20 +281,19 @@ impl ArtifactApi {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{config::Config, query::ArtifactQuery};
|
||||
|
||||
use super::ArtifactApi;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_forgejo() {
|
||||
let query = ArtifactQuery {
|
||||
host: "code.thetadev.de".to_owned(),
|
||||
host_alias: None,
|
||||
user: "HSA".to_owned(),
|
||||
repo: "Visitenbuch".to_owned(),
|
||||
run: 32,
|
||||
artifact: 1,
|
||||
};
|
||||
let query = ArtifactQuery::from_subdomain(
|
||||
"code-thetadev-de--hsa--visitenbuch--32-1",
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let api = ArtifactApi::new(Config::default());
|
||||
let res = api.fetch(&query).await.unwrap();
|
||||
|
||||
|
@ -304,14 +303,11 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn fetch_github() {
|
||||
let query = ArtifactQuery {
|
||||
host: "github.com".to_owned(),
|
||||
host_alias: None,
|
||||
user: "actions".to_owned(),
|
||||
repo: "upload-artifact".to_owned(),
|
||||
run: 8805345396,
|
||||
artifact: 1440556464,
|
||||
};
|
||||
let query = ArtifactQuery::from_subdomain(
|
||||
"github-com--actions--upload-artifact--8805345396-1440556464",
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let api = ArtifactApi::new(Config::default());
|
||||
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 filename: Option<String>,
|
||||
pub file: FileEntry,
|
||||
pub mime: Option<Mime>,
|
||||
pub status: StatusCode,
|
||||
|
@ -114,7 +115,7 @@ impl Cache {
|
|||
query: &ArtifactQuery,
|
||||
ip: &IpAddr,
|
||||
) -> 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 downloaded = !zip_path.is_file();
|
||||
if downloaded {
|
||||
|
@ -280,6 +281,7 @@ impl CacheEntry {
|
|||
// 2. Site path + `/index.html`
|
||||
else if let Some(file) = self.files.get(path) {
|
||||
return Ok(GetFileResult::File(GetFileResultFile {
|
||||
filename: path.rsplit('/').next().map(str::to_owned),
|
||||
file: file.clone(),
|
||||
mime: util::path_mime(path),
|
||||
status: StatusCode::OK,
|
||||
|
@ -294,6 +296,7 @@ impl CacheEntry {
|
|||
{
|
||||
// index.html or SPA entrypoint
|
||||
return Ok(GetFileResult::File(GetFileResultFile {
|
||||
filename: None,
|
||||
file: file.clone(),
|
||||
mime: Some(mime::TEXT_HTML),
|
||||
status: StatusCode::OK,
|
||||
|
@ -328,6 +331,7 @@ impl CacheEntry {
|
|||
} else if let Some(file) = self.files.get("404.html") {
|
||||
// Custom 404 error page
|
||||
return Ok(GetFileResult::File(GetFileResultFile {
|
||||
filename: None,
|
||||
file: file.clone(),
|
||||
mime: Some(mime::TEXT_HTML),
|
||||
status: StatusCode::NOT_FOUND,
|
||||
|
@ -375,7 +379,7 @@ impl CacheEntry {
|
|||
directories.push(ListingEntry {
|
||||
name: n.to_owned(),
|
||||
url: format!("{n}{path}"),
|
||||
size: Size(0),
|
||||
size: 0.into(),
|
||||
crc32: "-".to_string(),
|
||||
is_dir: true,
|
||||
});
|
||||
|
@ -383,7 +387,7 @@ impl CacheEntry {
|
|||
files.push(ListingEntry {
|
||||
name: n.to_owned(),
|
||||
url: format!("{n}{path}"),
|
||||
size: Size(entry.uncompressed_size),
|
||||
size: entry.uncompressed_size.into(),
|
||||
crc32: hex::encode(entry.crc32.to_le_bytes()),
|
||||
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::{
|
||||
error::{Error, Result},
|
||||
query::{Query, QueryFilterList},
|
||||
query::{ArtifactQuery, QueryFilterList},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
|
@ -27,6 +27,8 @@ struct ConfigInner {
|
|||
pub struct ConfigData {
|
||||
/// Folder where the downloaded artifacts are stored
|
||||
pub cache_dir: PathBuf,
|
||||
/// Port number of the web server
|
||||
pub port: u16,
|
||||
/// Root domain under which the server is available
|
||||
///
|
||||
/// The individual artifacts are served under `<subdomain>.<root_domain>`
|
||||
|
@ -65,12 +67,15 @@ pub struct ConfigData {
|
|||
pub repo_whitelist: QueryFilterList,
|
||||
/// Aliases for sites (Example: `gh => github.com`)
|
||||
pub site_aliases: HashMap<String, String>,
|
||||
/// Maximum file size for the viewer
|
||||
pub viewer_max_size: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
impl Default for ConfigData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cache_dir: Path::new("/tmp/artifactview").into(),
|
||||
port: 3000,
|
||||
root_domain: "localhost:3000".to_string(),
|
||||
no_https: false,
|
||||
max_artifact_size: Some(NonZeroU32::new(100_000_000).unwrap()),
|
||||
|
@ -85,6 +90,7 @@ impl Default for ConfigData {
|
|||
repo_blacklist: QueryFilterList::default(),
|
||||
repo_whitelist: QueryFilterList::default(),
|
||||
site_aliases: HashMap::new(),
|
||||
viewer_max_size: Some(NonZeroU32::new(100_000).unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -148,7 +154,7 @@ impl Config {
|
|||
&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) {
|
||||
Err(Error::Forbidden("repository is blacklisted".into()))
|
||||
} else if !self.i.data.repo_whitelist.passes(query, false) {
|
||||
|
|
|
@ -41,6 +41,10 @@ pub enum Error {
|
|||
MethodNotAllowed,
|
||||
#[error("You are fetching new artifacts too fast, please wait a minute and try again")]
|
||||
Ratelimit,
|
||||
#[error("viewer: {0}")]
|
||||
Viewer(Cow<'static, str>),
|
||||
#[error("viewer not applicable")]
|
||||
ViewerNotApplicable,
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
|
|
|
@ -7,5 +7,6 @@ mod gzip_reader;
|
|||
mod query;
|
||||
mod templates;
|
||||
mod util;
|
||||
mod viewer;
|
||||
|
||||
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 regex::{Captures, Regex};
|
||||
|
@ -6,53 +6,113 @@ use serde::{de::Visitor, Deserialize};
|
|||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
templates::LinkItem,
|
||||
util,
|
||||
};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Query {
|
||||
Artifact(ArtifactQuery),
|
||||
Run(RunQuery),
|
||||
}
|
||||
|
||||
pub type RunQuery = QueryData<()>;
|
||||
pub type ArtifactQuery = QueryData<u64>;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct QueryData<T> {
|
||||
pub struct ArtifactQuery {
|
||||
/// Forge host
|
||||
pub host: String,
|
||||
/// Host alias if the query was constructed using one
|
||||
pub host_alias: Option<String>,
|
||||
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,
|
||||
// Optional selected artifact
|
||||
pub artifact: T,
|
||||
/// CI artifact id
|
||||
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());
|
||||
|
||||
impl Query {
|
||||
impl ArtifactQuery {
|
||||
pub fn from_subdomain(subdomain: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
||||
let segments = subdomain.split("--").collect::<Vec<_>>();
|
||||
if segments.len() != 4 {
|
||||
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_alias = None;
|
||||
let user = decode_domain(segments[1], '-');
|
||||
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)]
|
||||
if let Some(alias) = aliases.get(&host) {
|
||||
|
@ -60,26 +120,29 @@ impl Query {
|
|||
host = alias.clone();
|
||||
}
|
||||
|
||||
Ok(match run_and_artifact.get(1) {
|
||||
Some(x) => Self::Artifact(QueryData {
|
||||
Ok(ArtifactQuery {
|
||||
host,
|
||||
host_alias,
|
||||
user,
|
||||
repo,
|
||||
run,
|
||||
artifact: x.parse().ok().ok_or(Error::InvalidUrl)?,
|
||||
}),
|
||||
None => Self::Run(QueryData {
|
||||
host,
|
||||
host_alias,
|
||||
user,
|
||||
repo,
|
||||
run,
|
||||
artifact: (),
|
||||
}),
|
||||
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> {
|
||||
let (host, mut path_segs) = util::parse_url(url)?;
|
||||
|
||||
|
@ -104,118 +167,74 @@ impl Query {
|
|||
return Err(Error::BadRequest("invalid repository name".into()));
|
||||
}
|
||||
|
||||
let host = aliases
|
||||
let host_alias = aliases
|
||||
.iter()
|
||||
.find(|(_, v)| *v == host)
|
||||
.map(|(k, _)| k.to_owned())
|
||||
.unwrap_or_else(|| host.to_owned());
|
||||
.map(|(k, _)| k.to_owned());
|
||||
|
||||
let run = path_segs
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or(Error::BadRequest("no run ID".into()))?;
|
||||
|
||||
Ok(Self::Run(RunQuery {
|
||||
host,
|
||||
host_alias: None,
|
||||
Ok(Self {
|
||||
host: host.to_owned(),
|
||||
host_alias,
|
||||
user,
|
||||
repo,
|
||||
run,
|
||||
artifact: (),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subdomain(&self) -> Result<String> {
|
||||
match self {
|
||||
Query::Artifact(q) => q.subdomain(),
|
||||
Query::Run(q) => q.subdomain(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_runquery(self) -> RunQuery {
|
||||
match self {
|
||||
Query::Artifact(q) => q.into_runquery(),
|
||||
Query::Run(q) => q,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_into_artifactquery(self) -> Result<ArtifactQuery> {
|
||||
match self {
|
||||
Query::Artifact(q) => Ok(q),
|
||||
Query::Run(_) => Err(Error::BadRequest("no artifact specified".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtifactQuery {
|
||||
pub fn subdomain(&self) -> 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!(
|
||||
pub fn cache_key(&self) -> String {
|
||||
format!(
|
||||
"{}--{}--{}--{}",
|
||||
encode_domain(host, '.'),
|
||||
encode_domain(&self.host, '.'),
|
||||
encode_domain(&self.user, '-'),
|
||||
encode_domain(&self.repo, '-'),
|
||||
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 {
|
||||
RunQuery {
|
||||
host: self.host,
|
||||
host_alias: self.host_alias,
|
||||
user: self.user,
|
||||
repo: self.repo,
|
||||
impl Query for ArtifactQuery {
|
||||
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,
|
||||
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 {
|
||||
pub fn passes(&self, query: &Query) -> bool {
|
||||
let (host, user, repo) = match query {
|
||||
Query::Artifact(q) => (&q.host, &q.user, &q.repo),
|
||||
Query::Run(q) => (&q.host, &q.user, &q.repo),
|
||||
};
|
||||
&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)
|
||||
pub fn passes<Q: Query>(&self, query: &Q) -> bool {
|
||||
let q = query.as_ref();
|
||||
self.host == q.host
|
||||
&& self.user.as_deref().map(|u| u == q.user).unwrap_or(true)
|
||||
&& self.repo.as_deref().map(|r| r == q.repo).unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -349,7 +365,7 @@ impl FromStr for 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() {
|
||||
true
|
||||
} else {
|
||||
|
@ -388,9 +404,9 @@ impl<'de> Deserialize<'de> for QueryFilterList {
|
|||
mod tests {
|
||||
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 rstest::rstest;
|
||||
|
@ -426,19 +442,19 @@ mod tests {
|
|||
#[test]
|
||||
fn query_from_subdomain() {
|
||||
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!(
|
||||
query,
|
||||
Query::Artifact(ArtifactQuery {
|
||||
ArtifactQuery {
|
||||
host: "github.com".to_owned(),
|
||||
host_alias: None,
|
||||
user: "thetadev".to_owned(),
|
||||
repo: "newpipe-extractor".to_owned(),
|
||||
run: 14,
|
||||
artifact: 123
|
||||
})
|
||||
}
|
||||
);
|
||||
assert_eq!(query.subdomain().unwrap(), d1);
|
||||
assert_eq!(query.subdomain_with_artifact(query.artifact), d1);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
|
|
@ -2,18 +2,14 @@ use crate::{
|
|||
artifact_api::Artifact,
|
||||
cache::{ListingEntry, Size},
|
||||
config::Config,
|
||||
error::Result,
|
||||
query::QueryData,
|
||||
query::{Query, QueryRef},
|
||||
};
|
||||
use yarte::{Render, Template};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Version;
|
||||
|
||||
#[derive(Template, Default)]
|
||||
#[derive(Template)]
|
||||
#[template(path = "index")]
|
||||
pub struct Index {
|
||||
pub version: Version,
|
||||
pub struct Index<'a> {
|
||||
pub main_url: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
@ -27,7 +23,6 @@ pub struct Error<'a> {
|
|||
#[template(path = "selection")]
|
||||
pub struct Selection<'a> {
|
||||
pub main_url: &'a str,
|
||||
pub version: Version,
|
||||
pub run_url: &'a str,
|
||||
pub run_name: &'a str,
|
||||
pub publisher: LinkItem,
|
||||
|
@ -38,16 +33,37 @@ pub struct Selection<'a> {
|
|||
#[template(path = "listing")]
|
||||
pub struct Listing<'a> {
|
||||
pub main_url: &'a str,
|
||||
pub version: Version,
|
||||
pub run_url: &'a str,
|
||||
pub artifact_name: &'a str,
|
||||
pub path_components: Vec<LinkItem>,
|
||||
pub n_dirs: usize,
|
||||
pub n_files: usize,
|
||||
pub has_parent: bool,
|
||||
pub publisher: LinkItem,
|
||||
pub viewer_max_size: u32,
|
||||
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 name: String,
|
||||
pub url: String,
|
||||
|
@ -62,25 +78,15 @@ pub struct ArtifactItem {
|
|||
}
|
||||
|
||||
impl ArtifactItem {
|
||||
pub fn from_artifact<T>(
|
||||
artifact: Artifact,
|
||||
query: &QueryData<T>,
|
||||
cfg: &Config,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
pub fn from_artifact(artifact: Artifact, query: QueryRef<'_>, cfg: &Config) -> Self {
|
||||
Self {
|
||||
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),
|
||||
expired: artifact.expired,
|
||||
download_url: artifact.user_download_url.unwrap_or(artifact.download_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Version {
|
||||
fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Size {
|
||||
|
|
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">
|
||||
|
||||
<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); } p { margin: 16px 0; } header { gap: 1em;
|
||||
padding-top: 10px; padding-bottom: 10px; background-color: #f2f2f2; } footer {
|
||||
padding: 40px 20px; font-size: 12px; text-align: center; } @media
|
||||
(prefers-color-scheme: dark) { * { --color-secondary: #082437; --color-border:
|
||||
#212121; --color-text: #dddddd; } body { background-color: #101010; } header {
|
||||
* { padding: 0; margin: 0; --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; } .card { display: flex; flex-direction: column; width: 90%;
|
||||
max-width: 500px; align-items: center; } .center { width: 100%; display: flex;
|
||||
flex-direction: row; justify-content: center; } .light { color:
|
||||
var(--color-text-light); } p { margin: 16px 0; } header { gap: 1em; padding-top:
|
||||
10px; padding-bottom: 10px; background-color: #f2f2f2; } footer { padding: 40px
|
||||
20px; font-size: 12px; text-align: center; } @media (prefers-color-scheme: dark) {
|
||||
* { --color-text: #dddddd; } body { background-color: #101010; } header {
|
||||
background-color: #151515; } }
|
||||
</style>
|
||||
<title>Artifactview</title>
|
||||
|
|
|
@ -1,28 +1,6 @@
|
|||
<html lang="en">
|
||||
<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>
|
||||
{{#> partial/header ~}}
|
||||
<title>Artifactview</title>
|
||||
</head>
|
||||
<body>
|
||||
{{~/partial/header }}
|
||||
<header class="center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -40,10 +18,12 @@
|
|||
<div class="center">
|
||||
<div class="card">
|
||||
<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
|
||||
class="query-input"
|
||||
name="url"
|
||||
type="text"
|
||||
required
|
||||
placeholder="codeberg.org/username/repo/actions/runs/42"
|
||||
style="flex-grow: 1"
|
||||
/>
|
||||
|
@ -59,7 +39,7 @@
|
|||
>
|
||||
Artifactview
|
||||
</a>
|
||||
{{version}}
|
||||
{{~crate::app::VERSION}}
|
||||
<p class="light">
|
||||
<b>Disclaimer:</b>
|
||||
Artifactview does not host any websites, the data is fetched from the respective
|
||||
|
|
|
@ -1,112 +1,20 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<style type="text/css">
|
||||
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
|
||||
#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>
|
||||
|
||||
{{#> partial/header ~}}
|
||||
<title>Index: {{artifact_name}}</title>
|
||||
{{~/partial/header }}
|
||||
{{> partial/fileIcons }}
|
||||
<header>
|
||||
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 13.229 13.229"
|
||||
><g
|
||||
aria-label="AV"
|
||||
style="stroke-width:.264583"
|
||||
><path
|
||||
d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
|
||||
style="fill: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>
|
||||
{{> partial/logoLink }}
|
||||
<h1>
|
||||
{{#each path_components}}<a href="{{this.url}}">{{this.name}}</a> /{{/each}}
|
||||
{{#each path_components}}<a href="{{url}}">{{name}}</a>{{/each}}
|
||||
</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div class="meta">
|
||||
<div class="metadata">
|
||||
<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_files}}</b> file{{#if n_files != 1}}s{{/if}}</span>
|
||||
<span class="meta-item"><a
|
||||
href="{{run_url}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>CI run</a></span>
|
||||
<span class="meta-item"><input
|
||||
type="text"
|
||||
placeholder="filter"
|
||||
id="filter"
|
||||
onkeyup="filter()"
|
||||
/></span>
|
||||
<span class="meta-item"><a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a></span>
|
||||
<span class="meta-item"><input type="text" placeholder="filter" id="filter" onkeyup="filter()"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listing">
|
||||
|
@ -130,40 +38,34 @@
|
|||
<td>—</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{ let vms = viewer_max_size }}
|
||||
{{#each entries}}
|
||||
<tr class="file">
|
||||
<td>
|
||||
<a href="{{this.name}}">
|
||||
<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>
|
||||
<span class="name">{{this.name}}</span>
|
||||
<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 is_dir}}#folder{{else}}#file{{/if}}"></use></svg>
|
||||
<span class="name">{{name}}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{#if this.is_dir}}—{{else}}{{this.size}}{{/if}}</td>
|
||||
<td>{{#if this.is_dir}}—{{else}}{{this.crc32}}{{/if}}</td>
|
||||
<td>{{#if is_dir}}—{{else}}{{size}}{{/if}}</td>
|
||||
<td>{{#if is_dir}}—{{else}}{{crc32}}{{/if}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Served with
|
||||
<a
|
||||
href="https://codeberg.org/ThetaDev/artifactview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Artifactview</a>
|
||||
{{version}}
|
||||
</footer>
|
||||
{{#> partial/footer ~}}
|
||||
<script>
|
||||
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
|
||||
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
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{~/partial/footer }}
|
||||
|
|
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>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<style type="text/css">
|
||||
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
|
||||
#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>
|
||||
|
||||
{{#> partial/header ~}}
|
||||
<title>Artifacts: {{run_name}}</title>
|
||||
{{~/partial/header }}
|
||||
{{> partial/fileIcons }}
|
||||
<header>
|
||||
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 13.229 13.229"
|
||||
><g
|
||||
aria-label="AV"
|
||||
style="stroke-width:.264583"
|
||||
><path
|
||||
d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
|
||||
style="fill:#888;fill-opacity:1"
|
||||
/><path
|
||||
d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z"
|
||||
style="fill:#ddd;fill-opacity:1;stroke-width:.264583"
|
||||
/></g></svg>
|
||||
</a>
|
||||
{{> partial/logoLink }}
|
||||
<h1>
|
||||
<a href="/">{{run_name}}</a>
|
||||
<a href="/?url={{run_url}}">{{run_name}}</a>
|
||||
/
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="meta">
|
||||
<div class="metadata">
|
||||
<div id="summary">
|
||||
<span class="meta-item"><b>{{artifacts.len()}}</b> artifact{{#if artifacts.len() != 1}}s{{/if}}</span>
|
||||
<span class="meta-item"><a
|
||||
href="{{run_url}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>CI run</a></span>
|
||||
<span class="meta-item"><input
|
||||
type="text"
|
||||
placeholder="filter"
|
||||
id="filter"
|
||||
onkeyup="filter()"
|
||||
/></span>
|
||||
<span class="meta-item"><a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a></span>
|
||||
<span class="meta-item"><input type="text" placeholder="filter" id="filter" onkeyup="filter()"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listing">
|
||||
|
@ -111,7 +29,7 @@
|
|||
<tbody>
|
||||
{{#each artifacts}}
|
||||
<tr class="file">
|
||||
{{#if this.expired}}
|
||||
{{#if expired}}
|
||||
<td>
|
||||
<svg
|
||||
class="expired"
|
||||
|
@ -120,27 +38,27 @@
|
|||
version="1.1"
|
||||
viewBox="0 0 317 259"
|
||||
><use xlink:href="#folder"></use></svg>
|
||||
<span class="name light">{{this.name}}</span>
|
||||
<span class="name light">{{name}}</span>
|
||||
</td>
|
||||
{{else}}
|
||||
<td>
|
||||
<a href="{{this.url}}">
|
||||
<a href="{{url}}">
|
||||
<svg
|
||||
width="1.5em"
|
||||
height="1em"
|
||||
version="1.1"
|
||||
viewBox="0 0 317 259"
|
||||
><use xlink:href="#folder"></use></svg>
|
||||
<span class="name">{{this.name}}</span>
|
||||
<span class="name">{{name}}</span>
|
||||
</a>
|
||||
</td>
|
||||
{{/if}}
|
||||
<td>{{this.size}}</td>
|
||||
<td>{{size}}</td>
|
||||
<td>
|
||||
{{#if this.expired}}
|
||||
{{#if expired}}
|
||||
—
|
||||
{{else}}
|
||||
<a href="{{this.download_url}}" rel="noopener noreferrer">Download</a>
|
||||
<a href="{{download_url}}" rel="noopener noreferrer">Download</a>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -150,32 +68,17 @@
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
|
||||
{{#> partial/footer ~}}
|
||||
<script>
|
||||
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
|
||||
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
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{~/partial/footer }}
|
||||
|
|
Loading…
Reference in a new issue