diff --git a/.env.example b/.env.example index abc9179..4c3da3c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -NO_HTTPS=1 +CACHE_DIR=/tmp/artifactview +MAX_ARTIFACT_SIZE=100000000 +MAX_AGE_H=12 # If you only want to access public repositories, # create a fine-grained token with Public Repositories (read-only) access -# GITHUB_TOKEN=github_pat_123456 -SITE_ALIASES=gh=>github.com;cb=>codeberg.org +GITHUB_TOKEN=github_pat_123456 diff --git a/.forgejo/workflows/artifact.yaml b/.forgejo/workflows/artifact.yaml deleted file mode 100644 index 28870dd..0000000 --- a/.forgejo/workflows/artifact.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 cda59f0..9ca8bff 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -25,6 +25,8 @@ jobs: steps: - name: 👁️ Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 # important to fetch tag logs - name: ⚒️ Build application run: | @@ -47,7 +49,11 @@ 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 - awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' CHANGELOG.md >> "$GITHUB_ENV" + { + echo 'CHANGELOG<> "$GITHUB_ENV" - name: 🎉 Publish release if: ${{ startsWith(github.ref, 'refs/tags/v') }} diff --git a/Cargo.lock b/Cargo.lock index ddb1bfd..323af5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,7 +146,6 @@ dependencies = [ "async_zip", "axum", "axum-extra", - "comrak", "dotenvy", "envy", "flate2", @@ -172,8 +171,6 @@ dependencies = [ "serde-env", "serde-hex", "serde_json", - "serde_urlencoded", - "syntect", "thiserror", "tokio", "tokio-util", @@ -267,6 +264,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", "tower", @@ -352,15 +350,6 @@ 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" @@ -376,12 +365,6 @@ 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" @@ -481,22 +464,6 @@ 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" @@ -559,41 +526,6 @@ 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" @@ -622,37 +554,6 @@ 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" @@ -666,12 +567,6 @@ 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" @@ -695,12 +590,6 @@ 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" @@ -1182,12 +1071,6 @@ 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" @@ -1444,35 +1327,13 @@ 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 2.5.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1672,7 +1533,7 @@ checksum = "31b476131c3c86cb68032fdc5cb6d5a1045e3e42d96b69fa599fd77701e1f5bf" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.5.0", + "bitflags", "lazy_static", "num-traits", "rand", @@ -1771,7 +1632,7 @@ version = "11.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e29830cbb1290e404f24c73af91c5d8d631ce7e128691e9477556b540cd01ecd" dependencies = [ - "bitflags 2.5.0", + "bitflags", ] [[package]] @@ -1780,7 +1641,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" dependencies = [ - "bitflags 2.5.0", + "bitflags", ] [[package]] @@ -1929,7 +1790,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2014,15 +1875,6 @@ 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" @@ -2044,7 +1896,7 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" dependencies = [ - "bitflags 2.5.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -2191,16 +2043,6 @@ 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" @@ -2241,12 +2083,6 @@ 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" @@ -2287,26 +2123,6 @@ 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" @@ -2487,6 +2303,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2495,7 +2312,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.5.0", + "bitflags", "bytes", "http", "http-body", @@ -2524,6 +2341,7 @@ 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", @@ -2581,12 +2399,6 @@ 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" @@ -2641,12 +2453,6 @@ 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" @@ -2713,16 +2519,6 @@ 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" @@ -2852,15 +2648,6 @@ 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 e529eee..495d81b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,15 +21,8 @@ async_zip = { path = "crates/async_zip", features = [ "tokio-fs", "deflate", ] } -axum = { version = "0.7.5", default-features = false, features = [ - "http1", - "http2", - "json", - "tokio", - "tracing", -] } +axum = { version = "0.7.5", features = ["http2"] } 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" @@ -56,14 +49,6 @@ 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 bd9424a..6d078a3 100644 --- a/README.md +++ b/README.md @@ -6,136 +6,35 @@ 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 fetches these CI artifacts and displays -their contents. +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. -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. +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. -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) +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. ## How to use -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` +Artifactview accepts URLs in the given format: `-------.example.com` Example: `https://github-com--theta-dev--example-project--4-11.example.com` -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. +## Security considerations -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. +It is recommended to use the whitelist feature to limit 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 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 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. 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 deleted file mode 100644 index 53d7877..0000000 --- a/resources/content.css +++ /dev/null @@ -1,453 +0,0 @@ -/* 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 deleted file mode 100644 index 53052e2..0000000 Binary files a/resources/screenshotFiles.png and /dev/null differ diff --git a/resources/style.css b/resources/style.css deleted file mode 100644 index df823db..0000000 --- a/resources/style.css +++ /dev/null @@ -1,229 +0,0 @@ -/* 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 c1460b3..4db3274 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, ops::Bound, path::Path, str::FromStr, sync::Arc}; +use std::{net::SocketAddr, ops::Bound, path::PathBuf, str::FromStr, sync::Arc}; use async_zip::tokio::read::ZipEntryReader; use axum::{ @@ -6,11 +6,10 @@ use axum::{ extract::{Host, Request, State}, http::{Response, Uri}, response::{IntoResponse, Redirect}, - routing::{any, get}, - Router, + routing::{any, get, post}, + Form, Router, }; -use futures_lite::AsyncReadExt as LiteAsyncReadExt; -use headers::{ContentType, HeaderMapExt}; +use headers::HeaderMapExt; use http::{HeaderMap, StatusCode}; use serde::Deserialize; use tokio::{ @@ -32,10 +31,9 @@ use crate::{ config::Config, error::Error, gzip_reader::{PrecompressedGzipReader, GZIP_EXTRA_LEN}, - query::{ArtifactQuery, Query, RunQuery}, + query::Query, templates::{self, ArtifactItem, LinkItem}, util::{self, ErrorJson, ResponseBuilderExt}, - viewer::Viewers, App, }; @@ -48,7 +46,6 @@ struct AppInner { cfg: Config, cache: Cache, api: ArtifactApi, - viewers: Viewers, } impl Default for App { @@ -57,22 +54,13 @@ impl Default for App { } } -#[derive(Default, Deserialize)] -struct FileQparams { - viewer: Option, +#[derive(Deserialize)] +struct UrlForm { + url: 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 { @@ -84,16 +72,11 @@ 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 @@ -110,6 +93,7 @@ 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 @@ -139,228 +123,153 @@ impl App { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; if subdomain.is_empty() { - Self::get_homepage(state, uri).await + // 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())?) } else { - let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; + let query = Query::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())?; - 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 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)) => { - 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}") - } + 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), } - 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()); + Query::Run(query) => { + let artifacts = state.i.api.list(&query).await?; + + if uri.path() == FAVICON_PATH { + return Self::favicon(); } - - let run_url = query.forge_url(); - let tmpl = templates::Listing { + if uri.path() != "/" { + return Err(Error::NotFound("path".into())); + } + if artifacts.is_empty() { + return Err(Error::NotFound("artifacts".into())); + } + let tmpl = templates::Selection { main_url: state.i.cfg.main_url(), - 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, + 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::, _>>()?, }; - 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 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())); - } + 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)?; - #[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())?) - } + if subdomain.is_empty() { + let query = Query::from_forge_url(&url.url, &state.i.cfg.load().site_aliases)?; + let subdomain = query.subdomain()?; + let target = format!( + "{}{}.{}", + state.i.cfg.url_proto(), + subdomain, + state.i.cfg.load().root_domain + ); + Ok(Redirect::to(&target)) } else { - Ok(Response::builder() - .typed_header(headers::ContentType::html()) - .cache() - .body( - templates::Index { - main_url: state.i.cfg.main_url(), - } - .to_string() - .into(), - )?) + Err(Error::MethodNotAllowed) } } - 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: &Path, + zip_path: PathBuf, res: GetFileResultFile, hdrs: &HeaderMap, ) -> Result, Error> { @@ -484,9 +393,9 @@ impl App { Host(host): Host, ) -> Result, ErrorJson> { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; - let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; + let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; - let artifacts = state.i.api.list(&query.into()).await?; + let artifacts = state.i.api.list(&query.into_runquery()).await?; Ok(Response::builder().cache().json(&artifacts)?) } @@ -496,9 +405,9 @@ impl App { Host(host): Host, ) -> Result, ErrorJson> { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; - let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; + let query = Query::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; - let artifact = state.i.api.fetch(&query).await?; + let artifact = state.i.api.fetch(&query.try_into_artifactquery()?).await?; Ok(Response::builder().cache().json(&artifact)?) } @@ -510,9 +419,13 @@ 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 = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; + let query = Query::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, &ip).await?; + let entry_res = state + .i + .cache + .get_entry(&state.i.api, &query.try_into_artifactquery()?, &ip) + .await?; if entry_res.downloaded { state.garbage_collect(); } @@ -529,13 +442,6 @@ 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 { @@ -544,12 +450,7 @@ impl AppState { let cache = Cache::new(cfg.clone()); let api = ArtifactApi::new(cfg.clone()); Ok(Self { - i: Arc::new(AppInner { - cfg, - cache, - api, - viewers: Viewers::new(), - }), + i: Arc::new(AppInner { cfg, cache, api }), }) } @@ -563,32 +464,3 @@ 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 7a48b76..945ab06 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, Query, QueryRef, RunQuery}, + query::{ArtifactQuery, QueryData}, }; pub struct ArtifactApi { @@ -69,7 +69,7 @@ enum ForgejoArtifactStatus { } impl GithubArtifact { - fn into_artifact(self, query: QueryRef<'_>) -> Artifact { + fn into_artifact(self, query: &QueryData) -> Artifact { Artifact { id: self.id, name: self.name, @@ -85,7 +85,7 @@ impl GithubArtifact { } impl ForgejoArtifact { - fn into_artifact(self, id: u64, query: QueryRef<'_>) -> Artifact { + fn into_artifact(self, id: u64, query: &QueryData) -> Artifact { Artifact { download_url: format!( "https://{}/{}/{}/actions/runs/{}/artifacts/{}", @@ -116,14 +116,14 @@ impl ArtifactApi { } } - pub async fn list(&self, query: &RunQuery) -> Result> { - let cache_key = query.cache_key(); + pub async fn list(&self, query: &QueryData) -> Result> { + let subdomain = query.subdomain_with_artifact(None)?; self.qc - .get_or_insert_async(&cache_key, async { + .get_or_insert_async(&subdomain, async { if query.is_github() { - self.list_github(query.as_ref()).await + self.list_github(query).await } else { - self.list_forgejo(query.as_ref()).await + self.list_forgejo(query).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.as_ref()).await?; + let mut artifacts = self.list_forgejo(query).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: QueryRef<'_>) -> Result> { + async fn list_forgejo(&self, query: &QueryData) -> 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: QueryRef<'_>) -> Result> { + async fn list_github(&self, query: &QueryData) -> 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.as_ref())) + Ok(artifact.into_artifact(query)) } async fn handle_github_error(resp: Response) -> Result { @@ -281,19 +281,20 @@ 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::from_subdomain( - "code-thetadev-de--hsa--visitenbuch--32-1", - &HashMap::new(), - ) - .unwrap(); + 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 api = ArtifactApi::new(Config::default()); let res = api.fetch(&query).await.unwrap(); @@ -303,11 +304,14 @@ mod tests { #[tokio::test] async fn fetch_github() { - let query = ArtifactQuery::from_subdomain( - "github-com--actions--upload-artifact--8805345396-1440556464", - &HashMap::new(), - ) - .unwrap(); + 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 api = ArtifactApi::new(Config::default()); let res = api.fetch(&query).await.unwrap(); diff --git a/src/cache.rs b/src/cache.rs index 8e3bb9e..f3db3ec 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -65,7 +65,6 @@ pub enum GetFileResult { } pub struct GetFileResultFile { - pub filename: Option, pub file: FileEntry, pub mime: Option, pub status: StatusCode, @@ -115,7 +114,7 @@ impl Cache { query: &ArtifactQuery, ip: &IpAddr, ) -> Result { - let subdomain = query.cache_key(); + let subdomain = query.subdomain_noalias(); let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip")); let downloaded = !zip_path.is_file(); if downloaded { @@ -281,7 +280,6 @@ 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, @@ -296,7 +294,6 @@ 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, @@ -331,7 +328,6 @@ 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, @@ -379,7 +375,7 @@ impl CacheEntry { directories.push(ListingEntry { name: n.to_owned(), url: format!("{n}{path}"), - size: 0.into(), + size: Size(0), crc32: "-".to_string(), is_dir: true, }); @@ -387,7 +383,7 @@ impl CacheEntry { files.push(ListingEntry { name: n.to_owned(), url: format!("{n}{path}"), - size: entry.uncompressed_size.into(), + size: Size(entry.uncompressed_size), crc32: hex::encode(entry.crc32.to_le_bytes()), is_dir: false, }); @@ -415,9 +411,3 @@ impl CacheEntry { } } } - -impl From for Size { - fn from(value: u32) -> Self { - Self(value) - } -} diff --git a/src/config.rs b/src/config.rs index aca3733..8047516 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use serde::Deserialize; use crate::{ error::{Error, Result}, - query::{ArtifactQuery, QueryFilterList}, + query::{Query, QueryFilterList}, }; #[derive(Clone)] @@ -27,8 +27,6 @@ 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 `.` @@ -67,15 +65,12 @@ 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()), @@ -90,7 +85,6 @@ 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()), } } } @@ -154,7 +148,7 @@ impl Config { &self.i.main_url } - pub fn check_filterlist(&self, query: &ArtifactQuery) -> Result<()> { + pub fn check_filterlist(&self, query: &Query) -> 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 357084c..336cc47 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,10 +41,6 @@ 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 f8d8a9a..c3adf4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,5 @@ 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 f3993e7..5e9d872 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, str::FromStr}; +use std::{collections::HashMap, fmt::Write, str::FromStr}; use once_cell::sync::Lazy; use regex::{Captures, Regex}; @@ -6,113 +6,53 @@ use serde::{de::Visitor, Deserialize}; use crate::{ error::{Error, Result}, - templates::LinkItem, util, }; #[derive(Debug, PartialEq, Eq)] -pub struct ArtifactQuery { - /// 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, - /// CI artifact id - pub artifact: u64, +pub enum Query { + Artifact(ArtifactQuery), + Run(RunQuery), } +pub type RunQuery = QueryData<()>; +pub type ArtifactQuery = QueryData; + #[derive(Debug, PartialEq, Eq)] -pub struct RunQuery { +pub struct QueryData { /// Forge host pub host: String, /// Host alias if the query was constructed using one - host_alias: Option, + pub 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), - } - } + // Optional selected artifact + pub artifact: T, } static RE_REPO_NAME: Lazy = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap()); -impl ArtifactQuery { +impl Query { 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_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)?; + let run = run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?; #[allow(clippy::assigning_clones)] if let Some(alias) = aliases.get(&host) { @@ -120,29 +60,26 @@ impl ArtifactQuery { host = alias.clone(); } - Ok(ArtifactQuery { - host, - host_alias, - user, - repo, - run, - artifact, + 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: (), + }), }) } - 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)?; @@ -167,74 +104,118 @@ impl RunQuery { return Err(Error::BadRequest("invalid repository name".into())); } - let host_alias = aliases + let host = aliases .iter() .find(|(_, v)| *v == host) - .map(|(k, _)| k.to_owned()); + .map(|(k, _)| k.to_owned()) + .unwrap_or_else(|| host.to_owned()); let run = path_segs .next() .and_then(|s| s.parse::().ok()) .ok_or(Error::BadRequest("no run ID".into()))?; - Ok(Self { - host: host.to_owned(), - host_alias, + Ok(Self::Run(RunQuery { + host, + host_alias: None, user, repo, run, - }) + artifact: (), + })) } - pub fn cache_key(&self) -> String { - format!( + 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!( "{}--{}--{}--{}", - encode_domain(&self.host, '.'), + encode_domain(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 ) } -} -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, + 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, run: self.run, - } - } -} - -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, + artifact: (), } } } @@ -344,11 +325,14 @@ impl FromStr for QueryFilter { } impl QueryFilter { - 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) + 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) } } @@ -365,7 +349,7 @@ impl FromStr for QueryFilterList { } impl QueryFilterList { - pub fn passes(&self, query: &ArtifactQuery, blacklist: bool) -> bool { + pub fn passes(&self, query: &Query, blacklist: bool) -> bool { if self.0.is_empty() { true } else { @@ -404,9 +388,9 @@ impl<'de> Deserialize<'de> for QueryFilterList { mod tests { use std::{collections::HashMap, str::FromStr}; - use crate::query::{Query, QueryFilter, QueryFilterList}; + use crate::query::{QueryFilter, QueryFilterList}; - use super::ArtifactQuery; + use super::{ArtifactQuery, Query}; use proptest::prelude::*; use rstest::rstest; @@ -442,19 +426,19 @@ mod tests { #[test] fn query_from_subdomain() { let d1 = "github-com--thetadev--newpipe-extractor--14-123"; - let query = ArtifactQuery::from_subdomain(d1, &HashMap::new()).unwrap(); + let query = Query::from_subdomain(d1, &HashMap::new()).unwrap(); assert_eq!( query, - ArtifactQuery { + Query::Artifact(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_with_artifact(query.artifact), d1); + assert_eq!(query.subdomain().unwrap(), d1); } #[rstest] diff --git a/src/templates.rs b/src/templates.rs index 56e6618..bc36b21 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -2,14 +2,18 @@ use crate::{ artifact_api::Artifact, cache::{ListingEntry, Size}, config::Config, - query::{Query, QueryRef}, + error::Result, + query::QueryData, }; use yarte::{Render, Template}; -#[derive(Template)] +#[derive(Default)] +pub struct Version; + +#[derive(Template, Default)] #[template(path = "index")] -pub struct Index<'a> { - pub main_url: &'a str, +pub struct Index { + pub version: Version, } #[derive(Template)] @@ -23,6 +27,7 @@ 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, @@ -33,37 +38,16 @@ 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, @@ -78,14 +62,24 @@ pub struct ArtifactItem { } impl ArtifactItem { - pub fn from_artifact(artifact: Artifact, query: QueryRef<'_>, cfg: &Config) -> Self { - Self { + pub fn from_artifact( + artifact: Artifact, + query: &QueryData, + cfg: &Config, + ) -> Result { + Ok(Self { name: artifact.name, - url: cfg.url_with_subdomain(&query.subdomain_with_artifact(artifact.id)), + url: cfg.url_with_subdomain(&query.subdomain_with_artifact(Some(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 deleted file mode 100644 index 3a56c31..0000000 --- a/src/viewer/code.rs +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 624e79b..0000000 --- a/src/viewer/markdown.rs +++ /dev/null @@ -1,103 +0,0 @@ -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
deleted file mode 100644
index 18a53a7..0000000
--- a/src/viewer/mod.rs
+++ /dev/null
@@ -1,88 +0,0 @@
-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 17af881..2e42d4c 100644
--- a/templates/error.hbs
+++ b/templates/error.hbs
@@ -1,19 +1,21 @@
-
 
