From 65b4d8ba06e2d909ccb20fdbbfd15f469c6c8854 Mon Sep 17 00:00:00 2001 From: Carlos Feria <2582866+carlosthe19916@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:16:26 +0200 Subject: [PATCH] feat: generate downloadable static report using POST /v2/ui/generate-sbom-static-report Signed-off-by: Carlos Feria <2582866+carlosthe19916@users.noreply.github.com> --- Cargo.lock | 47 ++++++++++++++++++ Cargo.toml | 1 + README.md | 2 +- docs/book/package-lock.json | 7 +-- modules/ui/Cargo.toml | 7 ++- modules/ui/src/endpoints.rs | 99 +++++++++++++++++++++++++++++++++++-- modules/ui/src/error.rs | 2 + modules/ui/tests/extract.rs | 4 +- openapi.yaml | 36 ++++++++++++++ server/src/profile/api.rs | 2 +- 10 files changed, 194 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa2d13117..b935020ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,44 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "actix-multipart" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more 0.99.20", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.8.5", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" +dependencies = [ + "darling 0.20.11", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "actix-router" version = "0.5.3" @@ -5163,6 +5201,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + [[package]] name = "parse-zoneinfo" version = "0.3.1" @@ -8749,13 +8793,16 @@ dependencies = [ name = "trustify-module-ui" version = "0.3.5" dependencies = [ + "actix-multipart", "actix-web", "actix-web-static-files", "anyhow", + "flate2", "serde", "serde-cyclonedx", "serde_json", "spdx-rs", + "tar", "test-log", "thiserror 2.0.12", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 61303c8a6..fb5d3ab54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ actix-web = "4.3.1" actix-web-extras = "0.1" actix-web-httpauth = "0.8" actix-web-static-files = "4.0.1" +actix-multipart = "0.7.2" anyhow = "1.0.72" async-compression = "0.4.13" async-graphql = "7.0.5" diff --git a/README.md b/README.md index 498540b55..ffef8253a 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ CPE (Product?) and/or pURLs described by the SBOM ## Related Projects -* [Trustify user interface](https://github.com/trustification/trustify-ui) +* [Trustify user interface](https://github.com/guacsec/trustify-ui) * [Helm charts used for the Trustify deployment](https://github.com/trustification/trustify-helm-charts) * [OpenShift Operator used for the Trustify deployment](https://github.com/trustification/trustify-operator) * [Ansible playbooks used for the Trustify deployment](https://github.com/trustification/trustify-ansible) diff --git a/docs/book/package-lock.json b/docs/book/package-lock.json index ce7d37c2d..2cfaac9bf 100644 --- a/docs/book/package-lock.json +++ b/docs/book/package-lock.json @@ -673,9 +673,10 @@ ] }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" diff --git a/modules/ui/Cargo.toml b/modules/ui/Cargo.toml index c4334358f..48daffe91 100644 --- a/modules/ui/Cargo.toml +++ b/modules/ui/Cargo.toml @@ -12,16 +12,19 @@ trustify-module-ingestor = { workspace = true } actix-web = { workspace = true } actix-web-static-files = { workspace = true } +actix-multipart = { workspace = true } anyhow = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde-cyclonedx = { workspace = true } serde_json = { workspace = true } spdx-rs = { workspace = true } thiserror = { workspace = true } -tokio = {workspace = true } +tokio = { workspace = true } trustify-ui = { workspace = true } utoipa = { workspace = true } utoipa-actix-web = { workspace = true } +flate2 = { workspace = true } +tar = { workspace = true } [dev-dependencies] test-log = { workspace = true } diff --git a/modules/ui/src/endpoints.rs b/modules/ui/src/endpoints.rs index 8ef90ace1..78712859b 100644 --- a/modules/ui/src/endpoints.rs +++ b/modules/ui/src/endpoints.rs @@ -3,6 +3,7 @@ use crate::{ model::ExtractResult, service::{extract_cyclonedx_purls, extract_spdx_purls}, }; +use actix_multipart::form::{MultipartForm, json::Json as MPJson}; use actix_web::{ HttpResponse, Responder, http::header, @@ -10,11 +11,13 @@ use actix_web::{ web::{self, Bytes, ServiceConfig}, }; use actix_web_static_files::{ResourceFiles, deps::static_files::Resource}; -use std::collections::HashMap; +use flate2::{Compression, write::GzEncoder}; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tar::{Builder, Header}; use trustify_common::{decompress::decompress_async, error::ErrorInformation, model::BinaryData}; use trustify_module_ingestor::service::Format; use trustify_ui::{UI, trustify_ui}; -use utoipa::IntoParams; +use utoipa::{IntoParams, ToSchema}; #[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct Config { @@ -22,13 +25,16 @@ pub struct Config { pub scan_limit: usize, } -pub fn post_configure(svc: &mut ServiceConfig, ui: &UiResources) { - svc.service(ResourceFiles::new("/", ui.resources()).resolve_not_found_to("")); +pub fn post_configure(svc: &mut ServiceConfig, ui: Arc) { + let resources = ui.resources(); + svc.app_data(web::Data::new(ui)) + .service(ResourceFiles::new("/", resources).resolve_not_found_to("")); } pub fn configure(svc: &mut utoipa_actix_web::service_config::ServiceConfig, config: Config) { svc.app_data(web::Data::new(config)) - .service(extract_sbom_purls); + .service(extract_sbom_purls) + .service(generate_sbom_static_report); } pub struct UiResources { @@ -136,3 +142,86 @@ async fn extract_sbom_purls( warnings, })) } + +#[derive(Debug, MultipartForm, ToSchema)] +struct UploadForm { + #[schema(value_type = Object)] + analysis_response: MPJson, +} + +#[utoipa::path( + tag = "ui", + operation_id = "generateSbomStaticReport", + request_body(content = UploadForm, content_type = "multipart/form-data"), + responses( + ( + status = 200, + description = "Static report", + body = Vec, + content_type = "application/gzip" + ), + ( + status = 400, + description = "Bad request data, like an unsupported format or invalid data", + body = ErrorInformation, + ) + ) +)] +#[post("/v2/ui/generate-sbom-static-report")] +/// Generates an static report +async fn generate_sbom_static_report( + ui: web::Data>, + MultipartForm(form): MultipartForm, +) -> Result { + let mut data = Vec::new(); + { + let encoder = GzEncoder::new(&mut data, Compression::default()); + let mut gzip = Builder::new(encoder); + + // Add static report template + let prefix = "static-report/"; + for (path, resource) in ui.resources.iter() { + if let Some(relative_path) = path.strip_prefix(prefix) { + let mut header = Header::new_gnu(); + header.set_size(resource.data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + header.set_mtime( + std::time::UNIX_EPOCH + .elapsed() + .unwrap_or(Duration::from_secs(0)) + .as_secs(), + ); + + gzip.append_data(&mut header, relative_path, resource.data)?; + } + } + + // Add static report data + let json_data = serde_json::to_string(&form.analysis_response.0)?; + let js_data = format!("window.analysis_response={json_data}"); + + let mut header = Header::new_gnu(); + header.set_size(js_data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + header.set_mtime( + std::time::UNIX_EPOCH + .elapsed() + .unwrap_or(Duration::from_secs(0)) + .as_secs(), + ); + gzip.append_data(&mut header, "data.js", js_data.as_bytes())?; + + // Close gzip + gzip.finish()?; + } + + Ok(HttpResponse::Ok() + .content_type("application/gzip") + .append_header(( + "Content-Disposition", + "attachment; filename=\"static-report.tar.gz\"", + )) + .body(data)) +} diff --git a/modules/ui/src/error.rs b/modules/ui/src/error.rs index d5dcad20a..c6fa90c62 100644 --- a/modules/ui/src/error.rs +++ b/modules/ui/src/error.rs @@ -15,6 +15,8 @@ pub enum Error { BadRequest(String, Option), #[error(transparent)] Decompression(#[from] trustify_common::decompress::Error), + #[error(transparent)] + Io(#[from] std::io::Error), } impl actix_web::error::ResponseError for Error { diff --git a/modules/ui/tests/extract.rs b/modules/ui/tests/extract.rs index 0e834dcb6..3fd657f4f 100644 --- a/modules/ui/tests/extract.rs +++ b/modules/ui/tests/extract.rs @@ -1,5 +1,7 @@ #![allow(clippy::expect_used)] +use std::sync::Arc; + use actix_web::{dev::ServiceResponse, test::TestRequest}; use serde_json::{Value, json}; use test_log::test; @@ -17,7 +19,7 @@ async fn caller_with(config: Config) -> anyhow::Result { call::caller(move |svc| { configure(svc, config); svc.map(|svc| { - post_configure(svc, &ui); + post_configure(svc, Arc::new(ui)); svc }); }) diff --git a/openapi.yaml b/openapi.yaml index 32472814f..0ec9f4ed2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2848,6 +2848,35 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorInformation' + /api/v2/ui/generate-sbom-static-report: + post: + tags: + - ui + summary: Generates an static report + operationId: generateSbomStaticReport + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/UploadForm' + required: true + responses: + '200': + description: Static report + content: + application/gzip: + schema: + type: array + items: + type: integer + format: int32 + minimum: 0 + '400': + description: Bad request data, like an unsupported format or invalid data + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorInformation' /api/v2/userPreference/{key}: get: tags: @@ -5138,6 +5167,13 @@ components: oneOf: - type: 'null' - type: string + UploadForm: + type: object + required: + - analysis_response + properties: + analysis_response: + type: object VersionedPurlHead: type: object required: diff --git a/server/src/profile/api.rs b/server/src/profile/api.rs index 3a68f4e36..800863f31 100644 --- a/server/src/profile/api.rs +++ b/server/src/profile/api.rs @@ -482,7 +482,7 @@ fn post_configure(svc: &mut web::ServiceConfig, config: PostConfig) { svc.configure(|svc| { // I think the UI must come last due to // its use of `resolve_not_found_to` - trustify_module_ui::endpoints::post_configure(svc, &ui); + trustify_module_ui::endpoints::post_configure(svc, ui); }); }