diff --git a/.env.example b/.env.example index 4c3da3c..abc9179 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/artifact.yaml b/.forgejo/workflows/artifact.yaml new file mode 100644 index 0000000..28870dd --- /dev/null +++ b/.forgejo/workflows/artifact.yaml @@ -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 diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 9ca8bff..cda59f0 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -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<> "$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') }} diff --git a/Cargo.lock b/Cargo.lock index 323af5f..ddb1bfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 495d81b..e529eee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,15 @@ async_zip = { path = "crates/async_zip", features = [ "tokio-fs", "deflate", ] } -axum = { version = "0.7.5", features = ["http2"] } +axum = { version = "0.7.5", default-features = false, features = [ + "http1", + "http2", + "json", + "tokio", + "tracing", +] } axum-extra = { version = "0.9.3", features = ["typed-header"] } +comrak = { version = "0.24.1", default-features = false } dotenvy = "0.15.7" envy = { path = "crates/envy" } flate2 = "1.0.30" @@ -49,6 +56,14 @@ serde = { version = "1.0.203", features = ["derive"] } serde-env = "0.1.1" serde-hex = "0.1.0" serde_json = "1.0.117" +serde_urlencoded = "0.7.1" +syntect = { version = "5.2.0", default-features = false, features = [ + "parsing", + "default-syntaxes", + "default-themes", + "html", + "regex-onig", +] } thiserror = "1.0.61" tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] } tokio-util = { version = "0.7.11", features = ["io"] } diff --git a/README.md b/README.md index 6d078a3..bd9424a 100644 --- a/README.md +++ b/README.md @@ -6,35 +6,136 @@ Forgejo and GitHub's CI systems allow you to upload files and directories as [artifacts](https://github.com/actions/upload-artifact). These can be downloaded as zip files. However there is no simple way to view individual files of an artifact. -Artifactview is a small web application that can fetch these CI artifacts and serve -their contents. If the artifact contains a website, it is displayed normally, if it consists -of other files, a file listing is shown. +Artifactview is a small web application that fetches these CI artifacts and displays +their contents. -There is also full support for single page applications, placing a file named `200.html` in the -root directory it will be returned in case no file exists for the requested path. +It offers full support for single page applications and custom 404 error pages. +Single-page applications require a file named `200.html` placed in the root directory, +which will be served in case no file exists for the requested path. A custom 404 error +page is defined using a file named `404.html` in the root directory. -Alternatively, if a file named `404.html` exists in the root directory, it will be returned with -status code 404 if no file was found. +Artifactview displays a file listing if there is no `index.html` or fallback page +present, so you can browse artifacts that dont contain websites. + +![Artifact file listing](resources/screenshotFiles.png) ## How to use -Artifactview accepts URLs in the given format: `-------.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
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.
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)
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
Example: `gh => github.com;cb => codeberg.org` | + +## Technical details + +### URL format + +Artifactview uses URLs in the given format for accessing the individual artifacts: +`-------.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. diff --git a/resources/content.css b/resources/content.css new file mode 100644 index 0000000..53d7877 --- /dev/null +++ b/resources/content.css @@ -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; +} diff --git a/resources/screenshotFiles.png b/resources/screenshotFiles.png new file mode 100644 index 0000000..53052e2 Binary files /dev/null and b/resources/screenshotFiles.png differ diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..df823db --- /dev/null +++ b/resources/style.css @@ -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; + } +} diff --git a/src/app.rs b/src/app.rs index 4db3274..c1460b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, ops::Bound, path::PathBuf, str::FromStr, sync::Arc}; +use std::{net::SocketAddr, ops::Bound, path::Path, str::FromStr, sync::Arc}; use async_zip::tokio::read::ZipEntryReader; use axum::{ @@ -6,10 +6,11 @@ use axum::{ extract::{Host, Request, State}, http::{Response, Uri}, response::{IntoResponse, Redirect}, - routing::{any, get, post}, - Form, Router, + routing::{any, get}, + Router, }; -use headers::HeaderMapExt; +use futures_lite::AsyncReadExt as LiteAsyncReadExt; +use headers::{ContentType, HeaderMapExt}; use http::{HeaderMap, StatusCode}; use serde::Deserialize; use tokio::{ @@ -31,9 +32,10 @@ use crate::{ config::Config, error::Error, gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN}, - query::Query, + query::{ArtifactQuery, Query, RunQuery}, templates::{self, ArtifactItem, LinkItem}, util::{self, ErrorJson, ResponseBuilderExt}, + viewer::Viewers, App, }; @@ -46,6 +48,7 @@ struct AppInner { cfg: Config, cache: Cache, api: ArtifactApi, + viewers: Viewers, } impl Default for App { @@ -54,13 +57,22 @@ impl Default for App { } } -#[derive(Deserialize)] -struct UrlForm { - url: String, +#[derive(Default, Deserialize)] +struct FileQparams { + viewer: Option, } 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::(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::, _>>()?, + 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, - Host(host): Host, - Form(url): Form, - ) -> Result { - let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; + async fn get_homepage(state: AppState, uri: Uri) -> Result, 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, + } + + if let Some(params) = uri + .query() + .and_then(|q| serde_urlencoded::from_str::(q).ok()) + { + let query = RunQuery::from_forge_url(¶ms.url, &state.i.cfg.load().site_aliases)?; + let artifacts = state.i.api.list(&query).await?; + + if artifacts.is_empty() { + Err(Error::NotFound("artifacts".into())) + } else if let Some(artifact) = params + .name + .and_then(|n| artifacts.iter().find(|a| a.name == n)) + { + Ok(Redirect::to( + &state + .i + .cfg + .url_with_subdomain(&query.subdomain_with_artifact(artifact.id)), + ) + .into_response()) + } else { + let tmpl = templates::Selection { + main_url: state.i.cfg.main_url(), + 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::>(), + }; + 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, + zip_path: &Path, + query: &ArtifactQuery, + res: &GetFileResultFile, + viewer: &str, + path: &str, + ) -> Result, 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, - zip_path: PathBuf, + zip_path: &Path, res: GetFileResultFile, hdrs: &HeaderMap, ) -> Result, Error> { @@ -393,9 +484,9 @@ impl App { Host(host): Host, ) -> Result, 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, 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, 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, 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 { + 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 +} diff --git a/src/artifact_api.rs b/src/artifact_api.rs index 945ab06..7a48b76 100644 --- a/src/artifact_api.rs +++ b/src/artifact_api.rs @@ -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(self, query: &QueryData) -> 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(self, id: u64, query: &QueryData) -> 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(&self, query: &QueryData) -> Result> { - let subdomain = query.subdomain_with_artifact(None)?; + pub async fn list(&self, query: &RunQuery) -> Result> { + 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(&self, query: &QueryData) -> Result> { + async fn list_forgejo(&self, query: QueryRef<'_>) -> Result> { 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(&self, query: &QueryData) -> Result> { + async fn list_github(&self, query: QueryRef<'_>) -> Result> { 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::() .await?; - Ok(artifact.into_artifact(query)) + Ok(artifact.into_artifact(query.as_ref())) } async fn handle_github_error(resp: Response) -> Result { @@ -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(); diff --git a/src/cache.rs b/src/cache.rs index f3db3ec..8e3bb9e 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -65,6 +65,7 @@ pub enum GetFileResult { } pub struct GetFileResultFile { + pub filename: Option, pub file: FileEntry, pub mime: Option, pub status: StatusCode, @@ -114,7 +115,7 @@ impl Cache { query: &ArtifactQuery, ip: &IpAddr, ) -> Result { - 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 for Size { + fn from(value: u32) -> Self { + Self(value) + } +} diff --git a/src/config.rs b/src/config.rs index 8047516..aca3733 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 `.` @@ -65,12 +67,15 @@ pub struct ConfigData { pub repo_whitelist: QueryFilterList, /// Aliases for sites (Example: `gh => github.com`) pub site_aliases: HashMap, + /// Maximum file size for the viewer + pub viewer_max_size: Option, } 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) { diff --git a/src/error.rs b/src/error.rs index 336cc47..357084c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 for Error { diff --git a/src/lib.rs b/src/lib.rs index c3adf4a..f8d8a9a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,5 +7,6 @@ mod gzip_reader; mod query; mod templates; mod util; +mod viewer; pub struct App; diff --git a/src/query.rs b/src/query.rs index 5e9d872..f3993e7 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, fmt::Write, str::FromStr}; +use std::{collections::HashMap, str::FromStr}; use once_cell::sync::Lazy; use regex::{Captures, Regex}; @@ -6,53 +6,113 @@ use serde::{de::Visitor, Deserialize}; use crate::{ error::{Error, Result}, + templates::LinkItem, util, }; #[derive(Debug, PartialEq, Eq)] -pub enum Query { - Artifact(ArtifactQuery), - Run(RunQuery), -} - -pub type RunQuery = QueryData<()>; -pub type ArtifactQuery = QueryData; - -#[derive(Debug, PartialEq, Eq)] -pub struct QueryData { +pub struct ArtifactQuery { /// Forge host pub host: String, /// Host alias if the query was constructed using one - pub host_alias: Option, + host_alias: Option, /// 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, + /// 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 = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap()); -impl Query { +impl ArtifactQuery { pub fn from_subdomain(subdomain: &str, aliases: &HashMap) -> Result { let segments = subdomain.split("--").collect::>(); if segments.len() != 4 { return Err(Error::InvalidUrl); } - let run_and_artifact = segments[3].split('-').collect::>(); - 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) -> Result { 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::().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 { - 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 { - match self { - Query::Artifact(q) => Ok(q), - Query::Run(_) => Err(Error::BadRequest("no artifact specified".into())), - } - } -} - -impl ArtifactQuery { - pub fn subdomain(&self) -> Result { - 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 { - self.subdomain_with_artifact(None) - } -} - -impl QueryData { - pub fn _subdomain(&self, artifact: Option, 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) -> Result { - 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 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(&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] diff --git a/src/templates.rs b/src/templates.rs index bc36b21..56e6618 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -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, pub n_dirs: usize, pub n_files: usize, pub has_parent: bool, + pub publisher: LinkItem, + pub viewer_max_size: u32, pub entries: Vec, } +#[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, + pub publisher: LinkItem, + pub lines: usize, + pub size: Size, + pub viewers: Vec, + 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( - artifact: Artifact, - query: &QueryData, - cfg: &Config, - ) -> Result { - 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")) + } } } diff --git a/src/viewer/code.rs b/src/viewer/code.rs new file mode 100644 index 0000000..3a56c31 --- /dev/null +++ b/src/viewer/code.rs @@ -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, +} + +impl CodeViewer { + pub fn new(ss: Arc) -> 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 { + 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!( + "
{}
", + 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(); + } + */ +} diff --git a/src/viewer/markdown.rs b/src/viewer/markdown.rs new file mode 100644 index 0000000..624e79b --- /dev/null +++ b/src/viewer/markdown.rs @@ -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) -> 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 { + 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!("
{html}
")) + } +} + +struct SyntectAdapter { + ss: Arc, +} + +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, + ) -> std::io::Result<()> { + output.write_all(b"
")
+    }
+
+    fn write_code_tag(
+        &self,
+        output: &mut dyn Write,
+        _attributes: HashMap,
+    ) -> std::io::Result<()> {
+        output.write_all(b"")
+    }
+}
diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs
new file mode 100644
index 0000000..18a53a7
--- /dev/null
+++ b/src/viewer/mod.rs
@@ -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;
+}
+
+pub struct Viewers {
+    viewers: [Box; 2],
+}
+
+pub struct RenderRes {
+    pub html: String,
+    pub tmpl_viewers: Vec,
+}
+
+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 {
+        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 {
+        self.viewers
+            .iter()
+            .filter(|v| v.is_applicable(filename, ext))
+            .map(|v| ViewerLink {
+                id: v.id(),
+                name: v.name(),
+                selected: v.id() == viewer,
+            })
+            .collect()
+    }
+}
diff --git a/templates/error.hbs b/templates/error.hbs
index 2e42d4c..17af881 100644
--- a/templates/error.hbs
+++ b/templates/error.hbs
@@ -1,21 +1,19 @@
+
 
