From 5a54f0a7a6828b29ea67269c1474e9ebbafdca2a Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 31 May 2024 15:17:26 +0200 Subject: [PATCH 1/8] fix: make url input field required --- templates/index.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/index.hbs b/templates/index.hbs index 36cbab6..bf96126 100644 --- a/templates/index.hbs +++ b/templates/index.hbs @@ -44,6 +44,7 @@ From 607255931a0a50ca2afd430e57aaf1084305d0f2 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 31 May 2024 15:26:40 +0200 Subject: [PATCH 2/8] ci: fix getting release description --- .forgejo/workflows/ci.yaml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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') }} From eca80aaa8e7ff9d3b36991f9d80d8846441a5536 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 31 May 2024 15:31:29 +0200 Subject: [PATCH 3/8] feat: add port config option --- src/app.rs | 13 +++++++++---- src/config.rs | 3 +++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4db3274..507bafb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -72,11 +72,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 diff --git a/src/config.rs b/src/config.rs index 8047516..5e1a1ec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 `.` @@ -71,6 +73,7 @@ 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()), From 79ad3b9c240a8bee9b1ed6e19ce23aa697f6a059 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 31 May 2024 18:14:17 +0200 Subject: [PATCH 4/8] update README --- .env.example | 7 +- README.md | 137 +++++++++++++++++++++++++++++----- resources/screenshotFiles.png | Bin 0 -> 30230 bytes 3 files changed, 122 insertions(+), 22 deletions(-) create mode 100644 resources/screenshotFiles.png 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/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/screenshotFiles.png b/resources/screenshotFiles.png new file mode 100644 index 0000000000000000000000000000000000000000..53052e26d813c59d39f6c3bf402c8264ae77e30e GIT binary patch literal 30230 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 literal 0 HcmV?d00001 From 72c0e3af69e101c3690696a065b573624e19626d Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 1 Jun 2024 00:21:09 +0200 Subject: [PATCH 5/8] refactor!: Don't use URL queries for artifact selection --- Cargo.lock | 4 +- Cargo.toml | 9 +- src/app.rs | 254 ++++++++++++++++------------------ src/artifact_api.rs | 52 ++++--- src/cache.rs | 2 +- src/config.rs | 4 +- src/query.rs | 295 ++++++++++++++++++++-------------------- src/templates.rs | 15 +- templates/index.hbs | 2 +- templates/selection.hbs | 2 +- 10 files changed, 312 insertions(+), 327 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 323af5f..f6846b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,7 @@ dependencies = [ "serde-env", "serde-hex", "serde_json", + "serde_urlencoded", "thiserror", "tokio", "tokio-util", @@ -264,7 +265,6 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", "tower", @@ -2303,7 +2303,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2341,7 +2340,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", diff --git a/Cargo.toml b/Cargo.toml index 495d81b..5c810af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,13 @@ 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"] } dotenvy = "0.15.7" envy = { path = "crates/envy" } @@ -49,6 +55,7 @@ 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" 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/src/app.rs b/src/app.rs index 507bafb..e503ac4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,8 +6,8 @@ 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 http::{HeaderMap, StatusCode}; @@ -31,7 +31,7 @@ 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}, App, @@ -54,11 +54,6 @@ impl Default for App { } } -#[derive(Deserialize)] -struct UrlForm { - url: String, -} - const FAVICON_PATH: &str = "/favicon.ico"; const FAVICON_BYTES: &[u8; 268] = include_bytes!("../resources/favicon.ico"); @@ -98,7 +93,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 @@ -128,146 +122,138 @@ 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(); - } + 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), - } + 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 } - 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(); + let run_url = query.forge_url(); + let mut path_components = vec![ + LinkItem { + name: query.shortid(), + url: format!("{}/?url={}", state.i.cfg.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(), + }); } - if uri.path() != "/" { - return Err(Error::NotFound("path".into())); - } - if artifacts.is_empty() { - return Err(Error::NotFound("artifacts".into())); - } - let tmpl = templates::Selection { + + 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, + 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() + .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), } } } - 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() != "/" { + 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(), + 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.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::default().to_string().into())?) } } @@ -398,9 +384,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)?) } @@ -410,9 +396,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)?) } @@ -424,13 +410,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(); } 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..5e0766b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -114,7 +114,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 { diff --git a/src/config.rs b/src/config.rs index 5e1a1ec..327a76b 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)] @@ -151,7 +151,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/query.rs b/src/query.rs index 5e9d872..95dc37d 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}; @@ -10,49 +10,100 @@ use crate::{ }; #[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, + ) + } } 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 +111,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 +158,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 +335,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 +356,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 +395,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 +433,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..433b28c 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -2,8 +2,7 @@ use crate::{ artifact_api::Artifact, cache::{ListingEntry, Size}, config::Config, - error::Result, - query::QueryData, + query::{Query, QueryRef}, }; use yarte::{Render, Template}; @@ -62,18 +61,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), - }) + } } } diff --git a/templates/index.hbs b/templates/index.hbs index bf96126..568a83c 100644 --- a/templates/index.hbs +++ b/templates/index.hbs @@ -40,7 +40,7 @@

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

