Compare commits

..

8 commits

30 changed files with 1987 additions and 667 deletions

View file

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

View 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

View file

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

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

View file

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

@ -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
View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

229
resources/style.css Normal file
View 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;
}
}

View file

@ -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,153 +139,228 @@ 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 {
state.garbage_collect();
}
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)
.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 tmpl = templates::Listing {
main_url: state.i.cfg.main_url(),
version: templates::Version,
run_url: &query.forge_url(),
artifact_name: &entry.name,
path_components,
n_dirs: listing.n_dirs,
n_files: listing.n_files,
has_parent: listing.has_parent,
entries: listing.entries,
};
Ok(Response::builder()
.typed_header(headers::ContentType::html())
.cache_immutable()
.body(tmpl.to_string().into())?)
}
Err(Error::NotFound(e)) => {
if path == FAVICON_PATH {
Self::favicon()
} else {
Err(Error::NotFound(e))
}
}
Err(e) => Err(e),
let entry_res = state.i.cache.get_entry(&state.i.api, &query, &ip).await?;
let entry = entry_res.entry;
if entry_res.downloaded {
state.garbage_collect();
}
match entry.get_file(&path, uri.query().unwrap_or_default()) {
Ok(GetFileResult::File(res)) => {
let qparams = uri
.query()
.and_then(|q| serde_urlencoded::from_str::<FileQparams>(q).ok())
.unwrap_or_default();
if res.filename.is_some() {
if let Some(viewer) = qparams.viewer {
match Self::try_view_file(
&state,
&entry,
&entry_res.zip_path,
&query,
&res,
&viewer,
&path,
)
.await
{
Ok(resp) => return Ok(resp),
Err(e) => {
tracing::error!("{e}")
}
}
}
}
Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs).await
}
Query::Run(query) => {
let artifacts = state.i.api.list(&query).await?;
Ok(GetFileResult::Listing(listing)) => {
if !path.ends_with('/') {
return Ok(Redirect::to(&format!("{path}/")).into_response());
}
if uri.path() == FAVICON_PATH {
return Self::favicon();
}
if uri.path() != "/" {
return Err(Error::NotFound("path".into()));
}
if artifacts.is_empty() {
return Err(Error::NotFound("artifacts".into()));
}
let tmpl = templates::Selection {
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_name: &query.shortid(),
publisher: LinkItem {
name: query.user.to_owned(),
url: format!("https://{}/{}", query.host, query.user),
},
artifacts: artifacts
.into_iter()
.map(|a| ArtifactItem::from_artifact(a, &query, &state.i.cfg))
.collect::<Result<Vec<_>, _>>()?,
run_url: &run_url,
artifact_name: &entry.name,
path_components: path_components(
&query,
state.i.cfg.main_url(),
&run_url,
&entry.name,
&path,
),
n_dirs: listing.n_dirs,
n_files: listing.n_files,
has_parent: listing.has_parent,
publisher: query.publisher(),
viewer_max_size: state
.i
.cfg
.load()
.viewer_max_size
.map(u32::from)
.unwrap_or(u32::MAX),
entries: listing.entries,
};
Ok(Response::builder()
.typed_header(headers::ContentType::html())
.cache()
.body(tmpl.to_string().into())?)
}
Err(Error::NotFound(e)) => {
if path == FAVICON_PATH {
Self::favicon()
} else {
Err(Error::NotFound(e))
}
}
Err(e) => Err(e),
}
}
}
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)?;
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 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))
#[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(&params.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(),
run_url: &query.forge_url(),
run_name: &query.shortid(),
publisher: query.publisher(),
artifacts: artifacts
.into_iter()
.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())?)
}
} 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
}

View file

@ -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();

View file

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

View file

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

View file

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

View file

@ -7,5 +7,6 @@ mod gzip_reader;
mod query;
mod templates;
mod util;
mod viewer;
pub struct App;

View file

@ -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 {
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: (),
}),
Ok(ArtifactQuery {
host,
host_alias,
user,
repo,
run,
artifact,
})
}
pub fn cache_key(&self) -> String {
format!(
"{}--{}--{}--{}-{}",
encode_domain(&self.host, '.'),
encode_domain(&self.user, '-'),
encode_domain(&self.repo, '-'),
self.run,
self.artifact,
)
}
}
impl RunQuery {
pub fn from_forge_url(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
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]

View file

@ -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,24 +78,14 @@ 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"))
}
}
}

83
src/viewer/code.rs Normal file
View 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
View 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
View 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()
}
}

View file

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

View file

@ -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>
<title>Artifactview</title>
</head>
<body>
{{#> partial/header ~}}
<title>Artifactview</title>
{{~/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

View file

@ -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>&mdash;</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}}&mdash;{{else}}{{this.size}}{{/if}}</td>
<td>{{#if this.is_dir}}&mdash;{{else}}{{this.crc32}}{{/if}}</td>
<td>{{#if is_dir}}&mdash;{{else}}{{size}}{{/if}}</td>
<td>{{#if is_dir}}&mdash;{{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
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
var filterEl = document.getElementById("filter");
function initFilter() { if (!filterEl.value) { var filterParam = new URL(window.location.href).searchParams.get("filter"); if (filterParam) { filterEl.value = filterParam } } filter() }
function filter() { var q = filterEl.value.trim().toLowerCase(); var elems = document.querySelectorAll("tr.file"); elems.forEach(function (el) { if (!q) { el.style.display = ""; return } var nameEl = el.querySelector("td"); var nameVal = nameEl.textContent.trim().toLowerCase(); if (nameVal.indexOf(q) !== -1) { el.style.display = "" } else { el.style.display = "none" } }) }
document.addEventListener("DOMContentLoaded", initFilter);
// @license-end
// @license-end
</script>
</body>
</html>
{{~/partial/footer }}

View 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

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

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

View 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

View 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
View 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 }}

View file

@ -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}}
&mdash;
{{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
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
var filterEl = document.getElementById("filter");
function initFilter() { if (!filterEl.value) { var filterParam = new URL(window.location.href).searchParams.get("filter"); if (filterParam) { filterEl.value = filterParam } } filter() }
function filter() { var q = filterEl.value.trim().toLowerCase(); var elems = document.querySelectorAll("tr.file"); elems.forEach(function (el) { if (!q) { el.style.display = ""; return } var nameEl = el.querySelector("td"); var nameVal = nameEl.textContent.trim().toLowerCase(); if (nameVal.indexOf(q) !== -1) { el.style.display = "" } else { el.style.display = "none" } }) }
document.addEventListener("DOMContentLoaded", initFilter);
// @license-end
// @license-end
</script>
</body>
</html>
{{~/partial/footer }}