+
   
     
     
     
     Artifactview
diff --git a/templates/index.hbs b/templates/index.hbs
index baee8b0..36cbab6 100644
--- a/templates/index.hbs
+++ b/templates/index.hbs
@@ -1,6 +1,28 @@
-{{#> partial/header ~}}
-  Artifactview
-{{~/partial/header }}
+
+  
+    
+    
+    
+    Artifactview
+  
+  
     

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

-
+ @@ -39,7 +59,7 @@ > Artifactview - {{~crate::app::VERSION}} + {{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 e97c97d..5c17850 100644 --- a/templates/listing.hbs +++ b/templates/listing.hbs @@ -1,20 +1,112 @@ -{{#> partial/header ~}} - Index: {{artifact_name}} -{{~/partial/header }} - {{> partial/fileIcons }} + + + + + + + Index: + {{artifact_name}} + + + + + +

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

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

-
-{{#> partial/footer ~}} + + -{{~/partial/footer }} + + diff --git a/templates/partial/fileIcons.hbs b/templates/partial/fileIcons.hbs deleted file mode 100644 index a5ac4d9..0000000 --- a/templates/partial/fileIcons.hbs +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - diff --git a/templates/partial/footer.hbs b/templates/partial/footer.hbs deleted file mode 100644 index 9a34589..0000000 --- a/templates/partial/footer.hbs +++ /dev/null @@ -1,13 +0,0 @@ -
- 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 deleted file mode 100644 index 6db3453..0000000 --- a/templates/partial/header.hbs +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - {{> @partial-block }} - - - diff --git a/templates/partial/logo.hbs b/templates/partial/logo.hbs deleted file mode 100644 index b5bd59f..0000000 --- a/templates/partial/logo.hbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/partial/logoLink.hbs b/templates/partial/logoLink.hbs deleted file mode 100644 index 8a314ff..0000000 --- a/templates/partial/logoLink.hbs +++ /dev/null @@ -1,3 +0,0 @@ - - {{> ./logo size="32" }} - diff --git a/templates/preview.hbs b/templates/preview.hbs deleted file mode 100644 index 7498cc6..0000000 --- a/templates/preview.hbs +++ /dev/null @@ -1,32 +0,0 @@ -{{#> 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 f283c82..98bfb0f 100644 --- a/templates/selection.hbs +++ b/templates/selection.hbs @@ -1,20 +1,102 @@ -{{#> partial/header ~}} - Artifacts: {{run_name}} -{{~/partial/header }} - {{> partial/fileIcons }} + + + + + + + Artifacts: + {{run_name}} + + + + + +
- {{> partial/logoLink }} + + +

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

+
-
-{{#> partial/footer ~}} +
+ 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 }} + +