-
+

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

From 47f3ea126784c3add59ef5feea94f11f8d4413b2 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Tue, 4 Jun 2024 02:05:51 +0200 Subject: [PATCH 6/8] feat: add viewer --- .forgejo/workflows/artifact.yaml | 15 + Cargo.lock | 100 ++++++- Cargo.toml | 7 + resources/content.css | 459 +++++++++++++++++++++++++++++++ resources/style.css | 229 +++++++++++++++ src/app.rs | 210 +++++++++++--- src/cache.rs | 14 +- src/config.rs | 3 + src/error.rs | 4 + src/lib.rs | 1 + src/query.rs | 9 + src/templates.rs | 32 ++- src/viewer/code.rs | 82 ++++++ src/viewer/mod.rs | 30 ++ templates/error.hbs | 24 +- templates/index.hbs | 31 +-- templates/listing.hbs | 144 ++-------- templates/partial/fileIcons.hbs | 18 ++ templates/partial/footer.hbs | 13 + templates/partial/header.hbs | 10 + templates/partial/logo.hbs | 1 + templates/partial/logoLink.hbs | 3 + templates/selection.hbs | 143 ++-------- templates/viewer.hbs | 32 +++ 24 files changed, 1276 insertions(+), 338 deletions(-) create mode 100644 .forgejo/workflows/artifact.yaml create mode 100644 resources/content.css create mode 100644 resources/style.css create mode 100644 src/viewer/code.rs create mode 100644 src/viewer/mod.rs create mode 100644 templates/partial/fileIcons.hbs create mode 100644 templates/partial/footer.hbs create mode 100644 templates/partial/header.hbs create mode 100644 templates/partial/logo.hbs create mode 100644 templates/partial/logoLink.hbs create mode 100644 templates/viewer.hbs 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/Cargo.lock b/Cargo.lock index f6846b2..bf4aa04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,7 @@ dependencies = [ "serde-hex", "serde_json", "serde_urlencoded", + "syntect", "thiserror", "tokio", "tokio-util", @@ -350,6 +351,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 +375,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" @@ -1327,13 +1343,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 +1571,7 @@ checksum = "31b476131c3c86cb68032fdc5cb6d5a1045e3e42d96b69fa599fd77701e1f5bf" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.5.0", "lazy_static", "num-traits", "rand", @@ -1632,7 +1670,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 +1679,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 +1828,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 +1913,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 +1943,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", @@ -2123,6 +2170,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" @@ -2311,7 +2378,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", @@ -2517,6 +2584,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" @@ -2646,6 +2723,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 5c810af..49ff9ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,13 @@ 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/resources/content.css b/resources/content.css new file mode 100644 index 0000000..b94ffec --- /dev/null +++ b/resources/content.css @@ -0,0 +1,459 @@ +/* Additional stylesheet for artifactview content viewer */ +.markup { + max-width: 790px; + word-wrap: break-word; + overflow: hidden; + 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; + background-color: var(--color-secondary); + border-radius: 4px; + margin: 0; + padding: 0.2em 0.4em; + font-size: 85%; +} +.markup code br { + display: none; +} +.markup pre { + background-color: var(--color-secondary); + border-radius: 4px; + padding: 16px; + font-size: 85%; + 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 */ +code { + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: break-word; + line-height: inherit; + word-wrap: normal; + border: 0; + margin: 0; + display: block; + font-size: 100%; + + color: #cccccc; + background-color: #1c1c1c; + font-family: monospace; +} +.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/style.css b/resources/style.css new file mode 100644 index 0000000..5c4f0dd --- /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, code { + 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 e503ac4..b8f6d7d 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::{ @@ -9,7 +9,8 @@ use axum::{ 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::{ @@ -34,6 +35,7 @@ use crate::{ 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,8 +57,22 @@ impl Default for App { } } +#[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; 4053] = include_bytes!("../resources/style.css"); +const STYLE_CONTENT_BYTES: &[u8; 10309] = include_bytes!("../resources/content.css"); impl App { pub fn new() -> Self { @@ -138,7 +155,31 @@ impl App { match entry.get_file(&path, uri.query().unwrap_or_default()) { Ok(GetFileResult::File(res)) => { - Self::serve_artifact_file(state, entry, entry_res.zip_path, res, hdrs).await + 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 } Ok(GetFileResult::Listing(listing)) => { if !path.ends_with('/') { @@ -146,41 +187,34 @@ impl App { } let run_url = query.forge_url(); - let mut path_components = vec![ - LinkItem { - name: query.shortid(), - url: format!("{}/?url={}", state.i.cfg.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(), - }); - } - let tmpl = templates::Listing { main_url: state.i.cfg.main_url(), - version: templates::Version, run_url: &run_url, artifact_name: &entry.name, - path_components, + path_components: path_components( + &query, + state.i.cfg.main_url(), + &run_url, + &entry.name, + &path, + ), n_dirs: listing.n_dirs, n_files: listing.n_files, has_parent: listing.has_parent, + publisher: query.publisher(), + viewer_max_size: state + .i + .cfg + .load() + .viewer_max_size + .map(u32::from) + .unwrap_or(u32::MAX), entries: listing.entries, }; Ok(Response::builder() .typed_header(headers::ContentType::html()) - .cache_immutable() + .cache() .body(tmpl.to_string().into())?) } Err(Error::NotFound(e)) => { @@ -199,6 +233,12 @@ impl App { 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())); } @@ -232,13 +272,9 @@ impl App { } else { let tmpl = templates::Selection { main_url: state.i.cfg.main_url(), - version: templates::Version, run_url: &query.forge_url(), run_name: &query.shortid(), - publisher: LinkItem { - name: query.user.to_owned(), - url: format!("https://{}/{}", query.host, query.user), - }, + publisher: query.publisher(), artifacts: artifacts .into_iter() .map(|a| ArtifactItem::from_artifact(a, query.as_ref(), &state.i.cfg)) @@ -253,14 +289,77 @@ impl App { Ok(Response::builder() .typed_header(headers::ContentType::html()) .cache() - .body(templates::Index::default().to_string().into())?) + .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 body = state.i.viewers.try_render(filename, &contents)?; + let run_url = query.forge_url(); + + let tmpl = templates::Viewer { + 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(), + body: &body, + }; + + 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> { @@ -429,6 +528,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 { @@ -437,7 +543,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(), + }), }) } @@ -451,3 +562,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/cache.rs b/src/cache.rs index 5e0766b..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, @@ -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 327a76b..aca3733 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,6 +67,8 @@ 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 { @@ -88,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()), } } } 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 95dc37d..f3993e7 100644 --- a/src/query.rs +++ b/src/query.rs @@ -6,6 +6,7 @@ use serde::{de::Visitor, Deserialize}; use crate::{ error::{Error, Result}, + templates::LinkItem, util, }; @@ -86,6 +87,14 @@ pub trait Query { 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()); diff --git a/src/templates.rs b/src/templates.rs index 433b28c..274d1d9 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -6,13 +6,10 @@ use crate::{ }; 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)] @@ -26,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, @@ -37,16 +33,30 @@ 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 = "viewer")] +pub struct Viewer<'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 body: &'a str, +} + pub struct LinkItem { pub name: String, pub url: String, @@ -72,12 +82,6 @@ impl ArtifactItem { } } -impl Render for Version { - fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str(env!("CARGO_PKG_VERSION")) - } -} - impl Render for Size { fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!( diff --git a/src/viewer/code.rs b/src/viewer/code.rs new file mode 100644 index 0000000..a7d36ba --- /dev/null +++ b/src/viewer/code.rs @@ -0,0 +1,82 @@ +use syntect::{ + html::{ClassStyle, ClassedHTMLGenerator}, + parsing::SyntaxSet, + util::LinesWithEndings, +}; + +use crate::error::Error; + +use super::Viewer; + +pub struct CodeViewer { + ss: SyntaxSet, +} + +impl CodeViewer { + pub fn new() -> Self { + Self { + ss: SyntaxSet::load_defaults_newlines(), + } + } +} + +impl Viewer for CodeViewer { + fn is_applicable(&self, _filename: &str, ext: &str) -> bool { + // TODO: replace with hashset for improved performance + self.ss.find_syntax_by_extension(ext).is_some() + } + + 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()))?; + + // for line in LinesWithEndings::from(code) { + // let ranges: Vec<(Style, &str)> = h.highlight_line(line.trim_end(), &ps).unwrap(); + // let highlighted_line = styled_line_to_highlighted_html(&ranges[..], IncludeBackground::No); + // let mut spanned_line = String::from(r#""#); + // spanned_line.push_str(&highlighted_line.unwrap()); + // spanned_line.push_str(""); + // the_lines.push(spanned_line); + // } + + Ok(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/mod.rs b/src/viewer/mod.rs new file mode 100644 index 0000000..1a2d8f7 --- /dev/null +++ b/src/viewer/mod.rs @@ -0,0 +1,30 @@ +use crate::error::Error; + +mod code; + +pub trait Viewer: Sync + Send { + fn is_applicable(&self, filename: &str, ext: &str) -> bool; + fn try_render(&self, filename: &str, ext: &str, data: &str) -> Result; +} + +pub struct Viewers([Box; 1]); + +impl Viewers { + pub fn new() -> Self { + Self([Box::new(code::CodeViewer::new())]) + } + + pub fn try_render(&self, filename: &str, data: &str) -> Result { + let ext = filename.rsplit('.').next().unwrap(); + for viewer in &self.0 { + match viewer.try_render(filename, ext, data) { + Ok(res) => return Ok(res), + Err(Error::ViewerNotApplicable) => {} + Err(e) => { + tracing::error!("could not render {filename}: {e}"); + } + } + } + Err(Error::ViewerNotApplicable) + } +} 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 568a83c..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

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/selection.hbs b/templates/selection.hbs index 0249ea0..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}} /

-
-
+
@@ -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 }} diff --git a/templates/viewer.hbs b/templates/viewer.hbs new file mode 100644 index 0000000..4b15ad7 --- /dev/null +++ b/templates/viewer.hbs @@ -0,0 +1,32 @@ +{{#> partial/header ~}} + + {{filename}} +{{~/partial/header }} +
+ {{> partial/logoLink }} +

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

+
+ +
+ +
+ {{{body}}} +
+
+{{#> partial/footer ~}} +{{~/partial/footer }} From 4ebeb4b873d3b935a2eaa4008c0fc30dfd87c0f7 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Tue, 4 Jun 2024 02:36:43 +0200 Subject: [PATCH 7/8] feat: add viewer selection --- src/app.rs | 5 ++-- src/templates.rs | 7 +++++ src/viewer/code.rs | 10 +++++++ src/viewer/mod.rs | 65 ++++++++++++++++++++++++++++++++++++++------ templates/viewer.hbs | 2 +- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index b8f6d7d..a06c177 100644 --- a/src/app.rs +++ b/src/app.rs @@ -330,7 +330,7 @@ impl App { let mut contents = String::new(); reader.read_to_string(&mut contents).await?; - let body = state.i.viewers.try_render(filename, &contents)?; + let render_res = state.i.viewers.try_render(filename, viewer, &contents)?; let run_url = query.forge_url(); let tmpl = templates::Viewer { @@ -347,7 +347,8 @@ impl App { publisher: query.publisher(), lines: contents.lines().count(), size: file.uncompressed_size.into(), - body: &body, + viewers: state.i.viewers.tmpl_viewers(render_res.viewer), + body: &render_res.html, }; Ok(Response::builder() diff --git a/src/templates.rs b/src/templates.rs index 274d1d9..909a334 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -54,9 +54,16 @@ pub struct Viewer<'a> { 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, diff --git a/src/viewer/code.rs b/src/viewer/code.rs index a7d36ba..c29c632 100644 --- a/src/viewer/code.rs +++ b/src/viewer/code.rs @@ -21,10 +21,20 @@ impl CodeViewer { } 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 { // TODO: replace with hashset for improved performance self.ss.find_syntax_by_extension(ext).is_some() } + */ fn try_render(&self, _filename: &str, ext: &str, data: &str) -> Result { let syntax = self diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 1a2d8f7..9aab767 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -1,30 +1,77 @@ -use crate::error::Error; +use crate::{error::Error, templates::ViewerLink}; mod code; pub trait Viewer: Sync + Send { - fn is_applicable(&self, filename: &str, ext: &str) -> bool; + 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([Box; 1]); +pub struct RenderRes { + pub html: String, + pub viewer: &'static str, +} + impl Viewers { pub fn new() -> Self { Self([Box::new(code::CodeViewer::new())]) } - pub fn try_render(&self, filename: &str, data: &str) -> Result { + pub fn tmpl_viewers(&self, viewer: &str) -> Vec { + self.0 + .iter() + .map(|v| ViewerLink { + id: v.id(), + name: v.name(), + selected: v.id() == viewer, + }) + .collect() + } + + 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(res) = self + .0 + .iter() + .find(|v| v.id() == viewer) + .and_then(|viewer| Self::try_viewer(viewer, filename, ext, data)) + { + return Ok(res); + } + } + for viewer in &self.0 { - match viewer.try_render(filename, ext, data) { - Ok(res) => return Ok(res), - Err(Error::ViewerNotApplicable) => {} - Err(e) => { - tracing::error!("could not render {filename}: {e}"); - } + if let Some(res) = Self::try_viewer(viewer, filename, ext, data) { + return Ok(res); } } Err(Error::ViewerNotApplicable) } + + #[allow(clippy::borrowed_box)] + fn try_viewer( + viewer: &Box, + filename: &str, + ext: &str, + data: &str, + ) -> Option { + match viewer.try_render(filename, ext, data) { + Ok(html) => Some(RenderRes { + html, + viewer: viewer.id(), + }), + Err(Error::ViewerNotApplicable) => None, + Err(e) => { + tracing::error!("could not render {filename}: {e}"); + None + } + } + } } diff --git a/templates/viewer.hbs b/templates/viewer.hbs index 4b15ad7..c442dc7 100644 --- a/templates/viewer.hbs +++ b/templates/viewer.hbs @@ -19,7 +19,7 @@ CI run
- Code + {{#each viewers}}{{name}}{{/each}} Raw
From 608a9f68f4e756bf65c0084fbf654c8e40d4762c Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Tue, 4 Jun 2024 03:59:53 +0200 Subject: [PATCH 8/8] feat: add markdown viewer --- Cargo.lock | 129 ++++++++++++++++++++++++++ Cargo.toml | 1 + resources/content.css | 34 +++---- resources/style.css | 2 +- src/app.rs | 8 +- src/templates.rs | 4 +- src/viewer/code.rs | 31 +++---- src/viewer/markdown.rs | 103 ++++++++++++++++++++ src/viewer/mod.rs | 107 +++++++++++---------- templates/{viewer.hbs => preview.hbs} | 2 +- 10 files changed, 325 insertions(+), 96 deletions(-) create mode 100644 src/viewer/markdown.rs rename templates/{viewer.hbs => preview.hbs} (96%) diff --git a/Cargo.lock b/Cargo.lock index bf4aa04..ddb1bfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,7 @@ dependencies = [ "async_zip", "axum", "axum-extra", + "comrak", "dotenvy", "envy", "flate2", @@ -480,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" @@ -542,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" @@ -570,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" @@ -583,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" @@ -606,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" @@ -1087,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" @@ -2090,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" @@ -2130,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" @@ -2464,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" @@ -2518,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" diff --git a/Cargo.toml b/Cargo.toml index 49ff9ac..e529eee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ axum = { version = "0.7.5", default-features = false, features = [ "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" diff --git a/resources/content.css b/resources/content.css index b94ffec..53d7877 100644 --- a/resources/content.css +++ b/resources/content.css @@ -1,8 +1,20 @@ /* Additional stylesheet for artifactview content viewer */ + +.viewer > pre { + padding: 10px 20px; +} + +pre, code { + color: #cccccc; + background-color: #1c1c1c; +} + .markup { - max-width: 790px; + 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 { @@ -159,7 +171,6 @@ } .markup code { white-space: break-spaces; - background-color: var(--color-secondary); border-radius: 4px; margin: 0; padding: 0.2em 0.4em; @@ -169,10 +180,8 @@ display: none; } .markup pre { - background-color: var(--color-secondary); border-radius: 4px; - padding: 16px; - font-size: 85%; + padding: 8px; line-height: 1.45; margin-bottom: 16px; word-break: normal; @@ -189,21 +198,6 @@ } /* theme "Monokai++" generated by syntect */ -code { - white-space: pre-wrap; - word-break: break-all; - overflow-wrap: break-word; - line-height: inherit; - word-wrap: normal; - border: 0; - margin: 0; - display: block; - font-size: 100%; - - color: #cccccc; - background-color: #1c1c1c; - font-family: monospace; -} .entity.name.function.preprocessor, .meta.preprocessor.macro, .storage.modifier.import, diff --git a/resources/style.css b/resources/style.css index 5c4f0dd..df823db 100644 --- a/resources/style.css +++ b/resources/style.css @@ -20,7 +20,7 @@ a { a:hover, a.selected { color: #319cff; } -header, #summary, code { +header, #summary, .content { padding: 0 20px; } header { diff --git a/src/app.rs b/src/app.rs index a06c177..c1460b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -71,8 +71,8 @@ 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; 4053] = include_bytes!("../resources/style.css"); -const STYLE_CONTENT_BYTES: &[u8; 10309] = include_bytes!("../resources/content.css"); +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 { @@ -333,7 +333,7 @@ impl App { let render_res = state.i.viewers.try_render(filename, viewer, &contents)?; let run_url = query.forge_url(); - let tmpl = templates::Viewer { + let tmpl = templates::Preview { main_url: state.i.cfg.main_url(), run_url: &run_url, filename, @@ -347,7 +347,7 @@ impl App { publisher: query.publisher(), lines: contents.lines().count(), size: file.uncompressed_size.into(), - viewers: state.i.viewers.tmpl_viewers(render_res.viewer), + viewers: render_res.tmpl_viewers, body: &render_res.html, }; diff --git a/src/templates.rs b/src/templates.rs index 909a334..56e6618 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -45,8 +45,8 @@ pub struct Listing<'a> { } #[derive(Template)] -#[template(path = "viewer")] -pub struct Viewer<'a> { +#[template(path = "preview")] +pub struct Preview<'a> { pub main_url: &'a str, pub run_url: &'a str, pub filename: &'a str, diff --git a/src/viewer/code.rs b/src/viewer/code.rs index c29c632..3a56c31 100644 --- a/src/viewer/code.rs +++ b/src/viewer/code.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use syntect::{ html::{ClassStyle, ClassedHTMLGenerator}, parsing::SyntaxSet, @@ -9,14 +11,12 @@ use crate::error::Error; use super::Viewer; pub struct CodeViewer { - ss: SyntaxSet, + ss: Arc, } impl CodeViewer { - pub fn new() -> Self { - Self { - ss: SyntaxSet::load_defaults_newlines(), - } + pub fn new(ss: Arc) -> Self { + Self { ss } } } @@ -29,12 +29,9 @@ impl Viewer for CodeViewer { "Code" } - /* - fn is_applicable(&self, _filename: &str, ext: &str) -> bool { - // TODO: replace with hashset for improved performance - self.ss.find_syntax_by_extension(ext).is_some() + fn is_applicable(&self, _filename: &str, _ext: &str) -> bool { + true } - */ fn try_render(&self, _filename: &str, ext: &str, data: &str) -> Result { let syntax = self @@ -48,16 +45,10 @@ impl Viewer for CodeViewer { .try_for_each(|line| html_generator.parse_html_for_line_which_includes_newline(line)) .map_err(|e| Error::Viewer(e.to_string().into()))?; - // for line in LinesWithEndings::from(code) { - // let ranges: Vec<(Style, &str)> = h.highlight_line(line.trim_end(), &ps).unwrap(); - // let highlighted_line = styled_line_to_highlighted_html(&ranges[..], IncludeBackground::No); - // let mut spanned_line = String::from(r#""#); - // spanned_line.push_str(&highlighted_line.unwrap()); - // spanned_line.push_str(""); - // the_lines.push(spanned_line); - // } - - Ok(html_generator.finalize()) + Ok(format!( + "
{}
", + html_generator.finalize() + )) } } 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
index 9aab767..18a53a7 100644
--- a/src/viewer/mod.rs
+++ b/src/viewer/mod.rs
@@ -1,30 +1,83 @@
+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 is_applicable(&self, filename: &str, ext: &str) -> bool;
     fn try_render(&self, filename: &str, ext: &str, data: &str) -> Result;
 }
 
-pub struct Viewers([Box; 1]);
+pub struct Viewers {
+    viewers: [Box; 2],
+}
 
 pub struct RenderRes {
     pub html: String,
-    pub viewer: &'static str,
+    pub tmpl_viewers: Vec,
 }
 
 impl Viewers {
     pub fn new() -> Self {
-        Self([Box::new(code::CodeViewer::new())])
+        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 tmpl_viewers(&self, viewer: &str) -> Vec {
-        self.0
+    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(),
@@ -32,46 +85,4 @@ impl Viewers {
             })
             .collect()
     }
-
-    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(res) = self
-                .0
-                .iter()
-                .find(|v| v.id() == viewer)
-                .and_then(|viewer| Self::try_viewer(viewer, filename, ext, data))
-            {
-                return Ok(res);
-            }
-        }
-
-        for viewer in &self.0 {
-            if let Some(res) = Self::try_viewer(viewer, filename, ext, data) {
-                return Ok(res);
-            }
-        }
-        Err(Error::ViewerNotApplicable)
-    }
-
-    #[allow(clippy::borrowed_box)]
-    fn try_viewer(
-        viewer: &Box,
-        filename: &str,
-        ext: &str,
-        data: &str,
-    ) -> Option {
-        match viewer.try_render(filename, ext, data) {
-            Ok(html) => Some(RenderRes {
-                html,
-                viewer: viewer.id(),
-            }),
-            Err(Error::ViewerNotApplicable) => None,
-            Err(e) => {
-                tracing::error!("could not render {filename}: {e}");
-                None
-            }
-        }
-    }
 }
diff --git a/templates/viewer.hbs b/templates/preview.hbs
similarity index 96%
rename from templates/viewer.hbs
rename to templates/preview.hbs
index c442dc7..7498cc6 100644
--- a/templates/viewer.hbs
+++ b/templates/preview.hbs
@@ -25,7 +25,7 @@
         
       
       
- {{{body}}} + {{{body}}}
{{#> partial/footer ~}}