-
   
     
     
     
     Artifactview
diff --git a/templates/index.hbs b/templates/index.hbs
index 36cbab6..baee8b0 100644
--- a/templates/index.hbs
+++ b/templates/index.hbs
@@ -1,28 +1,6 @@
-
-  
-    
-    
-    
-    Artifactview
-  
-  
+{{#> partial/header ~}}
+  Artifactview
+{{~/partial/header }}
     

Enter a GitHub/Gitea/Forgejo Actions run url to browse CI artifacts

-
+ @@ -59,7 +39,7 @@ > Artifactview - {{version}} + {{~crate::app::VERSION}}

Disclaimer: Artifactview does not host any websites, the data is fetched from the respective diff --git a/templates/listing.hbs b/templates/listing.hbs index 5c17850..e97c97d 100644 --- a/templates/listing.hbs +++ b/templates/listing.hbs @@ -1,112 +1,20 @@ - - - - - - - Index: - {{artifact_name}} - - - - - - +{{#> partial/header ~}} + Index: {{artifact_name}} +{{~/partial/header }} + {{> partial/fileIcons }}

- - - + {{> partial/logoLink }}

- {{#each path_components}}{{this.name}} /{{/each}} + {{#each path_components}}{{name}}{{/each}}

-
+
@@ -130,40 +38,34 @@ — {{/if}} + {{ let vms = viewer_max_size }} {{#each entries}} - - - {{this.name}} + + + {{name}} - {{#if this.is_dir}}—{{else}}{{this.size}}{{/if}} - {{#if this.is_dir}}—{{else}}{{this.crc32}}{{/if}} + {{#if is_dir}}—{{else}}{{size}}{{/if}} + {{#if is_dir}}—{{else}}{{crc32}}{{/if}} {{/each}}
- - +{{#> partial/footer ~}} - - +{{~/partial/footer }} diff --git a/templates/partial/fileIcons.hbs b/templates/partial/fileIcons.hbs new file mode 100644 index 0000000..a5ac4d9 --- /dev/null +++ b/templates/partial/fileIcons.hbs @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/templates/partial/footer.hbs b/templates/partial/footer.hbs new file mode 100644 index 0000000..9a34589 --- /dev/null +++ b/templates/partial/footer.hbs @@ -0,0 +1,13 @@ +
+ Served with Artifactview {{ crate::app::VERSION }} +

+ Disclaimer: 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, {{publisher.name}}, + is the only one responsible for the content. + Most forges delete artifacts after 90 days. +

+
+ {{> @partial-block }} + + diff --git a/templates/partial/header.hbs b/templates/partial/header.hbs new file mode 100644 index 0000000..6db3453 --- /dev/null +++ b/templates/partial/header.hbs @@ -0,0 +1,10 @@ + + + + + + + {{> @partial-block }} + + + diff --git a/templates/partial/logo.hbs b/templates/partial/logo.hbs new file mode 100644 index 0000000..b5bd59f --- /dev/null +++ b/templates/partial/logo.hbs @@ -0,0 +1 @@ + diff --git a/templates/partial/logoLink.hbs b/templates/partial/logoLink.hbs new file mode 100644 index 0000000..8a314ff --- /dev/null +++ b/templates/partial/logoLink.hbs @@ -0,0 +1,3 @@ + + {{> ./logo size="32" }} + diff --git a/templates/preview.hbs b/templates/preview.hbs new file mode 100644 index 0000000..7498cc6 --- /dev/null +++ b/templates/preview.hbs @@ -0,0 +1,32 @@ +{{#> partial/header ~}} + + {{filename}} +{{~/partial/header }} +
+ {{> partial/logoLink }} +

+ {{#each path_components}}{{name}} /{{/each}} + {{filename}} +

+
+ +
+ +
+ {{{body}}} +
+
+{{#> partial/footer ~}} +{{~/partial/footer }} diff --git a/templates/selection.hbs b/templates/selection.hbs index 98bfb0f..f283c82 100644 --- a/templates/selection.hbs +++ b/templates/selection.hbs @@ -1,102 +1,20 @@ - - - - - - - Artifacts: - {{run_name}} - - - - - - +{{#> partial/header ~}} + Artifacts: {{run_name}} +{{~/partial/header }} + {{> partial/fileIcons }}
- - - + {{> partial/logoLink }}

- {{run_name}} + {{run_name}} /

-
-
+
@@ -111,7 +29,7 @@ {{#each artifacts}} - {{#if this.expired}} + {{#if expired}} - {{this.name}} + {{name}} {{else}} - + - {{this.name}} + {{name}} {{/if}} - {{this.size}} + {{size}} - {{#if this.expired}} + {{#if expired}} — {{else}} - Download + Download {{/if}} @@ -150,32 +68,17 @@
-
- Served with - Artifactview - {{version}} -

- Disclaimer: 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, - {{publisher.name}}, - is the only one responsible for the content. - Most forges delete artifacts after 90 days. -

-
- +{{#> partial/footer ~}} - - +{{~/partial/footer }}