From 806a2dda9a5e7ce33e0496b31934a2cf11eeb2ae Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Tue, 11 Jun 2024 09:22:53 +0200 Subject: [PATCH 1/6] feat: add JUnit viewer template --- crates/junit-parser/src/lib.rs | 41 ++++++ resources/content.css | 247 ++++++++++++++++++++++++++++++++- resources/style.css | 45 +++--- src/app.rs | 12 +- src/templates.rs | 7 + src/util.rs | 4 + src/viewer/junit.rs | 7 +- templates/index.hbs | 2 +- templates/junit.hbs | 183 ++++++++++++++++++++++++ templates/listing.hbs | 2 +- 10 files changed, 516 insertions(+), 34 deletions(-) create mode 100644 templates/junit.hbs diff --git a/crates/junit-parser/src/lib.rs b/crates/junit-parser/src/lib.rs index b6a3a56..a74f94d 100644 --- a/crates/junit-parser/src/lib.rs +++ b/crates/junit-parser/src/lib.rs @@ -317,6 +317,47 @@ impl TestCase { } Ok(tc) } + + pub fn status_txt(&self) -> Cow<'static, str> { + match self.status { + TestStatus::Success => "Success".into(), + TestStatus::Error(_) => "Error".into(), + TestStatus::Failure(_) => { + if self.retries.is_empty() { + "Failure".into() + } else { + format!("Failure (after {} retries)", self.retries.len()).into() + } + } + TestStatus::Flaky => format!( + "Flaky (passed after {} failed attempt{})", + self.retries.len(), + if self.retries.len() == 1 { "s" } else { "" } + ) + .into(), + TestStatus::Skipped => "Skipped".into(), + } + } +} + +impl TestStatus { + pub fn id(&self) -> &'static str { + match self { + TestStatus::Success => "success", + TestStatus::Error(_) => "error", + TestStatus::Failure(_) => "failure", + TestStatus::Flaky => "flaky", + TestStatus::Skipped => "skipped", + } + } + + pub fn message(&self) -> Option<&Message> { + match self { + TestStatus::Error(msg) => Some(msg), + TestStatus::Failure(msg) => Some(msg), + _ => None, + } + } } impl Message { diff --git a/resources/content.css b/resources/content.css index fdb494c..68f7ffa 100644 --- a/resources/content.css +++ b/resources/content.css @@ -3,9 +3,11 @@ .viewer > pre { padding: 10px 20px; font-size: 14px; + overflow-x: auto; } -pre, code { +pre, +code { color: #cccccc; background-color: #1c1c1c; } @@ -50,12 +52,12 @@ pre, code { font-size: inherit; } .prose h1 { - border-bottom: 1px solid var(--color-secondary); + border-bottom: 1px solid var(--color-border2); padding-bottom: 0.3em; font-size: 2em; } .prose h2 { - border-bottom: 1px solid var(--color-secondary); + border-bottom: 1px solid var(--color-border2); padding-bottom: 0.3em; font-size: 1.5em; } @@ -456,3 +458,242 @@ pre, code { .markup.untracked.git_gutter { color: #696d70; } + +.junit { + display: flex; + overflow: hidden; +} +@media (max-width: 1000px) { + .junit { + flex-wrap: wrap; + } + #preview-margin { + display: none; + } +} +.junit > div:not(:last-child) { + border-right: solid 1px var(--color-border); +} +#junit-suites, +#junit-cases { + min-width: 300px; + width: 300px; +} +#junit-preview { + flex-grow: 1; + margin-bottom: 40px; + overflow-x: auto; +} + +#junit-preview h2 { + border-bottom: 2px solid var(--color-status); +} + +#junit-preview h2 i { + color: var(--color-status); +} + +.junit ul { + list-style-type: none; +} + +.junit ul > li, +.colsubtitle { + border-bottom: 1px dashed var(--color-border2); +} + +.colsubtitle > button { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 2px 0; + padding: 4px 8px; + border: 2px solid var(--color-status, var(--color-btn)); +} +.colsubtitle button.active { + background-color: var(--color-status, var(--color-btn)); + color: #fff; +} + +.coltitle { + font-size: 14px; + margin: 8px; +} + +.colsubtitle { + padding: 0 8px 8px 8px; + margin: 0; +} + +.junit li > button { + width: 100%; + padding: 4px 8px; + text-align: left; + background-color: transparent; + overflow: hidden; + text-overflow: ellipsis; +} +.junit li button.active { + text-decoration: underline; + color: var(--color-a-hov); +} + +#junit-cases.filtered li > button > span { + display: none; +} + +.badges > *:not(:last-child):after { + content: "โ€ข"; + margin: 0 0.4em; +} + +.pvcontent { + display: none; +} + +.junit li[data-status="success"] { + background-color: #00800035; +} +.junit li[data-status="failure"] { + background-color: #a6000035; +} +.junit li[data-status="error"] { + background-color: #67000035; +} +.junit li[data-status="skipped"] { + background-color: #33333335; +} +.junit li[data-status="flaky"] { + background-color: #d3641a35; +} + +[data-status="success"] { + --color-status: #008000; +} +[data-status="failure"] { + --color-status: #a60000; +} +[data-status="error"] { + --color-status: #670000; +} +[data-status="flaky"] { + --color-status: #d3641a; +} +[data-status="skipped"] { + --color-status: #333; +} + +/* Icons from https://css.gg */ +.gg-check-o { + box-sizing: border-box; + position: relative; + display: inline-block; + transform: scale(var(--ggs, 1)); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 100px; +} +.gg-check-o::after { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + left: 3px; + top: -1px; + width: 6px; + height: 10px; + border-color: currentColor; + border-width: 0 2px 2px 0; + border-style: solid; + transform-origin: bottom left; + transform: rotate(45deg); +} + +.gg-close-o { + box-sizing: border-box; + position: relative; + display: inline-block; + transform: scale(var(--ggs, 1)); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 40px; +} + +.gg-close-o::after, +.gg-close-o::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 12px; + height: 2px; + background: currentColor; + transform: rotate(45deg); + border-radius: 5px; + top: 8px; + left: 3px; +} + +.gg-close-o::after { + transform: rotate(-45deg); +} + +.gg-block { + box-sizing: border-box; + position: relative; + display: inline-block; + transform: scale(var(--ggs, 1)); + width: 16px; + height: 16px; + border: 2px solid; + border-radius: 100%; +} + +.gg-block::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 10px; + height: 2px; + background: currentColor; + border-radius: 5px; + transform: rotate(-45deg); + top: 5px; + left: 1px; +} + +.gg-danger { + box-sizing: border-box; + position: relative; + display: inline-block; + transform: scale(var(--ggs, 1)); + width: 20px; + height: 20px; + border: 2px solid; + border-radius: 40px; +} + +.gg-danger::after, +.gg-danger::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + border-radius: 3px; + width: 2px; + background: currentColor; + left: 7px; +} + +.gg-danger::after { + top: 2px; + height: 8px; +} + +.gg-danger::before { + height: 2px; + bottom: 2px; +} diff --git a/resources/style.css b/resources/style.css index df823db..6eb520f 100644 --- a/resources/style.css +++ b/resources/style.css @@ -6,6 +6,10 @@ --color-text: #000; --color-text-light: #888; --color-border: #ccc; + --color-border2: #bbb; + --color-btn: #006ed3; + --color-a: #006ed3; + --color-a-hov: #319cff; } body { font-family: sans-serif; @@ -14,11 +18,11 @@ body { color: var(--color-text); } a { - color: #006ed3; + color: var(--color-a); text-decoration: none; } a:hover, a.selected { - color: #319cff; + color: var(--color-a-hov); } header, #summary, .content { padding: 0 20px; @@ -75,7 +79,7 @@ main { border-collapse: collapse; } #list tr { - border-bottom: 1px dashed #dadada; + border-bottom: 1px dashed var(--color-border2); } #list tbody tr:hover { background-color: #ffffec; @@ -129,19 +133,25 @@ main { .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; + font-size: 14px; + background-color: unset; + color: unset; +} +.btn { + background-color: var(--color-btn); + padding: 4px 8px; } button:hover { - opacity: 0.7; + filter: brightness(80%); +} +button:active { + filter: brightness(70%); } footer { padding: 40px 20px; @@ -189,11 +199,17 @@ p { .expired { filter: grayscale(100%); } +.hidden { + display: none !important; +} @media (prefers-color-scheme: dark) { * { --color-secondary: #082437; - --color-text: #dddddd; + --color-text: #ddd; --color-border: #212121; + --color-border2: #333; + --color-a: #009dff; + --color-a-hov: #62b2fd; } body { background-color: #101010; @@ -207,17 +223,6 @@ p { #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; diff --git a/src/app.rs b/src/app.rs index 50036c2..3d1c206 100644 --- a/src/app.rs +++ b/src/app.rs @@ -70,9 +70,9 @@ pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); pub(crate) const STYLE_MAIN_PATH: &str = "/style1.css"; pub(crate) const STYLE_CONTENT_PATH: &str = "/content1.css"; -const FAVICON_BYTES: &[u8; 268] = include_bytes!("../resources/favicon.ico"); -const STYLE_MAIN_BYTES: &[u8; 4057] = include_bytes!("../resources/style.css"); -const STYLE_CONTENT_BYTES: &[u8; 10079] = include_bytes!("../resources/content.css"); +const FAVICON_BYTES: &[u8] = include_bytes!("../resources/favicon.ico"); +const STYLE_MAIN_BYTES: &[u8] = include_bytes!("../resources/style.css"); +const STYLE_CONTENT_BYTES: &[u8] = include_bytes!("../resources/content.css"); impl App { pub fn new() -> Self { @@ -234,10 +234,10 @@ impl App { return Self::favicon(); } if uri.path() == STYLE_MAIN_PATH { - return Self::stylesheet(STYLE_MAIN_BYTES.as_slice()); + return Self::stylesheet(STYLE_MAIN_BYTES); } if uri.path() == STYLE_CONTENT_PATH { - return Self::stylesheet(STYLE_CONTENT_BYTES.as_slice()); + return Self::stylesheet(STYLE_CONTENT_BYTES); } if uri.path() != "/" { return Err(Error::NotFound("path".into())); @@ -527,7 +527,7 @@ impl App { Ok(Response::builder() .typed_header(headers::ContentType::from_str("image/x-icon").unwrap()) .cache_immutable() - .body(FAVICON_BYTES.as_slice().into())?) + .body(FAVICON_BYTES.into())?) } fn stylesheet(content: &'static [u8]) -> Result, Error> { diff --git a/src/templates.rs b/src/templates.rs index 56e6618..5c11ade 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -4,6 +4,7 @@ use crate::{ config::Config, query::{Query, QueryRef}, }; +use junit_parser::TestSuites; use yarte::{Render, Template}; #[derive(Template)] @@ -58,6 +59,12 @@ pub struct Preview<'a> { pub body: &'a str, } +#[derive(Template)] +#[template(path = "junit")] +pub struct Junit { + pub suites: TestSuites, +} + pub struct ViewerLink { pub id: &'static str, pub name: &'static str, diff --git a/src/util.rs b/src/util.rs index bc74080..461905c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -241,6 +241,10 @@ pub fn parse_url(input: &str) -> Result<(&str, std::str::Split)> { Ok((host, parts)) } +pub fn time_to_ms(time: f64) -> u64 { + (time * 1000.0) as u64 +} + #[derive(Serialize)] pub struct ErrorJson { status: u16, diff --git a/src/viewer/junit.rs b/src/viewer/junit.rs index bc2c7de..bda92f6 100644 --- a/src/viewer/junit.rs +++ b/src/viewer/junit.rs @@ -1,4 +1,4 @@ -use crate::error::Error; +use crate::{error::Error, templates}; use super::Viewer; @@ -23,8 +23,9 @@ impl Viewer for JunitViewer { tracing::error!("could not parse junit report {filename}: {e}"); Error::ViewerNotApplicable })?; - dbg!(&suites); - Ok(String::new()) + + let tmpl = templates::Junit { suites }; + Ok(tmpl.to_string()) } } diff --git a/templates/index.hbs b/templates/index.hbs index baee8b0..fcbe54e 100644 --- a/templates/index.hbs +++ b/templates/index.hbs @@ -27,7 +27,7 @@ placeholder="codeberg.org/username/repo/actions/runs/42" style="flex-grow: 1" /> - + diff --git a/templates/junit.hbs b/templates/junit.hbs new file mode 100644 index 0000000..0689aca --- /dev/null +++ b/templates/junit.hbs @@ -0,0 +1,183 @@ +
+
+

Test suites:

+
    +
  • + {{#each suites.suites}} +
  • + {{/each}} +
+
+
+

Test cases:

+

+ + + + + + +

+
    + {{~#each suites.suites ~}}{{ let suite_name = &name }} + {{#each cases}} +
  • + +
    +

    {{name}}

    +

    {{ this.status_txt() }}{{ crate::util::time_to_ms(time) }}ms

    + {{~#if let Some(msg) = status.message() ~}} +
    {{msg.message}}{{msg.text}}
    + {{~/if}} + {{~#if let Some(ref stdout) = system_out ~}} +
    {{stdout}}
    + {{~/if}} + {{~#if let Some(ref stderr) = system_err ~}} +
    {{stderr}}
    + {{~/if}} + {{~#each retries ~}} +

    Failed attempt #{{index}}

    +

    {{ crate::util::time_to_ms(time) }}ms

    + {{~#if let Some(msg) = status.message() ~}} +
    {{msg.message}}{{msg.text}}
    + {{~/if}} + {{~#if let Some(ref stdout) = system_out ~}} +
    {{stdout}}
    + {{~/if}} + {{~#if let Some(ref stderr) = system_err ~}} +
    {{stderr}}
    + {{~/if}} + {{~/each}} +
    +
  • + {{/each}} + {{~/each}} +
+
+
+
+
+

Select a test case to show details

+
+
+
+ + diff --git a/templates/listing.hbs b/templates/listing.hbs index e97c97d..10c1c78 100644 --- a/templates/listing.hbs +++ b/templates/listing.hbs @@ -5,7 +5,7 @@
{{> partial/logoLink }}

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

From d9f9d6edcaef4086ce51596305909a1d63e132f7 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Tue, 11 Jun 2024 09:51:21 +0200 Subject: [PATCH 2/6] fix: increment stylesheet path --- src/app.rs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3d1c206..32b7d5d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,8 +67,8 @@ 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"; +pub(crate) const STYLE_MAIN_PATH: &str = "/style2.css"; +pub(crate) const STYLE_CONTENT_PATH: &str = "/content2.css"; const FAVICON_BYTES: &[u8] = include_bytes!("../resources/favicon.ico"); const STYLE_MAIN_BYTES: &[u8] = include_bytes!("../resources/style.css"); @@ -97,10 +97,12 @@ impl App { let real_ip_header = state.i.cfg.load().real_ip_header.clone(); let router = Router::new() // Prevent search indexing since artifactview serves temporary artifacts - .route( - "/robots.txt", - get(|| async { "# PLEASE dont scrape this website.\n# All of the data here is fetched from the public GitHub/Gitea APIs, this app is open source and it is not running on some Fortune 500 company server. \n\nUser-agent: *\nDisallow: /\n" }), - ) + .route("/robots.txt", get(|| async { + Response::builder() + .typed_header(headers::ContentType::text_utf8()) + .cache() + .body::("# PLEASE dont scrape this website.\n# All of the data here is fetched from the public GitHub/Gitea APIs, this app is open source and it is not running on some Fortune 500 company server. \n\nUser-agent: *\nDisallow: /\n".into()).unwrap() + })) // Put the API in the .well-known folder, since it is disabled for pages .route("/.well-known/api/artifacts", get(Self::get_artifacts)) .route("/.well-known/api/artifact", get(Self::get_artifact)) @@ -116,12 +118,21 @@ impl App { .layer( TraceLayer::new_for_http() .make_span_with(move |request: &Request| { - let ip = util::get_ip_address(request, real_ip_header.as_deref()).map(|ip| ip.to_string()).unwrap_or_default(); - tracing::error_span!("request", url = util::full_url_from_request(request), ip) + let ip = util::get_ip_address(request, real_ip_header.as_deref()) + .map(|ip| ip.to_string()) + .unwrap_or_default(); + tracing::error_span!( + "request", + url = util::full_url_from_request(request), + ip + ) }) .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), ) - .layer(SetResponseHeaderLayer::appending(http::header::X_CONTENT_TYPE_OPTIONS, http::HeaderValue::from_static("nosniff"))); + .layer(SetResponseHeaderLayer::appending( + http::header::X_CONTENT_TYPE_OPTIONS, + http::HeaderValue::from_static("nosniff"), + )); axum::serve( listener, router.into_make_service_with_connect_info::(), @@ -354,6 +365,7 @@ impl App { Ok(Response::builder() .typed_header(ContentType::html()) .typed_header(headers::LastModified::from(entry.last_modified)) + .cache() .body(tmpl.to_string().into())?) } From 4aaff462bb602180832cae3fe902161d2f8043a9 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 14 Jun 2024 00:37:30 +0200 Subject: [PATCH 3/6] tests: add integration tests --- .gitignore | 1 + Cargo.lock | 451 +++++++++++++++++- Cargo.toml | 7 +- src/app.rs | 36 +- src/artifact_api.rs | 2 + src/cache.rs | 215 ++++++++- src/error.rs | 2 +- src/lib.rs | 4 +- src/query.rs | 2 + src/templates.rs | 43 +- src/util.rs | 43 +- src/viewer/code.rs | 68 +-- src/viewer/junit.rs | 21 +- src/viewer/markdown.rs | 30 ++ src/viewer/mod.rs | 32 +- ...factview__viewer__code__tests__render.snap | 10 + ...actview__viewer__junit__tests__render.snap | 197 ++++++++ ...view__viewer__markdown__tests__render.snap | 8 + templates/preview.hbs | 2 +- tests/testfiles/junit/hello.junit.xml | 40 ++ tests/testfiles/sites/.well-known/test.txt | 1 + tests/testfiles/sites/200.html | 10 + tests/testfiles/sites/404.html | 10 + tests/testfiles/sites/example.rs | 17 + tests/testfiles/sites/index.html | 10 + tests/testfiles/sites/make_zip.sh | 37 ++ tests/testfiles/sites/robots.txt | 1 + tests/testfiles/sites/style.css | 5 + tests/tests.rs | 432 +++++++++++++++++ 29 files changed, 1650 insertions(+), 87 deletions(-) create mode 100644 src/viewer/snapshots/artifactview__viewer__code__tests__render.snap create mode 100644 src/viewer/snapshots/artifactview__viewer__junit__tests__render.snap create mode 100644 src/viewer/snapshots/artifactview__viewer__markdown__tests__render.snap create mode 100644 tests/testfiles/junit/hello.junit.xml create mode 100644 tests/testfiles/sites/.well-known/test.txt create mode 100644 tests/testfiles/sites/200.html create mode 100644 tests/testfiles/sites/404.html create mode 100644 tests/testfiles/sites/example.rs create mode 100644 tests/testfiles/sites/index.html create mode 100755 tests/testfiles/sites/make_zip.sh create mode 100644 tests/testfiles/sites/robots.txt create mode 100644 tests/testfiles/sites/style.css create mode 100644 tests/tests.rs diff --git a/.gitignore b/.gitignore index a237387..4644368 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /dist /.env *.snap.new +/tests/testfiles/sites_data diff --git a/Cargo.lock b/Cargo.lock index 7238e51..db4c1c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,7 @@ dependencies = [ "async_zip", "axum", "axum-extra", + "axum-test", "comrak", "dotenvy", "envy", @@ -153,9 +154,10 @@ dependencies = [ "futures-lite", "governor", "headers", - "hex", - "http", + "http 1.1.0", + "httpdate", "humansize", + "insta", "junit-parser", "mime", "mime_guess", @@ -170,12 +172,14 @@ dependencies = [ "regex", "reqwest", "rstest", + "scraper", "serde", "serde-env", "serde-hex", "serde_json", "serde_urlencoded", "syntect", + "temp_testdir", "thiserror", "tokio", "tokio-util", @@ -238,6 +242,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + [[package]] name = "autocfg" version = "1.3.0" @@ -254,7 +264,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "hyper", @@ -269,6 +279,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", "tower", @@ -286,7 +297,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "mime", @@ -309,7 +320,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http", + "http 1.1.0", "http-body", "http-body-util", "mime", @@ -321,6 +332,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "15.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6eaf3651cc3b15185c6033db9cc40676b02ffaa69278679c5f018de2bcae598" +dependencies = [ + "anyhow", + "auto-future", + "axum", + "bytes", + "cookie", + "http 1.1.0", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec 1.13.2", + "tokio", + "tower", + "url", +] + [[package]] name = "backtrace" version = "0.3.72" @@ -523,6 +562,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -573,6 +622,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.2", + "smallvec 1.13.2", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.66", +] + [[package]] name = "darling" version = "0.20.9" @@ -687,6 +759,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -710,6 +788,21 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a68a4904193147e0a8dec3314640e6db742afd5f6e634f428a6af230d9b3591" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -814,6 +907,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.30" @@ -922,6 +1025,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -932,6 +1044,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -986,7 +1107,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.1.0", "indexmap", "slab", "tokio", @@ -1009,7 +1130,7 @@ dependencies = [ "base64 0.21.7", "bytes", "headers-core", - "http", + "http 1.1.0", "httpdate", "mime", "sha1", @@ -1021,7 +1142,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.1.0", ] [[package]] @@ -1030,12 +1151,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hmac" version = "0.12.1" @@ -1045,6 +1160,31 @@ dependencies = [ "digest", ] +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.1.0" @@ -1063,7 +1203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -1074,7 +1214,7 @@ checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" dependencies = [ "bytes", "futures-core", - "http", + "http 1.1.0", "http-body", "pin-project-lite", ] @@ -1116,7 +1256,7 @@ dependencies = [ "futures-channel", "futures-util", "h2", - "http", + "http 1.1.0", "http-body", "httparse", "httpdate", @@ -1134,7 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", - "http", + "http 1.1.0", "hyper", "hyper-util", "rustls", @@ -1169,7 +1309,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.1.0", "http-body", "hyper", "pin-project-lite", @@ -1357,6 +1497,26 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + [[package]] name = "matchit" version = "0.7.3" @@ -1428,6 +1588,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "no-std-compat" version = "0.4.1" @@ -1633,6 +1799,86 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -1689,6 +1935,22 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.1.25" @@ -1890,7 +2152,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "hyper", @@ -1927,6 +2189,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "reserve-port" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9838134a2bfaa8e1f40738fcc972ac799de6e0e06b5157acb95fc2b05a0ea283" +dependencies = [ + "lazy_static", + "thiserror", +] + [[package]] name = "ring" version = "0.17.8" @@ -1970,6 +2242,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand", + "thiserror", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2100,6 +2388,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b80b33679ff7a0ea53d37f3b39de77ea0c75b12c5805ac43ec0c33b3051af1b" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + [[package]] name = "security-framework" version = "2.11.0" @@ -2123,6 +2427,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags 2.5.0", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec 1.13.2", +] + [[package]] name = "semver" version = "1.0.23" @@ -2204,6 +2527,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2250,6 +2582,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -2309,6 +2647,38 @@ dependencies = [ "lock_api", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2375,6 +2745,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "temp_testdir" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921f1e9c427802414907a48b21a6504ff6b3a15a1a3cf37e699590949ad9befc" + [[package]] name = "tempfile" version = "3.10.1" @@ -2387,6 +2763,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.61" @@ -2465,9 +2852,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.37.0" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" dependencies = [ "backtrace", "bytes", @@ -2484,9 +2871,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", @@ -2567,6 +2954,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2577,7 +2965,7 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.5.0", "bytes", - "http", + "http 1.1.0", "http-body", "http-body-util", "pin-project-lite", @@ -2604,6 +2992,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2744,6 +3133,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.1" @@ -3123,6 +3518,12 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "yansi-term" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 8e097e6..45c65f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,6 @@ flate2 = "1.0.30" futures-lite = "2.3.0" governor = "0.6.3" headers = "0.4.0" -hex = "0.4.3" http = "1.1.0" humansize = "2.1.3" junit-parser = { path = "crates/junit-parser" } @@ -79,8 +78,14 @@ yarte = "0.15.7" yarte_helpers = "0.15.8" [dev-dependencies] +axum-test = "15.0.1" +flate2 = "1.0.30" +httpdate = "1.0.3" +insta = { version = "1.39.0", features = ["json"] } proptest = "1.4.0" rstest = { version = "0.20.0", default-features = false } +scraper = "0.19.0" +temp_testdir = "0.2.3" [workspace] members = [".", "crates/*"] diff --git a/src/app.rs b/src/app.rs index 32b7d5d..d9da147 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,11 +36,12 @@ use crate::{ templates::{self, ArtifactItem, LinkItem}, util::{self, ErrorJson, ResponseBuilderExt}, viewer::Viewers, - App, }; +pub struct App; + #[derive(Clone)] -struct AppState { +pub struct AppState { i: Arc, } @@ -94,8 +95,19 @@ impl App { .await?; tracing::info!("Listening on port {port}"); + let router = Self::router(state); + + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await?; + Ok(()) + } + + pub fn router(state: AppState) -> Router { let real_ip_header = state.i.cfg.load().real_ip_header.clone(); - let router = Router::new() + Router::new() // Prevent search indexing since artifactview serves temporary artifacts .route("/robots.txt", get(|| async { Response::builder() @@ -132,13 +144,7 @@ impl App { .layer(SetResponseHeaderLayer::appending( http::header::X_CONTENT_TYPE_OPTIONS, http::HeaderValue::from_static("nosniff"), - )); - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await?; - Ok(()) + )) } async fn get_page( @@ -553,20 +559,24 @@ impl App { impl AppState { pub fn new() -> Result { let cfg = Config::new()?; + Ok(Self::from_cfg(cfg)) + } + + pub fn from_cfg(cfg: Config) -> Self { let cache = Cache::new(cfg.clone()); let api = ArtifactApi::new(cfg.clone()); - Ok(Self { + Self { i: Arc::new(AppInner { cfg, cache, api, viewers: Viewers::new(), }), - }) + } } /// Run garbage collection in the background if necessary - pub fn garbage_collect(&self) { + fn garbage_collect(&self) { let state = self.clone(); tokio::spawn(async move { if let Err(e) = state.i.cache.garbage_collect().await { diff --git a/src/artifact_api.rs b/src/artifact_api.rs index 7a48b76..f57d9ab 100644 --- a/src/artifact_api.rs +++ b/src/artifact_api.rs @@ -288,6 +288,7 @@ mod tests { use super::ArtifactApi; #[tokio::test] + #[ignore] async fn fetch_forgejo() { let query = ArtifactQuery::from_subdomain( "code-thetadev-de--hsa--visitenbuch--32-1", @@ -302,6 +303,7 @@ mod tests { } #[tokio::test] + #[ignore] async fn fetch_github() { let query = ArtifactQuery::from_subdomain( "github-com--actions--upload-artifact--8805345396-1440556464", diff --git a/src/cache.rs b/src/cache.rs index 513361c..a2cb665 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -16,7 +16,7 @@ use mime::Mime; use path_macro::path; use quick_cache::sync::Cache as QuickCache; use serde::Serialize; -use serde_hex::{SerHex, Strict}; +use serde_hex::{SerHex, SerHexOpt}; use crate::{ artifact_api::ArtifactApi, @@ -54,8 +54,11 @@ pub struct FileEntry { } pub struct GetEntryResult { + /// Cached zip file metadata pub entry: Arc, + /// Path to the cached zip file pub zip_path: PathBuf, + /// True if the entry was just downloaded pub downloaded: bool, } @@ -75,10 +78,11 @@ pub struct GetFileResultFile { pub struct IndexEntry { pub name: String, pub size: u32, - #[serde(with = "SerHex::")] + #[serde(with = "SerHex::")] pub crc32: u32, } +#[derive(Serialize)] pub struct Listing { pub entries: Vec, pub n_files: usize, @@ -86,16 +90,21 @@ pub struct Listing { pub has_parent: bool, } +#[derive(Serialize)] pub struct ListingEntry { pub name: String, pub url: String, pub size: Size, - pub crc32: String, + pub crc32: Crc32, pub is_dir: bool, } +#[derive(Serialize)] pub struct Size(pub u32); +#[derive(Serialize)] +pub struct Crc32(#[serde(with = "SerHexOpt::")] pub Option); + impl Cache { pub fn new(cfg: Config) -> Self { Self { @@ -384,7 +393,7 @@ impl CacheEntry { name: n.to_owned(), url: format!("{n}{path}"), size: 0.into(), - crc32: "-".to_string(), + crc32: Crc32(None), is_dir: true, }); } else { @@ -392,7 +401,7 @@ impl CacheEntry { name: n.to_owned(), url: format!("{n}{path}"), size: entry.uncompressed_size.into(), - crc32: hex::encode(entry.crc32.to_le_bytes()), + crc32: Crc32(Some(entry.crc32)), is_dir: false, }); } @@ -425,3 +434,199 @@ impl From for Size { Self(value) } } + +#[cfg(test)] +mod tests { + use std::{net::Ipv4Addr, str::FromStr}; + + use rstest::{fixture, rstest}; + use temp_testdir::TempDir; + + use super::*; + + struct TdCache { + cache: Cache, + api: ArtifactApi, + td: TempDir, + } + + impl TdCache { + async fn get_entry(&self, subdomain: &str) -> Result { + self.cache + .get_entry( + &self.api, + &ArtifactQuery::from_subdomain(subdomain, &HashMap::new()).unwrap(), + &IpAddr::V4(Ipv4Addr::LOCALHOST), + ) + .await + } + } + + #[fixture] + fn cache() -> TdCache { + let td = TempDir::default(); + util::tests::setup_cache_dir(&td); + let cfg = Config::from_data(crate::ConfigData { + cache_dir: td.to_path_buf(), + ..Default::default() + }) + .unwrap(); + let cache = Cache::new(cfg.clone()); + let api = ArtifactApi::new(cfg); + TdCache { cache, api, td } + } + + const S1: &str = "codeberg-org--thetadev--artifactview-test--1-1"; + const Z1: &str = "codeberg-org--thetadev--artifactview-test--1-1.zip"; + const S2: &str = "codeberg-org--thetadev--artifactview-test--1-2"; + const Z2: &str = "codeberg-org--thetadev--artifactview-test--1-2.zip"; + const S3: &str = "codeberg-org--thetadev--artifactview-test--1-3"; + const Z3: &str = "codeberg-org--thetadev--artifactview-test--1-3.zip"; + + #[rstest] + #[tokio::test] + async fn get_entry(cache: TdCache) { + let entry = cache.get_entry(S1).await.unwrap(); + + assert_eq!(entry.entry.name, "view"); + assert_eq!(entry.zip_path, path!(cache.td / Z1)); + assert!(!entry.downloaded); + + let files = entry.entry.get_files(); + let mut filenames = files.iter().map(|f| f.name.as_str()).collect::>(); + filenames.sort(); + assert_eq!( + filenames, + [ + ".well-known/test.txt", + "README.md", + "example.rs", + "junit/hello.junit.xml", + "junit/retry.junit.xml", + "junit/simple.junit.xml", + "robots.txt", + "sites/index.html", + "sites/style.css" + ] + ); + } + + #[rstest] + #[tokio::test] + async fn garbage_collect(cache: TdCache) { + let ago = SystemTime::now() - Duration::from_secs(13 * 3600); + let file = std::fs::File::open(path!(cache.td / Z1)).unwrap(); + file.set_times(FileTimes::new().set_accessed(ago)).unwrap(); + let file = std::fs::File::open(path!(cache.td / Z2)).unwrap(); + file.set_times(FileTimes::new().set_accessed(ago)).unwrap(); + + // Access artifact 1, artifact 2 should be deleted + cache.get_entry(S1).await.unwrap(); + + cache.cache.garbage_collect().await.unwrap(); + + assert!(path!(cache.td / Z1).is_file()); + assert!(path!(cache.td / format!("{S1}.name")).is_file()); + assert!(path!(cache.td / Z3).is_file()); + assert!(path!(cache.td / format!("{S3}.name")).is_file()); + assert!(!path!(cache.td / Z2).is_file()); + assert!(!path!(cache.td / format!("{S2}.name")).is_file()); + } + + #[rstest] + #[tokio::test] + async fn get_file(cache: TdCache) { + let entry = cache.get_entry(S1).await.unwrap(); + let res = entry.entry.get_file("example.rs", "").unwrap(); + if let GetFileResult::File(file) = res { + assert_eq!(file.filename, Some("example.rs".to_string())); + assert_eq!(file.file.crc32, 0x2013120c); + assert_eq!(file.status, StatusCode::OK); + assert_eq!(file.mime, Some(Mime::from_str("text/x-rust").unwrap())); + } else { + panic!("no file") + } + } + + #[rstest] + #[tokio::test] + async fn get_file_spa(cache: TdCache) { + let entry = cache.get_entry(S3).await.unwrap(); + let res = entry.entry.get_file("foo/bar", "").unwrap(); + if let GetFileResult::File(file) = res { + assert_eq!(file.filename, None); + assert_eq!(file.file.crc32, 0xBE336584); + assert_eq!(file.status, StatusCode::OK); + assert_eq!(file.mime, Some(Mime::from_str("text/html").unwrap())); + } else { + panic!("no file") + } + } + + #[rstest] + #[tokio::test] + async fn get_file_404(cache: TdCache) { + let entry = cache.get_entry(S2).await.unwrap(); + let res = entry.entry.get_file("foo/bar", "").unwrap(); + if let GetFileResult::File(file) = res { + assert_eq!(file.filename, None); + assert_eq!(file.file.crc32, 0x69F73F18); + assert_eq!(file.status, StatusCode::NOT_FOUND); + assert_eq!(file.mime, Some(Mime::from_str("text/html").unwrap())); + } else { + panic!("no file") + } + } + + #[rstest] + #[case("", &[ + ".well-known/", + "junit/", + "sites/", + "README.md", + "example.rs", + "robots.txt" + ])] + #[case("C=N&O=D", &[ + "sites/", + "junit/", + ".well-known/", + "robots.txt", + "example.rs", + "README.md", + ])] + #[case("C=S&O=A", &[ + ".well-known/", + "junit/", + "sites/", + "robots.txt", + "example.rs", + "README.md", + ])] + #[case("C=S&O=D", &[ + ".well-known/", + "junit/", + "sites/", + "README.md", + "example.rs", + "robots.txt" + ])] + #[tokio::test] + async fn get_file_listing(cache: TdCache, #[case] query: &str, #[case] expect: &[&str]) { + let entry = cache.get_entry(S1).await.unwrap(); + let res = entry.entry.get_file("", query).unwrap(); + if let GetFileResult::Listing(listing) = res { + let filenames = listing + .entries + .iter() + .map(|e| e.name.as_str()) + .collect::>(); + assert_eq!(filenames, expect); + assert_eq!(listing.n_dirs, 3); + assert_eq!(listing.n_files, 3); + assert!(!listing.has_parent); + } else { + panic!("no listing") + } + } +} diff --git a/src/error.rs b/src/error.rs index 357084c..8d3097f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -35,7 +35,7 @@ pub enum Error { Inaccessible, #[error("This artifact has already expired")] Expired, - #[error("timeout")] + #[error("This action took too long")] Timeout(#[from] tokio::time::error::Elapsed), #[error("Method not allowed")] MethodNotAllowed, diff --git a/src/lib.rs b/src/lib.rs index f8d8a9a..78fd696 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,4 +9,6 @@ mod templates; mod util; mod viewer; -pub struct App; +pub use app::{App, AppState}; +pub use config::{Config, ConfigData}; +pub use error::Error; diff --git a/src/query.rs b/src/query.rs index f3993e7..d810b1c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -10,6 +10,7 @@ use crate::{ util, }; +/// Query to select an artifact #[derive(Debug, PartialEq, Eq)] pub struct ArtifactQuery { /// Forge host @@ -26,6 +27,7 @@ pub struct ArtifactQuery { pub artifact: u64, } +/// Query to select a CI run (set of artifacts) #[derive(Debug, PartialEq, Eq)] pub struct RunQuery { /// Forge host diff --git a/src/templates.rs b/src/templates.rs index 5c11ade..ec90fe3 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,6 +1,6 @@ use crate::{ artifact_api::Artifact, - cache::{ListingEntry, Size}, + cache::{Crc32, ListingEntry, Size}, config::Config, query::{Query, QueryRef}, }; @@ -105,3 +105,44 @@ impl Render for Size { ) } } + +impl Render for Crc32 { + fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self.0 { + Some(crc) => write!(f, "{crc:08x}"), + None => f.write_str("—"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Template)] + #[template(src = "{{ rendered }}")] + struct RenderTemplate { + rendered: T, + } + + #[test] + fn crc32() { + let tmpl = RenderTemplate { + rendered: Crc32(Some(0xc538cf99)), + }; + assert_eq!(tmpl.to_string(), "c538cf99"); + + let tmpl = RenderTemplate { + rendered: Crc32(None), + }; + assert_eq!(tmpl.to_string(), "—"); + } + + #[test] + fn size() { + let tmpl = RenderTemplate { + rendered: Size(1000), + }; + assert_eq!(tmpl.to_string(), "1 kB"); + } +} diff --git a/src/util.rs b/src/util.rs index 461905c..5ce1238 100644 --- a/src/util.rs +++ b/src/util.rs @@ -245,6 +245,18 @@ pub fn time_to_ms(time: f64) -> u64 { (time * 1000.0) as u64 } +/// Get the extension from a filename for selecting a viewer +pub fn filename_ext(filename: &str) -> &str { + let mut rsplit = filename.rsplit('.'); + let ext = rsplit.next().unwrap(); + if filename.starts_with('.') && rsplit.next().map(str::is_empty).unwrap_or(true) { + // Dotfile without extension (e.g. .bashrc) + filename + } else { + ext + } +} + #[derive(Serialize)] pub struct ErrorJson { status: u16, @@ -274,7 +286,7 @@ impl IntoResponse for ErrorJson { #[cfg(test)] pub(crate) mod tests { - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use http::{header, HeaderMap}; use once_cell::sync::Lazy; @@ -284,6 +296,25 @@ pub(crate) mod tests { pub static TESTFILES: Lazy = Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "tests" / "testfiles")); + static SITEDIR: Lazy = Lazy::new(|| { + let sitedir = path!(*TESTFILES / "sites_data"); + if !sitedir.is_dir() { + std::process::Command::new(path!(*TESTFILES / "sites" / "make_zip.sh")) + .output() + .unwrap(); + } + sitedir + }); + + pub fn setup_cache_dir(dir: &Path) { + for entry in std::fs::read_dir(SITEDIR.as_path()).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_file() { + std::fs::copy(entry.path(), path!(dir / entry.file_name())).unwrap(); + } + } + } + #[rstest] #[case("", false)] #[case("br", false)] @@ -331,4 +362,14 @@ pub(crate) mod tests { } } } + + #[rstest] + #[case("hello.txt", "txt")] + #[case(".bashrc", ".bashrc")] + #[case("Makefile", "Makefile")] + #[case("", "")] + fn filename_ext(#[case] filename: &str, #[case] expect: &str) { + let res = super::filename_ext(filename); + assert_eq!(res, expect); + } } diff --git a/src/viewer/code.rs b/src/viewer/code.rs index 3a56c31..e8bdf99 100644 --- a/src/viewer/code.rs +++ b/src/viewer/code.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use syntect::{ html::{ClassStyle, ClassedHTMLGenerator}, @@ -12,11 +12,19 @@ use super::Viewer; pub struct CodeViewer { ss: Arc, + smap: HashMap, } impl CodeViewer { pub fn new(ss: Arc) -> Self { - Self { ss } + let smap = ss + .syntaxes() + .iter() + .enumerate() + .flat_map(|(i, s)| s.file_extensions.iter().map(move |ext| (ext.to_owned(), i))) + .collect::>(); + + Self { ss, smap } } } @@ -29,15 +37,13 @@ impl Viewer for CodeViewer { "Code" } - fn is_applicable(&self, _filename: &str, _ext: &str) -> bool { - true + fn is_applicable(&self, _filename: &str, ext: &str) -> bool { + self.smap.contains_key(ext) } 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 i = self.smap.get(ext).ok_or(Error::ViewerNotApplicable)?; + let syntax = &self.ss.syntaxes()[*i]; let mut html_generator = ClassedHTMLGenerator::new_with_class_style(syntax, &self.ss, ClassStyle::Spaced); @@ -54,30 +60,32 @@ impl Viewer for CodeViewer { #[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(); + fn is_applicable() { + let ss = Arc::new(SyntaxSet::load_defaults_newlines()); + let cv = CodeViewer::new(ss); + assert!(cv.is_applicable("hello.txt", "txt")); + assert!(cv.is_applicable(".bashrc", ".bashrc")); + assert!(!cv.is_applicable("image.jpg", "jpg")); + } + + #[test] + fn render() { + let ss = Arc::new(SyntaxSet::load_defaults_newlines()); + let cv = CodeViewer::new(ss); + let res = cv + .try_render( + "hello.rs", + "rs", + r#"fn test() { + let x = "World"; + println!("Hello {x}"); +} +"#, + ) + .unwrap(); + insta::assert_snapshot!(res); } - */ } diff --git a/src/viewer/junit.rs b/src/viewer/junit.rs index bda92f6..a7f4834 100644 --- a/src/viewer/junit.rs +++ b/src/viewer/junit.rs @@ -33,15 +33,24 @@ impl Viewer for JunitViewer { mod tests { use path_macro::path; - use crate::{util::tests::TESTFILES, viewer::Viewer}; + use crate::util::tests::TESTFILES; - use super::JunitViewer; + use super::*; #[test] - fn t1() { + fn is_applicable() { + let ju = JunitViewer; + assert!(ju.is_applicable("junit.xml", "xml")); + assert!(ju.is_applicable("hello.junit.xml", "xml")); + assert!(!ju.is_applicable("hello.xml", "xml")); + } + + #[test] + fn render() { + let ju = JunitViewer; let data = - std::fs::read_to_string(path!(*TESTFILES / "junit" / "simple.junit.xml")).unwrap(); - let html = JunitViewer.try_render("", "", &data).unwrap(); - println!("{html}"); + std::fs::read_to_string(path!(*TESTFILES / "junit" / "hello.junit.xml")).unwrap(); + let res = ju.try_render("hello.junit.xml", "xml", &data).unwrap(); + insta::assert_snapshot!(res); } } diff --git a/src/viewer/markdown.rs b/src/viewer/markdown.rs index f1d158d..c0ed600 100644 --- a/src/viewer/markdown.rs +++ b/src/viewer/markdown.rs @@ -108,3 +108,33 @@ impl SyntaxHighlighterAdapter for SyntectAdapter { output.write_all(b"") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_applicable() { + let ss = Arc::new(SyntaxSet::load_defaults_newlines()); + let mv = MarkdownViewer::new(ss); + assert!(mv.is_applicable("hello.md", "md")); + assert!(!mv.is_applicable("hello.txt", "txt")); + } + + #[test] + fn render() { + let ss = Arc::new(SyntaxSet::load_defaults_newlines()); + let mv = MarkdownViewer::new(ss); + let res = mv + .try_render( + "hello.md", + "md", + r#"# Hello World + +this is a small paragraph for *testing*. +"#, + ) + .unwrap(); + insta::assert_snapshot!(res); + } +} diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index f8d10f6..632b99c 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use syntect::parsing::SyntaxSet; -use crate::{error::Error, templates::ViewerLink}; +use crate::{error::Error, templates::ViewerLink, util}; mod code; mod junit; @@ -21,7 +21,9 @@ pub struct Viewers { } pub struct RenderRes { + /// Body html pub html: String, + /// List of applicable viewers to be inserted into the top bar pub tmpl_viewers: Vec, } @@ -38,7 +40,7 @@ impl Viewers { } pub fn try_render(&self, filename: &str, viewer: &str, data: &str) -> Result { - let ext = filename.rsplit('.').next().unwrap(); + let ext = util::filename_ext(filename); if !viewer.is_empty() && viewer != "1" { if let Some(viewer) = self.viewers.iter().find(|v| v.id() == viewer) { @@ -88,3 +90,29 @@ impl Viewers { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + use rstest::rstest; + + #[rstest] + #[case("test.txt", "", &["code"])] + #[case("hello.md", "", &["md", "code"])] + #[case("junit.xml", r#" "#, &["junit", "code"])] + #[case("img.png", "", &[])] + fn render(#[case] filename: &str, #[case] data: &str, #[case] applicable: &[&str]) { + let viewers = Viewers::new(); + let res = viewers.try_render(filename, "1", data); + + if applicable.is_empty() { + assert!(matches!(res, Err(Error::ViewerNotApplicable))); + } else { + let res = res.unwrap(); + assert!(res.tmpl_viewers[0].selected); + let renderers = res.tmpl_viewers.iter().map(|v| v.id).collect::>(); + assert_eq!(renderers, applicable) + } + } +} diff --git a/src/viewer/snapshots/artifactview__viewer__code__tests__render.snap b/src/viewer/snapshots/artifactview__viewer__code__tests__render.snap new file mode 100644 index 0000000..3edec2c --- /dev/null +++ b/src/viewer/snapshots/artifactview__viewer__code__tests__render.snap @@ -0,0 +1,10 @@ +--- +source: src/viewer/code.rs +assertion_line: 89 +expression: res +--- +
fn test() {
+    let x = "World";
+    println!("Hello {x}");
+}
+
diff --git a/src/viewer/snapshots/artifactview__viewer__junit__tests__render.snap b/src/viewer/snapshots/artifactview__viewer__junit__tests__render.snap new file mode 100644 index 0000000..1daafd5 --- /dev/null +++ b/src/viewer/snapshots/artifactview__viewer__junit__tests__render.snap @@ -0,0 +1,197 @@ +--- +source: src/viewer/junit.rs +assertion_line: 54 +expression: res +--- +
+
+

Test suites:

+
    +
  • + +
  • + +
+
+
+

Test cases:

+

+ + + + + + +

+
    +
  • + +
    +

    lib1::tests::it_works

    +

    Success3ms

    
    +running 1 test
    +test tests::it_works ... ok
    +
    +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
    +
    +
    +
    +
  • + +
  • + +
    +

    lib1::tests::pippi_langstrumpf

    +

    Failure3ms

    thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9:
    +assertion `left == right` failed
    +  left: 7
    + right: 9
    +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    
    +running 1 test
    +test tests::pippi_langstrumpf ... FAILED
    +
    +failures:
    +
    +failures:
    +    tests::pippi_langstrumpf
    +
    +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
    +
    +
    thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9:
    +assertion `left == right` failed
    +  left: 7
    + right: 9
    +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    +
    +
    +
  • + +
+
+
+
+
+

Select a test case to show details

+
+
+
+ + diff --git a/src/viewer/snapshots/artifactview__viewer__markdown__tests__render.snap b/src/viewer/snapshots/artifactview__viewer__markdown__tests__render.snap new file mode 100644 index 0000000..db772bd --- /dev/null +++ b/src/viewer/snapshots/artifactview__viewer__markdown__tests__render.snap @@ -0,0 +1,8 @@ +--- +source: src/viewer/markdown.rs +assertion_line: 138 +expression: res +--- +

Hello World

+

this is a small paragraph for testing.

+
diff --git a/templates/preview.hbs b/templates/preview.hbs index 7498cc6..50b6271 100644 --- a/templates/preview.hbs +++ b/templates/preview.hbs @@ -18,7 +18,7 @@ {{size}} CI run -
+
{{#each viewers}}{{name}}{{/each}} Raw
diff --git a/tests/testfiles/junit/hello.junit.xml b/tests/testfiles/junit/hello.junit.xml new file mode 100644 index 0000000..4da869e --- /dev/null +++ b/tests/testfiles/junit/hello.junit.xml @@ -0,0 +1,40 @@ + + + + + +running 1 test +test tests::it_works ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s + + + + + + thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9: +assertion `left == right` failed + left: 7 + right: 9 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +running 1 test +test tests::pippi_langstrumpf ... FAILED + +failures: + +failures: + tests::pippi_langstrumpf + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s + + + thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9: +assertion `left == right` failed + left: 7 + right: 9 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + + + diff --git a/tests/testfiles/sites/.well-known/test.txt b/tests/testfiles/sites/.well-known/test.txt new file mode 100644 index 0000000..9dc5794 --- /dev/null +++ b/tests/testfiles/sites/.well-known/test.txt @@ -0,0 +1 @@ +This file should NOT be served for security reasons diff --git a/tests/testfiles/sites/200.html b/tests/testfiles/sites/200.html new file mode 100644 index 0000000..56df783 --- /dev/null +++ b/tests/testfiles/sites/200.html @@ -0,0 +1,10 @@ + + + + SPA test + + + +

Hello SPA

+ + diff --git a/tests/testfiles/sites/404.html b/tests/testfiles/sites/404.html new file mode 100644 index 0000000..d724984 --- /dev/null +++ b/tests/testfiles/sites/404.html @@ -0,0 +1,10 @@ + + + + Error 404 + + + +

Not found

+ + diff --git a/tests/testfiles/sites/example.rs b/tests/testfiles/sites/example.rs new file mode 100644 index 0000000..afa0669 --- /dev/null +++ b/tests/testfiles/sites/example.rs @@ -0,0 +1,17 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug)] +struct Point { + x: i32, + y: i32, +} + +fn main() { + let point = Point { x: 1, y: 2 }; + + let serialized = serde_json::to_string(&point).unwrap(); + println!("serialized = {}", serialized); + + let deserialized: Point = serde_json::from_str(&serialized).unwrap(); + println!("deserialized = {:?}", deserialized); +} diff --git a/tests/testfiles/sites/index.html b/tests/testfiles/sites/index.html new file mode 100644 index 0000000..3ec60da --- /dev/null +++ b/tests/testfiles/sites/index.html @@ -0,0 +1,10 @@ + + + + Artifactview test + + + +

Hello World

+ + diff --git a/tests/testfiles/sites/make_zip.sh b/tests/testfiles/sites/make_zip.sh new file mode 100755 index 0000000..619ab7a --- /dev/null +++ b/tests/testfiles/sites/make_zip.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Create artifacts for testing + +set -e +cd "$(dirname "$0")" + +TARGET="../sites_data" +# http://codeberg-org--thetadev--artifactview-test--1-1.localhost:3000 +T_VIEW="codeberg-org--thetadev--artifactview-test--1-1" +# http://codeberg-org--thetadev--artifactview-test--1-2.localhost:3000 +T_404="codeberg-org--thetadev--artifactview-test--1-2" +# http://codeberg-org--thetadev--artifactview-test--1-3.localhost:3000 +T_SPA="codeberg-org--thetadev--artifactview-test--1-3" + +mkdir -p $TARGET +rm -f $TARGET/* + +zip --no-dir-entries $TARGET/$T_SPA index.html style.css 200.html +zip --no-dir-entries $TARGET/$T_404 index.html style.css 404.html + +zip --no-dir-entries -r $TARGET/$T_VIEW robots.txt .well-known +zip --no-dir-entries --junk-paths $TARGET/$T_VIEW example.rs ../../../README.md + +( + cd .. + zip --no-dir-entries -r sites_data/$T_VIEW junit sites/index.html sites/style.css +) + +printf "404" > $TARGET/$T_404.name +printf "spa" > $TARGET/$T_SPA.name +printf "view" > $TARGET/$T_VIEW.name + +if [ -n "$LTST" ]; then + mkdir -p /tmp/artifactview + cp $TARGET/* /tmp/artifactview + echo "copied artifacts for local testing" +fi diff --git a/tests/testfiles/sites/robots.txt b/tests/testfiles/sites/robots.txt new file mode 100644 index 0000000..9dc5794 --- /dev/null +++ b/tests/testfiles/sites/robots.txt @@ -0,0 +1 @@ +This file should NOT be served for security reasons diff --git a/tests/testfiles/sites/style.css b/tests/testfiles/sites/style.css new file mode 100644 index 0000000..460e67a --- /dev/null +++ b/tests/testfiles/sites/style.css @@ -0,0 +1,5 @@ +body { + background-color: black; + color: white; + font-family: monospace; +} diff --git a/tests/tests.rs b/tests/tests.rs new file mode 100644 index 0000000..cdf21e9 --- /dev/null +++ b/tests/tests.rs @@ -0,0 +1,432 @@ +use std::{ + io::Read, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +use axum_test::{TestRequest, TestResponse, TestServer}; +use headers::HeaderMapExt; +use http::{header, HeaderName, HeaderValue, StatusCode}; +use once_cell::sync::Lazy; +use path_macro::path; +use rstest::{fixture, rstest}; + +use artifactview::{App, AppState, Config, ConfigData}; +use scraper::{selectable::Selectable, ElementRef, Html, Selector}; +use temp_testdir::TempDir; + +static TESTFILES: Lazy = + Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "tests" / "testfiles")); + +static SITEDIR: Lazy = Lazy::new(|| { + let sitedir = path!(*TESTFILES / "sites_data"); + if !sitedir.is_dir() { + std::process::Command::new(path!(*TESTFILES / "sites" / "make_zip.sh")) + .output() + .unwrap(); + } + sitedir +}); + +static CACHEDIR: Lazy = Lazy::new(|| { + let td = TempDir::default(); + setup_cache_dir(&td); + td +}); + +const S1: &str = "codeberg-org--thetadev--artifactview-test--1-1"; +const S2: &str = "codeberg-org--thetadev--artifactview-test--1-2"; +const S3: &str = "codeberg-org--thetadev--artifactview-test--1-3"; + +fn setup_cache_dir(dir: &Path) { + for entry in std::fs::read_dir(SITEDIR.as_path()).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_file() { + std::fs::copy(entry.path(), path!(dir / entry.file_name())).unwrap(); + } + } +} + +struct TestAv { + server: TestServer, +} + +#[fixture] +fn server() -> TestAv { + let router = App::router(AppState::from_cfg( + Config::from_data(ConfigData { + cache_dir: CACHEDIR.to_path_buf(), + no_https: true, + real_ip_header: Some("x-forwarded-for".to_string()), + ..Default::default() + }) + .unwrap(), + )); + + let mut server = TestServer::new(router).unwrap(); + + server.add_header( + HeaderName::from_static("x-forwarded-for"), + HeaderValue::from_static("127.0.0.1"), + ); + + TestAv { server } +} + +impl TestAv { + fn get(&self, subdomain: &str, path: &str) -> TestRequest { + self.server.get(path).add_header( + header::HOST, + if subdomain.is_empty() { + HeaderValue::from_static("localhost:3000") + } else { + HeaderValue::from_str(&format!("{subdomain}.localhost:3000")).unwrap() + }, + ) + } + + async fn get_html(&self, subdomain: &str, path: &str) -> Html { + let resp = self.get(subdomain, path).await; + resp.assert_status_ok(); + assert_eq!(resp.header(header::CONTENT_TYPE), "text/html"); + scraper::Html::parse_document(&resp.text()) + } +} + +#[derive(Debug)] +struct FileEntry { + name: String, + size: String, + crc32: String, +} + +#[derive(Debug, PartialEq, Eq)] +struct Link { + text: String, + href: String, + selected: bool, +} + +fn text_from_elm(elm: &ElementRef<'_>) -> String { + elm.text() + .fold(String::new(), |acc, s| acc + s) + .trim() + .to_owned() +} + +fn parse_link(elm: &ElementRef<'_>) -> Link { + assert_eq!(elm.value().name(), "a"); + Link { + text: text_from_elm(elm), + href: elm.attr("href").expect("href").to_owned(), + selected: elm + .value() + .has_class("selected", scraper::CaseSensitivity::CaseSensitive), + } +} + +#[track_caller] +fn assert_cache_immutable(resp: &TestResponse) { + assert_eq!( + resp.header(header::CACHE_CONTROL), + "max-age=31536000,public,immutable" + ); +} + +#[rstest] +#[tokio::test] +async fn homepage(server: TestAv) { + let doc = server.get_html("", "/").await; + let elm = doc + .select(&Selector::parse("title").unwrap()) + .next() + .unwrap(); + assert_eq!(text_from_elm(&elm), "Artifactview"); +} + +#[rstest] +#[case(S1, "/example.rs", true, "example.rs", "text/x-rust")] +#[case(S1, "/sites/", true, "index.html", "text/html")] +#[case(S1, "/foo.txt", false, "", "text/html")] +#[case(S1, "/.well-known/test.txt", false, "", "text/html")] +// 404 fallback +#[case(S2, "/foo.txt", false, "404.html", "text/html")] +// SPA +#[case(S3, "/", true, "index.html", "text/html")] +#[case(S3, "/foo.txt", true, "200.html", "text/html")] +#[tokio::test] +async fn get_file( + server: TestAv, + #[case] sd: &str, + #[case] path: &str, + #[case] found: bool, + #[case] exp_path: &str, + #[case] mime: &str, +) { + let resp = server.get(sd, path).await; + assert_eq!(resp.header(header::CONTENT_TYPE), mime); + + if found { + resp.assert_status_ok(); + assert_cache_immutable(&resp); + } else { + resp.assert_status_not_found(); + } + + if !exp_path.is_empty() { + let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / exp_path)).unwrap(); + assert_eq!(resp.text(), expect); + } +} + +#[rstest] +#[tokio::test] +async fn robots_txt(server: TestAv) { + let resp1 = server.get("", "/robots.txt").await; + let resp2 = server.get(S1, "/robots.txt").await; + + assert!(resp1.text().contains("\nUser-agent: *\nDisallow: /\n")); + assert!(resp2.text().contains("\nUser-agent: *\nDisallow: /\n")); +} + +#[rstest] +#[tokio::test] +async fn stylesheet(server: TestAv) { + let html = server.get_html(S1, "/README.md?viewer=1").await; + for path in html + .select(&Selector::parse("link[rel=\"stylesheet\"]").unwrap()) + .map(|elm| { + elm.attr("href") + .expect("href") + .strip_prefix("http://localhost:3000") + .expect("localhost url") + }) + { + let resp = server.get("", path).await; + resp.assert_status_ok(); + assert_eq!(resp.header(header::CONTENT_TYPE), "text/css"); + assert_cache_immutable(&resp); + + // Remove running number from stylesheet path + let fname = path + .strip_prefix('/') + .unwrap() + .strip_suffix(".css") + .unwrap() + .trim_end_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); + let expect = std::fs::read_to_string(path!( + env!("CARGO_MANIFEST_DIR") / "resources" / format!("{fname}.css") + )) + .unwrap(); + assert_eq!(resp.text(), expect); + } +} + +fn parse_listing(doc: &Html) -> Vec { + let sel_col = Selector::parse("td").unwrap(); + doc.select(&Selector::parse(".file").unwrap()) + .map(|elm| { + let icn = elm + .select(&Selector::parse("svg > use").unwrap()) + .next() + .expect("icon"); + let (k, icn_id) = icn.value().attrs().next().expect("icon id"); + assert_eq!(k, "href"); + assert!(matches!(icn_id, "#file" | "#folder")); + + let mut parts = elm.select(&sel_col).map(|elm| text_from_elm(&elm)); + + let name = parts.next().expect("name"); + let folder = icn_id == "#folder"; + assert_eq!(name.ends_with('/'), folder); + + FileEntry { + name, + size: parts.next().expect("size"), + crc32: parts.next().expect("crc32"), + } + }) + .collect() +} + +#[rstest] +#[case("/", &[ + ".well-known/", + "junit/", + "sites/", + "README.md", + "example.rs", + "robots.txt" +])] +#[case("/?C=N&O=D", &[ + "sites/", + "junit/", + ".well-known/", + "robots.txt", + "example.rs", + "README.md", +])] +#[tokio::test] +async fn listing(server: TestAv, #[case] path: &str, #[case] expect: &[&str]) { + let doc = server.get_html(S1, path).await; + let files = parse_listing(&doc); + let file_names = files.iter().map(|f| f.name.to_owned()).collect::>(); + + assert_eq!(file_names, expect); + + let example_rs = files + .iter() + .find(|f| f.name == "example.rs") + .expect("example.rs"); + assert_eq!(example_rs.size, "406 B"); + assert_eq!(example_rs.crc32, "2013120c"); +} + +#[rstest] +// JUnit +#[case("/junit/hello.junit.xml?viewer=1", "junit", &[("junit", "JUnit"), ("code", "Code")])] +#[case("/junit/hello.junit.xml?viewer=junit", "junit", &[("junit", "JUnit"), ("code", "Code")])] +#[case("/junit/hello.junit.xml?viewer=code", "code", &[("junit", "JUnit"), ("code", "Code")])] +#[case("/junit/hello.junit.xml?viewer=md", "", &[])] +#[case("/example.rs?viewer=1", "code", &[("code", "Code")])] +#[case("/README.md?viewer=1", "md", &[("md", "Markdown"), ("code", "Code")])] +#[tokio::test] +async fn viewer( + server: TestAv, + #[case] path: &str, + #[case] vid: &str, + #[case] exp_viewers: &[(&str, &str)], +) { + if vid.is_empty() { + // Raw file + let resp = server.get(S1, path).await; + resp.assert_status_ok(); + assert_eq!(resp.header(header::CONTENT_TYPE), "text/xml"); + assert_cache_immutable(&resp); + } else { + let doc = server.get_html(S1, path).await; + let viewers = doc + .select(&Selector::parse("#viewers a").unwrap()) + .map(|elm| parse_link(&elm)) + .filter(|ln| ln.text != "Raw") + .collect::>(); + + let viewer_names = viewers + .iter() + .map(|v| { + ( + v.href.strip_prefix("?viewer=").expect("link prefix"), + v.text.as_str(), + ) + }) + .collect::>(); + assert_eq!(viewer_names, exp_viewers); + + // Selected viewer + let mut sel_iter = viewers.iter().filter(|ln| ln.selected); + let selected = sel_iter.next().expect("selected"); + assert_eq!(selected.href, format!("?viewer={vid}")); + assert!(sel_iter.next().is_none()); + } +} + +#[rstest] +#[tokio::test] +async fn if_modified(server: TestAv) { + let resp = server.get(S1, "/README.md").await; + let lastmod = SystemTime::from( + resp.headers() + .typed_get::() + .expect("last modified"), + ); + let bef_lastmod = lastmod - Duration::from_secs(1); + + // if-modified-since + let resp = server + .get(S1, "/README.md") + .add_header( + header::IF_MODIFIED_SINCE, + HeaderValue::from_str(&httpdate::HttpDate::from(lastmod).to_string()).unwrap(), + ) + .await; + resp.assert_status(StatusCode::NOT_MODIFIED); + + let resp = server + .get(S1, "/README.md") + .add_header( + header::IF_MODIFIED_SINCE, + HeaderValue::from_str(&httpdate::HttpDate::from(bef_lastmod).to_string()).unwrap(), + ) + .await; + resp.assert_status_ok(); + + // if-unmodified-since + let resp = server + .get(S1, "/README.md") + .add_header( + header::IF_UNMODIFIED_SINCE, + HeaderValue::from_str(&httpdate::HttpDate::from(lastmod).to_string()).unwrap(), + ) + .await; + resp.assert_status_ok(); + + let resp = server + .get(S1, "/README.md") + .add_header( + header::IF_UNMODIFIED_SINCE, + HeaderValue::from_str(&httpdate::HttpDate::from(bef_lastmod).to_string()).unwrap(), + ) + .await; + resp.assert_status(StatusCode::PRECONDITION_FAILED); +} + +#[rstest] +#[tokio::test] +async fn range_request(server: TestAv) { + let resp = server + .get(S1, "/example.rs") + .add_header(header::RANGE, HeaderValue::from_static("bytes=10-99")) + .await; + resp.assert_status(StatusCode::PARTIAL_CONTENT); + assert_cache_immutable(&resp); + assert_eq!(resp.header(header::CONTENT_TYPE), "text/x-rust"); + assert_eq!(resp.header(header::CONTENT_LENGTH), "90"); + assert_eq!(resp.header(header::CONTENT_RANGE), "bytes 10-99/406"); + + let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); + assert_eq!(resp.text(), &expect[10..100]); +} + +#[rstest] +#[tokio::test] +async fn range_request_start(server: TestAv) { + let resp = server + .get(S1, "/example.rs") + .add_header(header::RANGE, HeaderValue::from_static("bytes=100-")) + .await; + resp.assert_status(StatusCode::PARTIAL_CONTENT); + assert_cache_immutable(&resp); + assert_eq!(resp.header(header::CONTENT_TYPE), "text/x-rust"); + assert_eq!(resp.header(header::CONTENT_LENGTH), "306"); + assert_eq!(resp.header(header::CONTENT_RANGE), "bytes 100-405/406"); + + let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); + assert_eq!(resp.text(), &expect[100..]); +} + +#[rstest] +#[tokio::test] +async fn compressed(server: TestAv) { + let resp = server + .get(S1, "/example.rs") + .add_header(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")) + .await; + let bts = resp.into_bytes().to_vec(); + let mut gz = flate2::read::GzDecoder::new(&bts[..]); + + let mut buf = String::new(); + gz.read_to_string(&mut buf).unwrap(); + + let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); + assert_eq!(buf, expect); +} From 134bdaa34bd750a64a8b7b33d5d4383de6f51188 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 14 Jun 2024 00:53:09 +0200 Subject: [PATCH 4/6] feat: add compressed stylesheets --- Justfile | 3 +++ resources/content.css.gz | Bin 0 -> 3149 bytes resources/style.css.gz | Bin 0 -> 1221 bytes src/app.rs | 33 +++++++++++++++++++++++++-------- 4 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 resources/content.css.gz create mode 100644 resources/style.css.gz diff --git a/Justfile b/Justfile index 7e99822..f4ecb96 100644 --- a/Justfile +++ b/Justfile @@ -1,6 +1,9 @@ test: cargo test +compress-res: + cd resources && zopfli *.css + release: #!/usr/bin/env bash set -e diff --git a/resources/content.css.gz b/resources/content.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..8bfaea9eb511efa1bfb814243fc07f2278288f8e GIT binary patch literal 3149 zcmV-T46^ediwFP!000021Km1ln&Yg||N9jB`u&p3($;wI9#8iC$$Ek8Sd?X^5h#GV z+u6^Oqva$4;WEYux3Vui&-js`s;IpR!H0LnwlODs82Z;IKpeJSSl=Tkqvv*;7eehqeJ ziG%V9)Q-#N$;+l7ospkzes^v8I*E5MeBv$mQ3I&5kK;Ce@yC8ZkXF(sD4(;&C#Jww1=n zCX2bB-JKri;c*cjm*H_09@lOa6!!Tj*Lphrl(0Pa7EU?JDrdfm7%YsrJ9~59aK>{q zJHMD+M6-*F*=01lyqH}@v#X2Q^>G&8&_`qYGqDH7VtpT>wo*}0K^fK;wd9NzPei?$ zcu$^qp!wTR=iwAgS}veTd~WW>!g}+6@_Nph;}sXF9-K{MpydEi46#Zfrsc!^1)X)2 z7$RW|yqF1hPUYlDD8+=@lQFkkR*%2xqzTrhUs^k?2=*KvA-uhV^4f;wARdAK7*Pl*$@#3STI+mlC^{K1KfM}Gr;7fKT~8O zXfd9pCgbop=J~cM!w}A@Px<7Ua(sQ^Og$LFLF#kj;tREy%diNu*}_gLxJ$;# z&HyUlm@o#GDmzxX>Q(f=XGIbxqLLRzyQ@X{#_s6M9vUcaemiWs29 z_E6}9Fi;t{g)2cAW)7tw4V1f1RBj_^mjt_N1zJmGIcUI;Xsm2VrKFwFMk-Q6*+|NE z3{>e;!`Y0h$z{JftL*R-QOw17*%C!8U2ybUw{A!Jr8RgKDo(U6w_q)M6#@j!Y!j`X znRp_cIe^Tb5!Q1u``E))vS~n=3u5b!YkKc(uZ^WG1+QQ6sZiTbGp*(L6t2h73?LapiV2`9O#O)Q0f*cMtCAcVuy(G1>BB)yq`I? zV#f_$rDO}r94r%$aBy)x;JyjbkMs5Y+z~~uJ(B_1x#omflOPj)6vROht>K7-cbpCq zdW?%@=3DsqK-@Axezg2xq=ooKFf__JxHnn@%@$NTu7J>I#S9<>vU!{*#wfP!3``u` z*V&jsp9)|XM7L9@ z77snqhN#D`E%!hu6Pd-+)_N;BJx6!%9``z&-^V$~%my!jc7lT9Okh;^TPg~bRK1cn z#R=dH%h#NIi9$An!@E1ZEAo6C%V&AaRI&wcTM7zxpg>emekM<3un*W1{Ro`r;x0mT zrME3V>-fEvuQvc?doX(`hH4j;jVGB_sdB4G1;bMy=p+g1GreGZmLcLJm3g@wxR#x&z>UhM!zl?KhhOWOj%aC$cjOu{5Lk08 z6X7`S+bJTbSE$Yr^F1-nVxz-^0o6*U1X@zJD^~Gh825g(7!nj0_6GP{v7Me`=CD2z zv-dN(?lb9;^r6{G?phQs>w&~(SuLH&FGF*_f9GXp8reqtGm%h%kq_q5I7V8s1lFWz zm89Wx`+$x^2~Qx5z6P;3rX8H8OgUMePLK^@Vhm)uY91EXPaCJw?3oMd;m$?2!Ef>< z8xd2uV5tx(fqd+%W3eJ19UTbQeSUO67GpL)|0rssqH#P!c_QQaNIfWQbTsfC#?$C_ zJV71PFX1j*-le1cQ~%+oZ1*juK)+2dbwxPIriJ4{77>UWcgUZ0vem=j@?AkP9a!l! zl8g&N2wl}}53xAGPc2a}@#J})s-)*1b&tIb7O+m6S9ffV5u+q`)<<4&%C5kPZ_~U6 zb92TCb`|8wMFB&ggw>tsxkamKqdIFRlCEKvuCVND@LFwe2ZYeP}i%(l@Hjdx?E z+uVcRw25q6A2GJ`cQ8OQB<vekW|5J0vM^#^g9(#W zi^>SwMU2kuw#R|HpS{FRD_=0IZdgTl*y4o@m6`xQrXdHJtxwW^Y^CHtL}_Hh^V@(Z zBXTAlOT!F@ekVcYv$z`qp`a_pd~HO)Y=rp~EF6iPI+!+R3tj{J1vAi$fE`B?w}~Sl z-Og_WnBVth0CVMOJU9qClGhC73^0iC!euDuz%#TnmdFs#!jGh`$a9`@3Okaji&aVt z8A&uiCEo;mSh0rG@H!X*y!u&y0*AikV!K_otw3NVG^hwVUqT#+6-m@!d2{CgG?!o! zUTS83QFq3G8qHB2C-8+h)!uTILCJ8&Y9UnyC8O9*7`JC0hjdeZwn{RW$74GVFPRHw z7U`P}P)3S3@$sZRO~kX&h^YH>^p0m-?$?w4UH|(^c}udM1#hoc_vWQMYxz3jRZPJ zi5x1*UMr`<+29f3a(eXwVoWr9ooYUOu5F0v^xF&}7WqXE5jEarF1etABFL{>sDAUy zb+_%ScK#L^C3+8;YhadYM8y)`b{17!x~%`CI0>HHpI1*@N!FX90%1V;iJG(nN6ho| z9+CsX^Il~9x}xu5A&hwIB!~s@?3bkf0+2fl97{V&QoG$rU8wQgT~`#_iI?a&Ic;`O z_&-LEk_cy))`>1JWK@fvo1MKZMR$ChyH#rx5xV~{U^3Y$kPB15Icqi3j@7_vYKH5d zy)$t_CVTmW@fFfWxntab0(!0#`(U|GJU2wome3ls1$&nyw&7F!+du#DPq!Bq!D|tY zVEc3g&98|H`H5>5j;{8#Tix|%T(>{VpH@$&4>sv9R*p=D%8oGJDY!<3tf#M00i~q6 zMq_>dDh>U}+7{?FDhvO2RUS?r57%hW#d;4i*U0a`r>Txn~KPr?D zyq6<2^RcRc6Bo;$2J1q&{^qmDPAXe{bVWG4@$p>OHpR}~zKu%z+-Cde2CEy%6@J$;mC{E*{W6)4Az(gw6CoSI%^b999Qqm9uZwD8ZS7+Dt3t3nL|*wnO6S+2_V(W^lwf8C{`5zIP|YDcf#Bv nt{3Bsj`q6G3Fd@A8z*@N_as@_QwPLNu*duhSfxB1M>qfg;@1cL literal 0 HcmV?d00001 diff --git a/resources/style.css.gz b/resources/style.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..8fa47ee4a0dac0bbbe876dd074d5679a3734c2c3 GIT binary patch literal 1221 zcmV;$1Uma4iwFP!000021C>|Tn(HVGzMrRH`{aD%)Ty3r&#>(t8#!hN0}G^WdG|F@ zd?FHjN_y0hehHYpW50%j0^b4z))>dQQjEJG8(xR4gdMYd4W7N5-MnM}+_3&(xe!vX zOE!N!{_nV3Oa1OnyrK;*2E1RPzqz(v&h%5S=^FXtVb)&WSIiw~F_vY831-#`voc(m{K9niAta6L)~{)5djNA!FCd0EM@_hRE1CZjljl+9WxvWcKo6462} zG}8G{)U8ygO9F_Z9hdsD)|E2#TV$SLGyGyd_F~&{aBwBpx{v`nU$Po>0GEOznw@qK zxZr_b7jv=1VqP@rAK(Pn{O+n_ad4c`WcSP)B}HGIIzBU4T+BzXh@80WMydervPELK zh*RPXCtB20(hse*lOnXy==>Y176GBS^-!|m*I6^5)9ktg_bmuSRXR2&#K3XX1&6>v zW7*E?A5arLaX0*GDa4NTESZ8PBss%mA%uwDb+SL+_(}O6k(h5;>g%JuMuq)$eG>1J z5@gNQu&_=Fp=bfHNwWU0qm76ls-+GscG~S?a?WQ$zDQC`>@dOzXr3cxvWAz`FePWI z=HeuL&CdC}@m1dV>eGxDk%X%mPYbDqHD2QI;*~dQ6Vf_N*Jfky$bZc{NJiKxdyDDQ z!?5e@j1xuWfWMXO_|=E2E@pkK<-m>d8NgWw#Q?eKUmI|TLTbASDd?nb!O4(Eaeh){ z+IRALHD=f|X0Y0XV01DPPDcU^W8-{`!y1W$HjB^v1lVW~0htLUF1QMMY;vUW2mR6D zlR<=YHRe7dMT7UZJ<=ycn%6;YVO;#_B;FX4o6tS-5PswP3dJyPP|==EfQ2Q*={Bvo z6Q6te zcF2J(a}z?D^dxXm!^OFV|HZt(-bx4J0=#R-55DFKZhLtI?-Y-kD3}JwzL1?YE^r-a z@~ndfoF8sybqgJoOmO!$EOofxjosqr&&&JO6Gof4$q6Blm%Xno7x9@Fz-!xTwW4f# zL3rL=75@$%%IJ!)q35#j#>>yY?saS#9if2V9% literal 0 HcmV?d00001 diff --git a/src/app.rs b/src/app.rs index d9da147..581060f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,7 +73,9 @@ pub(crate) const STYLE_CONTENT_PATH: &str = "/content2.css"; const FAVICON_BYTES: &[u8] = include_bytes!("../resources/favicon.ico"); const STYLE_MAIN_BYTES: &[u8] = include_bytes!("../resources/style.css"); +const STYLE_MAIN_BYTES_GZ: &[u8] = include_bytes!("../resources/style.css.gz"); const STYLE_CONTENT_BYTES: &[u8] = include_bytes!("../resources/content.css"); +const STYLE_CONTENT_BYTES_GZ: &[u8] = include_bytes!("../resources/content.css.gz"); impl App { pub fn new() -> Self { @@ -156,7 +158,7 @@ impl App { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; if subdomain.is_empty() { - Self::get_homepage(state, uri).await + Self::get_homepage(state, uri, request.headers()).await } else { let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; @@ -246,15 +248,19 @@ impl App { } } - async fn get_homepage(state: AppState, uri: Uri) -> Result, Error> { + async fn get_homepage( + state: AppState, + uri: Uri, + hdrs: &HeaderMap, + ) -> Result, Error> { if uri.path() == FAVICON_PATH { return Self::favicon(); } if uri.path() == STYLE_MAIN_PATH { - return Self::stylesheet(STYLE_MAIN_BYTES); + return Self::stylesheet(hdrs, STYLE_MAIN_BYTES, STYLE_MAIN_BYTES_GZ); } if uri.path() == STYLE_CONTENT_PATH { - return Self::stylesheet(STYLE_CONTENT_BYTES); + return Self::stylesheet(hdrs, STYLE_CONTENT_BYTES, STYLE_CONTENT_BYTES_GZ); } if uri.path() != "/" { return Err(Error::NotFound("path".into())); @@ -548,11 +554,22 @@ impl App { .body(FAVICON_BYTES.into())?) } - fn stylesheet(content: &'static [u8]) -> Result, Error> { - Ok(Response::builder() + fn stylesheet( + hdrs: &HeaderMap, + content: &'static [u8], + content_gz: &'static [u8], + ) -> Result, Error> { + let resp = Response::builder() .typed_header(headers::ContentType::from(mime::TEXT_CSS)) - .cache_immutable() - .body(content.into())?) + .cache_immutable(); + + if util::accepts_gzip(hdrs) { + Ok(resp + .typed_header(headers::ContentEncoding::gzip()) + .body(content_gz.into())?) + } else { + Ok(resp.body(content.into())?) + } } } From 194499b2768325e27dc77e0f3a71c410667a8851 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 14 Jun 2024 01:24:45 +0200 Subject: [PATCH 5/6] fix: improve path header --- .pre-commit-config.yaml | 8 ++++++++ resources/style.css | 10 +++------- resources/style.css.gz | Bin 1221 -> 1210 bytes src/app.rs | 15 ++++++++++----- templates/listing.hbs | 4 +--- templates/preview.hbs | 5 +---- templates/selection.hbs | 5 +---- 7 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c77c173..0aca880 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,3 +10,11 @@ repos: - id: cargo-fmt - id: cargo-clippy args: ["--all", "--tests", "--", "-D", "warnings"] + + - repo: local + hooks: + - id: compress-res + name: Compress resources + language: system + entry: zopfli + files: "^resources/.+.css$" diff --git a/resources/style.css b/resources/style.css index 6eb520f..1516292 100644 --- a/resources/style.css +++ b/resources/style.css @@ -45,16 +45,15 @@ header h1 { } header h1 a { color: var(--color-text); - margin: 0 4px; +} +header h1 .sep { + margin: 0 0.2em; } footer a:hover, header h1 a:hover, a.selected { text-decoration: underline; } -header h1 a:first-child { - margin: 0; -} main { display: block; } @@ -189,9 +188,6 @@ p { th:nth-child(2) { display: none; } - h1 a { - margin: 0; - } #filter { max-width: 100px; } diff --git a/resources/style.css.gz b/resources/style.css.gz index 8fa47ee4a0dac0bbbe876dd074d5679a3734c2c3..2efabdc2876cfa2b2661e2d6a7acc85f416ecaff 100644 GIT binary patch literal 1210 zcmV;r1V#HFiwFP!000021C>`-p7SOUf8SG(zD=%My=iAidq*&`$Rbbxx0JgFJl5xt z7X9M5dwTgZ!1R{=91a?M4G>sm9phRv?t-eg2wMd^*77xYc00YgWq)0<_-MJ5%B)vx z_ICW=aJN?G)8C>HR$Eti5Y|ZU4lDMBg!Jt51MEX&&1SQY^g^q3owzSAFMV!lodg#- zLWmq^5m{wf4p_k)Jw1V3_BnivUetCQdCU2$sH(TCf38X^52pfD)`X(sjnXkU&y6oU zI8~vAWB3k?1a~ZwZG&pmpWs^ni6SKz?`vl_MxwXqr+Q3JWS?kT?yhM~Zc-E+L&(YS z8xnv7VI2?3#>9;^aMWGJoWR{PKJlPI1dyZFumDGwHE@}*E*e1#& zXQZ-=^dv#mbJ|89TeHHr&%_-d{ET%C*T~q_DnQ{|E+8>>jQ^NcW3Qacq~d#g6l4^ zMoFoG)&T|c{)f>fL=e@L33VZArRDYf=3LJnc~GQOSTd|S(7YtfWDPHc;M8a&w%R+k5TCZ>-@zPtp3H?4y)^^k0lmCV{&>M}X>dhz5kKO*V zvrZPJ1OC2Z$FCT!9!&Mr%Z{7mGk|Ly6dmLye{8@V3T4_&=z~s~8k`Dw6z3;JX8A^_ z4#y08!3vg}5UlBqgwy7aVQidFakN(9pv~elKLIwzLqKMak`gY18J!%x_=EY+;Cq7z z=W42bLW%+JuOrfLh%^^LZDCye`6OOho14%Pc?iGpeT8BeH>hN56Q)F&Fk_7lBSPbePqCm!BBIbu7&6=zmwA0_^ z2mP6!`}0rA8TPGmAP2x)JAUwjYq*Z`2+Ro{7*VkAoPDO6*1Et=poM82RN#Df#}YL( z5wMcG_b!d@nl^S9o4qU^md_Y%<~}B+L|*pRwV0T!6g&K@H(`iSs<0Vd~r6E_e501x+IG5`Po literal 1221 zcmV;$1Uma4iwFP!000021C>|Tn(HVGzMrRH`{aD%)Ty3r&#>(t8#!hN0}G^WdG|F@ zd?FHjN_y0hehHYpW50%j0^b4z))>dQQjEJG8(xR4gdMYd4W7N5-MnM}+_3&(xe!vX zOE!N!{_nV3Oa1OnyrK;*2E1RPzqz(v&h%5S=^FXtVb)&WSIiw~F_vY831-#`voc(m{K9niAta6L)~{)5djNA!FCd0EM@_hRE1CZjljl+9WxvWcKo6462} zG}8G{)U8ygO9F_Z9hdsD)|E2#TV$SLGyGyd_F~&{aBwBpx{v`nU$Po>0GEOznw@qK zxZr_b7jv=1VqP@rAK(Pn{O+n_ad4c`WcSP)B}HGIIzBU4T+BzXh@80WMydervPELK zh*RPXCtB20(hse*lOnXy==>Y176GBS^-!|m*I6^5)9ktg_bmuSRXR2&#K3XX1&6>v zW7*E?A5arLaX0*GDa4NTESZ8PBss%mA%uwDb+SL+_(}O6k(h5;>g%JuMuq)$eG>1J z5@gNQu&_=Fp=bfHNwWU0qm76ls-+GscG~S?a?WQ$zDQC`>@dOzXr3cxvWAz`FePWI z=HeuL&CdC}@m1dV>eGxDk%X%mPYbDqHD2QI;*~dQ6Vf_N*Jfky$bZc{NJiKxdyDDQ z!?5e@j1xuWfWMXO_|=E2E@pkK<-m>d8NgWw#Q?eKUmI|TLTbASDd?nb!O4(Eaeh){ z+IRALHD=f|X0Y0XV01DPPDcU^W8-{`!y1W$HjB^v1lVW~0htLUF1QMMY;vUW2mR6D zlR<=YHRe7dMT7UZJ<=ycn%6;YVO;#_B;FX4o6tS-5PswP3dJyPP|==EfQ2Q*={Bvo z6Q6te zcF2J(a}z?D^dxXm!^OFV|HZt(-bx4J0=#R-55DFKZhLtI?-Y-kD3}JwzL1?YE^r-a z@~ndfoF8sybqgJoOmO!$EOofxjosqr&&&JO6Gof4$q6Blm%Xno7x9@Fz-!xTwW4f# zL3rL=75@$%%IJ!)q35#j#>>yY?saS#9if2V9% diff --git a/src/app.rs b/src/app.rs index 581060f..0e84191 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,8 +73,11 @@ pub(crate) const STYLE_CONTENT_PATH: &str = "/content2.css"; const FAVICON_BYTES: &[u8] = include_bytes!("../resources/favicon.ico"); const STYLE_MAIN_BYTES: &[u8] = include_bytes!("../resources/style.css"); -const STYLE_MAIN_BYTES_GZ: &[u8] = include_bytes!("../resources/style.css.gz"); const STYLE_CONTENT_BYTES: &[u8] = include_bytes!("../resources/content.css"); + +#[allow(unused_variables)] +const STYLE_MAIN_BYTES_GZ: &[u8] = include_bytes!("../resources/style.css.gz"); +#[allow(unused_variables)] const STYLE_CONTENT_BYTES_GZ: &[u8] = include_bytes!("../resources/content.css.gz"); impl App { @@ -554,6 +557,7 @@ impl App { .body(FAVICON_BYTES.into())?) } + #[allow(unused_variables)] fn stylesheet( hdrs: &HeaderMap, content: &'static [u8], @@ -563,13 +567,14 @@ impl App { .typed_header(headers::ContentType::from(mime::TEXT_CSS)) .cache_immutable(); + // Dont serve compressed stylesheets in debug mode to allow live changes + #[cfg(not(debug_assertions))] if util::accepts_gzip(hdrs) { - Ok(resp + return Ok(resp .typed_header(headers::ContentEncoding::gzip()) - .body(content_gz.into())?) - } else { - Ok(resp.body(content.into())?) + .body(content_gz.into())?); } + Ok(resp.body(content.into())?) } } diff --git a/templates/listing.hbs b/templates/listing.hbs index 10c1c78..94874e5 100644 --- a/templates/listing.hbs +++ b/templates/listing.hbs @@ -4,9 +4,7 @@ {{> partial/fileIcons }}
{{> partial/logoLink }} -

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

+

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