From 4379f97bbe3c1a924d69921b918f7afed42fd244 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 29 Apr 2025 23:27:41 -0600 Subject: [PATCH] Frontend build improvements, support CSP, add icons --- Cargo.lock | 54 ++- Cargo.toml | 1 + bacon.toml | 8 + crates/github/Cargo.toml | 2 +- crates/web/Cargo.toml | 4 + crates/web/src/handlers/auth.rs | 56 +++- crates/web/src/handlers/common.rs | 500 +++++++++++++++++++--------- crates/web/src/handlers/csp.rs | 103 ++++++ crates/web/src/handlers/manage.rs | 75 +++-- crates/web/src/handlers/mod.rs | 31 +- crates/web/src/handlers/project.rs | 28 +- crates/web/src/handlers/report.rs | 128 +++---- crates/web/src/main.rs | 59 +++- css/main.scss | 77 +++-- js/entry.ts | 26 ++ js/env.d.ts | 2 + js/history.ts | 42 +-- public/apple-touch-icon.png | Bin 0 -> 7037 bytes public/favicon-96x96.png | Bin 0 -> 4058 bytes public/favicon.ico | Bin 0 -> 15086 bytes public/favicon.svg | 4 + public/site.webmanifest | 21 ++ public/web-app-manifest-192x192.png | Bin 0 -> 6796 bytes public/web-app-manifest-512x512.png | Bin 0 -> 20289 bytes rsbuild.config.ts | 32 +- 25 files changed, 911 insertions(+), 342 deletions(-) create mode 100644 bacon.toml create mode 100644 crates/web/src/handlers/csp.rs create mode 100644 js/entry.ts create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-96x96.png create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 public/site.webmanifest create mode 100644 public/web-app-manifest-192x192.png create mode 100644 public/web-app-manifest-512x512.png diff --git a/Cargo.lock b/Cargo.lock index a887435..a8b6372 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 7.1.3", "num-rational", "v_frame", ] @@ -795,10 +795,12 @@ dependencies = [ "decomp-dev-images", "image", "itertools 0.14.0", + "libsystemd", "maud", "mime", "objdiff-core", "prost", + "regex", "reqwest", "serde", "serde_json", @@ -1887,6 +1889,24 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libsystemd" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85fe9dc49de659d05829fdf72b5770c0a5952d1055c34a39f6d4e932bce175d" +dependencies = [ + "hmac", + "libc", + "log", + "nix", + "nom 8.0.0", + "once_cell", + "serde", + "sha2", + "thiserror 2.0.12", + "uuid", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -2012,6 +2032,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2089,6 +2118,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -2099,6 +2141,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -4433,6 +4484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 200fd98..9a19a2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ mime = "0.3" objdiff-core = { version = "2.5", features = ["bindings"] } #objdiff-core = { path = "../objdiff/objdiff-core", features = ["bindings"] } octocrab = { version = "0.44", features = ["stream"] } +regex = "1.11" reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "zstd", "http2", "rustls-tls-native-roots"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/bacon.toml b/bacon.toml new file mode 100644 index 0000000..407a080 --- /dev/null +++ b/bacon.toml @@ -0,0 +1,8 @@ +# This is a configuration file for the bacon tool +# +# Complete help on configuration: https://dystroy.org/bacon/config/ +# +# You may check the current default at +# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml + +default_job = "run-long" diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 4985dfe..9692c3f 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -17,7 +17,7 @@ http = "1.3" jsonwebtoken = "9.3" objdiff-core.workspace = true octocrab.workspace = true -regex = "1.11" +regex.workspace = true serde.workspace = true serde_json.workspace = true sha2 = "0.10" diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index e7a510d..3d3774d 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -16,6 +16,7 @@ itertools = "0.14" maud.workspace = true mime.workspace = true objdiff-core.workspace = true +regex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true @@ -39,3 +40,6 @@ image.workspace = true # proto bytes = "1.7" prost = "0.13" + +[target.'cfg(unix)'.dependencies] +libsystemd = "0.7" diff --git a/crates/web/src/handlers/auth.rs b/crates/web/src/handlers/auth.rs index af2ea04..4291a13 100644 --- a/crates/web/src/handlers/auth.rs +++ b/crates/web/src/handlers/auth.rs @@ -1,14 +1,14 @@ use axum::{ extract::{Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode, header::REFERER}, response::{IntoResponse, Redirect, Response}, }; use decomp_dev_auth::{CurrentUser, GITHUB_OAUTH_STATE, RETURN_TO, generate_nonce}; -use decomp_dev_core::{AppError, config::GitHubConfig}; +use decomp_dev_core::{AppError, config::GitHubConfig, util::UrlExt}; use maud::{DOCTYPE, html}; use tower_sessions::Session; -use crate::handlers::common::{chunks, header}; +use crate::handlers::common::{Load, TemplateContext}; #[derive(serde::Deserialize)] pub struct LoginQuery { @@ -17,9 +17,11 @@ pub struct LoginQuery { pub async fn login( session: Session, - Query(LoginQuery { return_to }): Query, + headers: HeaderMap, + Query(query): Query, State(config): State, current_user: Option, + mut ctx: TemplateContext, ) -> Result { if current_user.is_some() { return Ok(Redirect::to("/").into_response()); @@ -28,29 +30,27 @@ pub async fn login( tracing::warn!("No GitHub OAuth config found"); return Ok((StatusCode::INTERNAL_SERVER_ERROR, "No GitHub OAuth config").into_response()); }; - let nonce = generate_nonce(); - session.insert(GITHUB_OAUTH_STATE, nonce.clone()).await?; - if let Some(return_to) = return_to { - if return_to.starts_with('/') { - session.insert(RETURN_TO, return_to).await?; - } + let oauth_state = generate_nonce(); + session.insert(GITHUB_OAUTH_STATE, oauth_state.clone()).await?; + if let Some(return_to) = calc_return_to(&headers, query) { + session.insert(RETURN_TO, return_to).await?; } let mut redirect_url = url::Url::parse("https://github.com/login/oauth/authorize")?; { let mut query = redirect_url.query_pairs_mut(); query.append_pair("client_id", &config.client_id); query.append_pair("redirect_uri", &config.redirect_uri); - query.append_pair("state", &nonce); + query.append_pair("state", &oauth_state); } - Ok(html! { + let rendered = html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; title { "Logging in... • decomp.dev" } meta http-equiv="refresh" content=(format!("0;URL={redirect_url}")); - (header()) - (chunks("main", true).await) + (ctx.header().await) + (ctx.chunks("main", Load::Deferred).await) } body { .loading-container { @@ -58,11 +58,33 @@ pub async fn login( } } } - } - .into_response()) + }; + Ok((ctx, rendered).into_response()) } -pub async fn logout(session: Session) -> Result { +pub async fn logout( + session: Session, + headers: HeaderMap, + Query(query): Query, +) -> Result { session.flush().await?; + if let Some(return_to) = calc_return_to(&headers, query) { + return Ok(Redirect::to(&return_to).into_response()); + } Ok(Redirect::to("/").into_response()) } + +fn calc_return_to(headers: &HeaderMap, LoginQuery { return_to }: LoginQuery) -> Option { + let mut return_to = return_to.or_else(|| { + if headers.get("sec-fetch-site").and_then(|h| h.to_str().ok()) == Some("same-origin") { + headers + .get(REFERER) + .and_then(|h| h.to_str().ok()) + .and_then(|s| url::Url::parse(s).ok()) + .map(|u| u.path_and_query().to_string()) + } else { + None + } + }); + return_to.take_if(|s| s.starts_with('/')) +} diff --git a/crates/web/src/handlers/common.rs b/crates/web/src/handlers/common.rs index 1f5a855..65f98ca 100644 --- a/crates/web/src/handlers/common.rs +++ b/crates/web/src/handlers/common.rs @@ -1,16 +1,26 @@ use std::{ - collections::HashMap, + borrow::Cow, + collections::{BTreeMap, HashMap, HashSet, hash_map::Entry}, + convert::Infallible, sync::LazyLock, time::{Duration, Instant}, }; use anyhow::{Result, anyhow}; -use axum::http::StatusCode; +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts}, + response::{IntoResponseParts, ResponseParts}, +}; use decomp_dev_auth::CurrentUser; use decomp_dev_core::AppError; -use maud::{Markup, PreEscaped, html}; +use maud::{Markup, PreEscaped, Render, html}; use objdiff_core::bindings::report::Measures; +use regex::Regex; use time::{UtcDateTime, macros::format_description}; +use url::Url; + +use crate::handlers::csp::{ExtraDomains, Nonce}; pub fn timeago(value: UtcDateTime) -> String { let Ok(duration) = Duration::try_from(UtcDateTime::now() - value) else { @@ -25,19 +35,10 @@ pub fn date(value: UtcDateTime) -> String { )).unwrap_or_else(|_| "[invalid]".to_string()) } -pub fn header() -> Markup { - html! { - meta name="viewport" content="width=device-width, initial-scale=1.0"; - meta name="color-scheme" content="dark light"; - meta name="darkreader-lock"; - script { (PreEscaped(r#"let t;try{t=localStorage.getItem("theme")}catch(_){}if(t)document.documentElement.setAttribute("data-theme",t);"#)) } - } -} - #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct WebpackManifest { - // pub all_files: Vec, + pub all_files: Vec, pub entries: HashMap, } @@ -47,7 +48,7 @@ pub struct WebpackManifestEntry { pub initial: WebpackManifestEntryPaths, // pub r#async: WebpackManifestEntryPaths, // pub html: Vec, - // pub assets: Vec, + pub assets: Vec, } #[derive(Debug, Clone, Default, serde::Deserialize)] @@ -57,70 +58,334 @@ pub struct WebpackManifestEntryPaths { pub css: Vec, } -pub async fn manifest_paths(entry: &str) -> Result { - let manifest_str = tokio::fs::read_to_string("dist/manifest.json").await?; - let manifest: WebpackManifest = serde_json::from_str(&manifest_str)?; - let entry = manifest - .entries - .get(entry) - .ok_or_else(|| anyhow!("Entry {} not found in manifest", entry))?; - Ok(entry.initial.clone()) +pub struct TemplateContext { + pub start: Instant, + pub manifest: Option, + pub added_resources: HashMap, + pub nonce: Option, } -pub async fn chunks(entry: &str, defer: bool) -> Markup { - let paths = manifest_paths(entry).await.unwrap_or_else(|e| { - tracing::error!("Failed to load chunks for {entry}: {e}"); - Default::default() - }); - let mut out = String::new(); - for path in paths.css { - out.push_str(&html! { link rel="stylesheet" href=(path); }.0); +impl FromRequestParts for TemplateContext +where S: Send + Sync +{ + type Rejection = Infallible; + + async fn from_request_parts(req: &mut Parts, _state: &S) -> Result { + let start = Instant::now(); + let nonce = + match as FromRequestParts>::from_request_parts(req, _state).await { + Ok(Some(nonce)) => Some(nonce.0), + Ok(None) => None, + Err(never) => match never {}, + }; + Ok(Self { start, manifest: None, added_resources: Default::default(), nonce }) } - if defer { - for path in paths.js { - out.push_str(&html! { script src=(path) defer {} }.0); - } - } else { - for path in paths.js { - out.push_str(&html! { script src=(path) {} }.0); - } - } - PreEscaped(out) } -pub fn footer(start: Instant, current_user: Option<&CurrentUser>) -> Markup { - let elapsed = start.elapsed(); - html! { - footer { - span class="section" { - small class="muted" { "Generated in " (elapsed.as_millis()) "ms" } - " | " - small class="muted" { - a href="https://github.com/encounter/decomp.dev" { "Code" } - " by " - a href="https://github.com/encounter" { "@encounter" } +impl IntoResponseParts for TemplateContext { + type Error = Infallible; + + fn into_response_parts( + self, + mut res: ResponseParts, + ) -> std::result::Result { + let mut origins = HashSet::new(); + if let Some(manifest) = &self.manifest { + for entry in &manifest.all_files { + if let Ok(url) = Url::parse(entry) { + origins.insert(url[..url::Position::BeforePath].to_string()); } } - @if let Some(user) = current_user { - span class="section" { - small class="muted" { - "Logged in as " - a href=(user.data.url) { "@" (user.data.login) } + } + if !origins.is_empty() { + res.extensions_mut().insert(ExtraDomains(origins.into_iter().collect())); + } + Ok(res) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Load { + Blocking, + Deferred, + Preload, +} + +impl TemplateContext { + pub async fn header(&mut self) -> Markup { + html! { + meta name="viewport" content="width=device-width, initial-scale=1.0"; + meta name="color-scheme" content="dark light"; + meta name="darkreader-lock"; + meta name="apple-mobile-web-app-title" content="decomp.dev"; + meta name="theme-color" content="#181c25" media="(prefers-color-scheme: dark)"; + meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"; + (self.chunks("entry", Load::Blocking).await) + link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96"; + link rel="icon" type="image/svg+xml" href="/favicon.svg"; + link rel="shortcut icon" href="/favicon.ico"; + link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180"; + link rel="manifest" href="/site.webmanifest"; + } + } + + async fn manifest_paths(&mut self, entry: &str) -> Result { + let manifest = match self.manifest.as_ref() { + Some(manifest) => manifest, + None => { + let manifest_str = tokio::fs::read_to_string("dist/manifest.json").await?; + let manifest: WebpackManifest = serde_json::from_str(&manifest_str)?; + self.manifest.insert(manifest) + } + }; + let entry = manifest + .entries + .get(entry) + .ok_or_else(|| anyhow!("Entry {} not found in manifest", entry))?; + // Asset paths are relative, resolve the full paths + let assets = entry + .assets + .iter() + .filter_map(|p| manifest.all_files.iter().find(|a| a.ends_with(p)).cloned()) + .collect(); + Ok(WebpackManifestEntry { initial: entry.initial.clone(), assets }) + } + + pub async fn chunks(&mut self, name: &str, load: Load) -> Markup { + let entry = self.manifest_paths(name).await.unwrap_or_else(|e| { + tracing::error!("Failed to load chunks for {name}: {e}"); + Default::default() + }); + let mut out = String::new(); + #[derive(Debug, Copy, Clone, PartialEq, Eq)] + enum ResourceKind { + Script, + Style, + Font, + } + let mut push = |kind: ResourceKind, path: &str, load: Load| { + match load { + Load::Preload => { + if self.added_resources.contains_key(path) { + return; } + } + Load::Deferred => match self.added_resources.entry(path.to_string()) { + Entry::Occupied(mut e) => match *e.get() { + Load::Blocking | Load::Deferred => return, + Load::Preload => { + e.insert(load); + } + }, + Entry::Vacant(e) => { + e.insert(load); + } + }, + Load::Blocking => match self.added_resources.entry(path.to_string()) { + Entry::Occupied(mut e) => match *e.get() { + Load::Blocking => return, + Load::Deferred => panic!("Resource {path} is already loaded as Deferred"), + Load::Preload => { + e.insert(load); + } + }, + Entry::Vacant(e) => { + e.insert(load); + } + }, + } + // Fonts must be preloaded with crossorigin + let crossorigin = path.starts_with("http://") + || path.starts_with("https://") + || kind == ResourceKind::Font; + let nonce = match kind { + ResourceKind::Script | ResourceKind::Style => self.nonce.as_deref(), + _ => None, + }; + let rendered = match load { + Load::Preload => html! { + link rel="preload" href=(path) as=(match kind { + ResourceKind::Script => "script", + ResourceKind::Style => "style", + ResourceKind::Font => "font", + }) crossorigin[crossorigin] nonce=[nonce]; + }, + Load::Blocking | Load::Deferred => match kind { + ResourceKind::Script => html! { + script src=(path) defer[load == Load::Deferred] crossorigin[crossorigin] nonce=[nonce] {}; + }, + ResourceKind::Style => html! { + link rel="stylesheet" href=(path) crossorigin[crossorigin] nonce=[nonce]; + }, + ResourceKind::Font => Markup::default(), + }, + }; + out.push_str(&rendered.0); + }; + for path in &entry.initial.js { + push(ResourceKind::Script, path, load); + } + for path in &entry.initial.css { + push(ResourceKind::Style, path, load); + } + for path in &entry.assets { + if path.ends_with(".woff2") { + push(ResourceKind::Font, path, Load::Preload); + } + } + PreEscaped(out) + } + + pub fn footer(&self, current_user: Option<&CurrentUser>) -> Markup { + let elapsed = self.start.elapsed(); + html! { + footer { + span class="section" { + small class="muted" { "Generated in " (elapsed.as_millis()) "ms" } " | " - form action="/logout" method="post" style="display: inline" { - input type="submit" class="button outline secondary" value="Logout"; + small class="muted" { + a href="https://github.com/encounter/decomp.dev" { "Code" } + " by " + a href="https://github.com/encounter" { "@encounter" } } } - } @else { - span class="section" { - small class="muted" { - a href="/login" { "Login" } + @if let Some(user) = current_user { + span class="section" { + small class="muted" { + "Logged in as " + a href=(user.data.url) { "@" (user.data.login) } + } + " | " + form action="/logout" method="post" { + input type="submit" class="button outline secondary" value="Logout"; + } + } + } @else { + span class="section" { + small class="muted" { + a href="/login" { "Login" } + } } } } } } + + pub fn code_progress_sections(&self, measures: &Measures) -> ProgressSections { + let mut out = ProgressSections::new("code", self.nonce.clone()); + if measures.total_code == 0 { + return out; + } + let mut current_percent = 0.0; + if measures.complete_code_percent > 0.0 { + out.push( + "progress-section", + measures.complete_code_percent - current_percent, + format!( + "{:.2}% fully linked ({})", + measures.complete_code_percent, + size(measures.complete_code) + ), + ); + current_percent = measures.complete_code_percent; + } + if measures.matched_code_percent > 0.0 && measures.matched_code_percent > current_percent { + out.push( + if current_percent > 0.0 { "progress-section striped" } else { "progress-section" }, + measures.matched_code_percent - current_percent, + format!( + "{:.2}% perfect match ({})", + measures.matched_code_percent, + size(measures.matched_code) + ), + ); + current_percent = measures.matched_code_percent; + } + if measures.fuzzy_match_percent > 0.0 && measures.fuzzy_match_percent > current_percent { + out.push( + "progress-section striped fuzzy", + measures.fuzzy_match_percent - current_percent, + format!("{:.2}% fuzzy match", measures.fuzzy_match_percent), + ); + } + out + } + + pub fn data_progress_sections(&self, measures: &Measures) -> ProgressSections { + let mut out = ProgressSections::new("data", self.nonce.clone()); + if measures.total_data == 0 { + return out; + } + let mut current_percent = 0.0; + if measures.complete_data_percent > 0.0 { + out.push( + "progress-section", + measures.complete_data_percent - current_percent, + format!( + "{:.2}% fully linked ({})", + measures.complete_data_percent, + size(measures.complete_data) + ), + ); + current_percent = measures.complete_data_percent; + } + if measures.matched_data_percent > 0.0 && measures.matched_data_percent > current_percent { + out.push( + if current_percent > 0.0 { "progress-section striped" } else { "progress-section" }, + measures.matched_data_percent - current_percent, + format!( + "{:.2}% perfect match ({})", + measures.matched_data_percent, + size(measures.matched_data) + ), + ); + } + out + } +} + +#[derive(Default)] +pub struct ProgressSections { + pub width_classes: BTreeMap, + pub rendered: Markup, + pub kind: &'static str, + pub nonce: Option, +} + +impl ProgressSections { + pub fn new(kind: &'static str, nonce: Option) -> Self { + Self { width_classes: BTreeMap::new(), rendered: Markup::default(), kind, nonce } + } + + pub fn push(&mut self, class: &str, percent: f32, tooltip: String) { + let width_class = format!("width-{:.2}", percent).replace('.', "p"); + let rendered = html! { .(class).(width_class) data-tooltip=(tooltip) {} }; + self.width_classes.insert(width_class, format!("width:{}%", percent)); + self.rendered.0.push_str(&rendered.0); + } +} + +impl Render for ProgressSections { + fn render(&self) -> Markup { + let mut out = Markup::default(); + if !self.width_classes.is_empty() { + let rendered = html! { + style nonce=[self.nonce.as_deref()] { + @for (class, width) in &self.width_classes { + "." (class) "{" (width) "}" + } + } + }; + out.0.push_str(&rendered.0); + } + if self.rendered.0.is_empty() { + return out; + } + html! { + (out) + .progress-root.(self.kind) { (self.rendered) } + } + } } pub fn nav_links() -> Markup { @@ -152,100 +417,6 @@ pub fn size(value: u64) -> String { format!("{:.2} {}", value, units[unit]) } -pub fn code_progress_sections(measures: &Measures) -> Markup { - if measures.total_code == 0 { - return Markup::default(); - } - let mut out = Markup::default(); - let mut add_section = |class: &str, percent: f32, tooltip: String| { - let rendered = html! { - div class=(class) style=(format!("width: {}%", percent)) - data-tooltip=(tooltip) {} - }; - out.0.push_str(&rendered.0); - }; - let mut current_percent = 0.0; - if measures.complete_code_percent > 0.0 { - add_section( - "progress-section", - measures.complete_code_percent - current_percent, - format!( - "{:.2}% fully linked ({})", - measures.complete_code_percent, - size(measures.complete_code) - ), - ); - current_percent = measures.complete_code_percent; - } - if measures.matched_code_percent > 0.0 && measures.matched_code_percent > current_percent { - add_section( - if current_percent > 0.0 { "progress-section striped" } else { "progress-section" }, - measures.matched_code_percent - current_percent, - format!( - "{:.2}% perfect match ({})", - measures.matched_code_percent, - size(measures.matched_code) - ), - ); - current_percent = measures.matched_code_percent; - } - if measures.fuzzy_match_percent > 0.0 && measures.fuzzy_match_percent > current_percent { - add_section( - "progress-section striped fuzzy", - measures.fuzzy_match_percent - current_percent, - format!("{:.2}% fuzzy match", measures.fuzzy_match_percent), - ); - } - html! { - div class="progress-root code" { - (out) - } - } -} - -pub fn data_progress_sections(measures: &Measures) -> Markup { - if measures.total_data == 0 { - return Markup::default(); - } - let mut out = Markup::default(); - let mut add_section = |class: &str, percent: f32, tooltip: String| { - let rendered = html! { - div class=(class) style=(format!("width: {}%", percent)) - data-tooltip=(tooltip) {} - }; - out.0.push_str(&rendered.0); - }; - let mut current_percent = 0.0; - if measures.complete_data_percent > 0.0 { - add_section( - "progress-section", - measures.complete_data_percent - current_percent, - format!( - "{:.2}% fully linked ({})", - measures.complete_data_percent, - size(measures.complete_data) - ), - ); - current_percent = measures.complete_data_percent; - } - if measures.matched_data_percent > 0.0 && measures.matched_data_percent > current_percent { - add_section( - if current_percent > 0.0 { "progress-section striped" } else { "progress-section" }, - measures.matched_data_percent - current_percent, - format!( - "{:.2}% perfect match ({})", - measures.matched_data_percent, - size(measures.matched_data) - ), - ); - } - html! { - div class="progress-root data" { - (out) - } - } -} - pub async fn get_robots() -> Result { static ROBOTS_CACHE: LazyLock>> = LazyLock::new(|| std::sync::RwLock::new(None)); @@ -272,3 +443,20 @@ pub async fn get_robots() -> Result { } Ok(text) } + +pub fn escape_script(text: &str) -> PreEscaped> { + static REGEX: LazyLock = LazyLock::new(|| Regex::new("<(?:!--|/?script)").unwrap()); + PreEscaped(REGEX.replace_all(text, |caps: ®ex::Captures| format!("\\x3C{}", &caps[0][1..]))) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_escape_script() { + let input = r#""#; + let expected = "\\x3C!--\\x3Cscript>alert('test');\\x3C/script>-->"; + assert_eq!(escape_script(input).0.as_ref(), expected); + } +} diff --git a/crates/web/src/handlers/csp.rs b/crates/web/src/handlers/csp.rs new file mode 100644 index 0000000..ec16ab3 --- /dev/null +++ b/crates/web/src/handlers/csp.rs @@ -0,0 +1,103 @@ +use std::convert::Infallible; + +use axum::{ + extract::{FromRequestParts, OptionalFromRequestParts, Request}, + http::{Extensions, HeaderValue, StatusCode, header::CONTENT_TYPE, request::Parts}, + middleware::Next, + response::Response, +}; +use decomp_dev_auth::generate_nonce; + +#[derive(Debug, Clone)] +pub struct Nonce(pub String); + +impl Nonce { + fn from_extensions(extensions: &Extensions) -> Option { + extensions.get::().cloned() + } +} + +impl FromRequestParts for Nonce +where S: Send + Sync +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(req: &mut Parts, _state: &S) -> Result { + Self::from_extensions(&req.extensions) + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Nonce not found")) + } +} + +impl OptionalFromRequestParts for Nonce +where S: Send + Sync +{ + type Rejection = Infallible; + + async fn from_request_parts( + req: &mut Parts, + _state: &S, + ) -> Result, Self::Rejection> { + Ok(Self::from_extensions(&req.extensions)) + } +} + +#[derive(Debug, Clone, Default)] +pub struct ExtraDomains(pub Vec); + +pub async fn csp_middleware(mut req: Request, next: Next) -> Response { + let nonce = generate_nonce(); + req.extensions_mut().insert(Nonce(nonce.clone())); + let mut response = next.run(req).await; + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if content_type.starts_with("text/html") { + let extra_domains = + response.extensions().get::().map(|e| e.0.clone()).unwrap_or_default(); + let response_headers = response.headers_mut(); + let mut header = "default-src 'none';base-uri 'none';script-src ".to_string(); + let nonce_value = format!("'nonce-{}'", nonce); + header.push_str(&nonce_value); + #[cfg(debug_assertions)] + { + // tower-livereload script + header.push_str(" 'sha256-L/4du8mXhXqvOm9Re02dTBSI4mWBbsqtG8F+xh3jiJc='"); + } + header.push_str(";style-src "); + header.push_str(&nonce_value); + header.push_str(";img-src 'self' data:"); + for domain in &extra_domains { + header.push(' '); + header.push_str(domain); + } + header.push_str(";font-src 'self'"); + for domain in &extra_domains { + header.push(' '); + header.push_str(domain); + } + header.push_str(";connect-src 'self'"); + for domain in &extra_domains { + header.push(' '); + header.push_str(domain); + if let Some(domain) = domain.strip_prefix("https://") { + header.push_str(&format!(" wss://{domain}")); + } else if let Some(domain) = domain.strip_prefix("http://") { + header.push_str(&format!(" ws://{domain}")); + } + } + header.push_str(";manifest-src 'self'"); + response_headers.insert("Content-Security-Policy", header.parse().unwrap()); + response_headers + .insert("Cross-Origin-Embedder-Policy", HeaderValue::from_static("require-corp")); + response_headers + .insert("Cross-Origin-Opener-Policy", HeaderValue::from_static("same-origin")); + response_headers + .insert("Referrer-Policy", HeaderValue::from_static("strict-origin-when-cross-origin")); + response_headers.insert("X-Frame-Options", HeaderValue::from_static("DENY")); + } + let response_headers = response.headers_mut(); + response_headers.insert("X-Content-Type-Options", HeaderValue::from_static("nosniff")); + response +} diff --git a/crates/web/src/handlers/manage.rs b/crates/web/src/handlers/manage.rs index 499c717..30ddb98 100644 --- a/crates/web/src/handlers/manage.rs +++ b/crates/web/src/handlers/manage.rs @@ -1,5 +1,3 @@ -use std::time::Instant; - use anyhow::Result; use axum::{ Form, @@ -19,15 +17,14 @@ use serde::Deserialize; use crate::{ AppState, - handlers::common::{chunks, footer, header, nav_links}, + handlers::common::{Load, TemplateContext, nav_links}, }; pub async fn manage( + mut ctx: TemplateContext, State(state): State, current_user: CurrentUser, -) -> Result { - let start = Instant::now(); - +) -> Result { let projects = state .db .get_projects() @@ -37,15 +34,15 @@ pub async fn manage( .sorted_by(|a, b| a.project.name().cmp(&b.project.name())) .collect::>(); - Ok(html! { + let rendered = html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; title { "Manage • decomp.dev" } - (header()) - (chunks("main", true).await) - (chunks("manage", true).await) + (ctx.header().await) + (ctx.chunks("main", Load::Deferred).await) + (ctx.chunks("manage", Load::Deferred).await) } body { header { @@ -72,9 +69,10 @@ pub async fn manage( } } } - (footer(start, Some(¤t_user))) + (ctx.footer(Some(¤t_user))) } - }) + }; + Ok((ctx, rendered).into_response()) } fn project_fragment(info: &ProjectInfo) -> Markup { @@ -94,20 +92,20 @@ fn project_fragment(info: &ProjectInfo) -> Markup { } pub async fn new( + ctx: TemplateContext, State(state): State, current_user: CurrentUser, ) -> Result { - render_new(&state, ¤t_user, None, None).await + render_new(ctx, &state, ¤t_user, None, None).await } async fn render_new( + mut ctx: TemplateContext, state: &AppState, current_user: &CurrentUser, message: Option<&str>, prefill: Option<&Project>, ) -> Result { - let start = Instant::now(); - let projects = state.db.get_projects().await?; let repos = current_user @@ -136,15 +134,15 @@ async fn render_new( } }; - Ok(html! { + let rendered = html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; title { "New Project • decomp.dev" } - (header()) - (chunks("main", true).await) - (chunks("manage", true).await) + (ctx.header().await) + (ctx.chunks("main", Load::Deferred).await) + (ctx.chunks("manage", Load::Deferred).await) } body { header { @@ -198,9 +196,10 @@ async fn render_new( } } } - (footer(start, Some(current_user))) + (ctx.footer(Some(current_user))) } - }.into_response()) + }; + Ok((ctx, rendered).into_response()) } fn platform_options(current_platform: Option<&str>) -> Markup { @@ -228,6 +227,7 @@ pub struct NewForm { } pub async fn new_save( + ctx: TemplateContext, State(state): State, current_user: CurrentUser, Form(form): Form, @@ -245,6 +245,7 @@ pub async fn new_save( Err(e) => { tracing::error!("Failed to fetch repository: {:?}", e); return render_new( + ctx, &state, ¤t_user, Some("Failed to fetch repository information."), @@ -267,6 +268,7 @@ pub async fn new_save( }; if repo.permissions.as_ref().is_none_or(|p| !p.admin) { return render_new( + ctx, &state, ¤t_user, Some("You do not have admin permissions on this repository."), @@ -279,7 +281,7 @@ pub async fn new_save( Ok(workflow_id) => workflow_id, Err(e) => { let message = e.to_string(); - return render_new(&state, ¤t_user, Some(&message), Some(&project)).await; + return render_new(ctx, &state, ¤t_user, Some(&message), Some(&project)).await; } }; project.workflow_id = Some(workflow_id); @@ -292,8 +294,8 @@ pub async fn manage_project( Path(params): Path, State(state): State, current_user: CurrentUser, -) -> Result { - let start = Instant::now(); + ctx: TemplateContext, +) -> Result { let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? else { return Err(AppError::Status(StatusCode::NOT_FOUND)); @@ -302,7 +304,7 @@ pub async fn manage_project( return Err(AppError::Status(StatusCode::FORBIDDEN)); } - Ok(render_manage_project(start, &state, &project_info, ¤t_user, Message::None).await) + render_manage_project(ctx, &state, &project_info, ¤t_user, Message::None).await } enum Message { @@ -324,12 +326,12 @@ fn render_message(message: &Message) -> Markup { } async fn render_manage_project( - start: Instant, + mut ctx: TemplateContext, state: &AppState, project_info: &ProjectInfo, current_user: &CurrentUser, message: Message, -) -> Markup { +) -> Result { let project_short_name = project_info.project.short_name(); let project_manage_path = format!("/manage/{}/{}", project_info.project.owner, project_info.project.repo); @@ -349,15 +351,15 @@ async fn render_manage_project( None }; - html! { + let rendered = html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; title { (project_short_name) " • Manage" } - (header()) - (chunks("main", true).await) - (chunks("manage", true).await) + (ctx.header().await) + (ctx.chunks("main", Load::Deferred).await) + (ctx.chunks("manage", Load::Deferred).await) } body { header { @@ -458,9 +460,10 @@ async fn render_manage_project( } } } - (footer(start, Some(current_user))) + (ctx.footer(Some(current_user))) } - } + }; + Ok((ctx, rendered).into_response()) } fn form_bool<'de, D>(deserializer: D) -> Result @@ -535,11 +538,11 @@ pub async fn manage_project_save( } pub async fn manage_project_refresh( + ctx: TemplateContext, Path(params): Path, State(state): State, current_user: CurrentUser, -) -> Result { - let start = Instant::now(); +) -> Result { let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? else { return Err(AppError::Status(StatusCode::NOT_FOUND)); @@ -563,5 +566,5 @@ pub async fn manage_project_refresh( Message::Error(format!("Failed to refresh project: {}", e)) } }; - Ok(render_manage_project(start, &state, &project_info, ¤t_user, message).await) + render_manage_project(ctx, &state, &project_info, ¤t_user, message).await } diff --git a/crates/web/src/handlers/mod.rs b/crates/web/src/handlers/mod.rs index db04177..3e5c029 100644 --- a/crates/web/src/handlers/mod.rs +++ b/crates/web/src/handlers/mod.rs @@ -2,17 +2,20 @@ use std::str::FromStr; use axum::{ Router, - http::{HeaderMap, header}, + extract::Request, + http::{HeaderMap, HeaderValue, header}, routing::{get, post}, }; use decomp_dev_images::image_mime_from_ext; use mime::Mime; +use tower::ServiceExt; use tower_http::services::ServeDir; use crate::AppState; mod auth; mod common; +pub mod csp; mod manage; mod project; mod report; @@ -20,7 +23,31 @@ mod treemap; pub fn build_router() -> Router { Router::new() - .nest_service("/static", ServeDir::new("dist/static")) + .nest_service( + "/static", + >::map_response( + ServeDir::new("dist/static"), + |mut response| { + // Cache static (hashed) files for a year, mark immutable + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + response + }, + ), + ) + .fallback_service(>::map_response( + ServeDir::new("dist"), + |mut response| { + // Cache non-hashed public files for a day, mark must-revalidate + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=86400, must-revalidate"), + ); + response + }, + )) .route("/robots.txt", get(common::get_robots)) .route("/api/github/webhook", post(decomp_dev_github::webhook::webhook)) .route("/api/github/oauth", get(decomp_dev_auth::oauth)) diff --git a/crates/web/src/handlers/project.rs b/crates/web/src/handlers/project.rs index 3abda6c..2237417 100644 --- a/crates/web/src/handlers/project.rs +++ b/crates/web/src/handlers/project.rs @@ -1,4 +1,4 @@ -use std::{str::FromStr, sync::Arc, time::Instant}; +use std::{str::FromStr, sync::Arc}; use anyhow::{Context, anyhow}; use axum::{ @@ -20,16 +20,14 @@ use url::Url; use crate::{ AppState, - handlers::common::{ - chunks, code_progress_sections, date, footer, header, nav_links, size, timeago, - }, + handlers::common::{Load, ProgressSections, TemplateContext, date, nav_links, size, timeago}, }; -#[derive(Serialize)] struct ProjectInfoContext { project: Project, commit: Commit, measures: Measures, + code_progress: ProgressSections, } #[derive(Deserialize)] @@ -59,12 +57,12 @@ pub struct ProgressSection { } pub async fn get_projects( + mut ctx: TemplateContext, State(state): State, Query(query): Query, FullUri(uri): FullUri, current_user: Option, ) -> Result { - let start = Instant::now(); let projects = state.db.get_projects().await?; let mut out = projects .iter() @@ -74,6 +72,7 @@ pub async fn get_projects( project: p.project.clone(), commit: commit.clone(), measures: Default::default(), + code_progress: Default::default(), }) }) .collect::>(); @@ -108,6 +107,7 @@ pub async fn get_projects( Ok((info, Ok(Some(file)))) => { if let Some(c) = out.iter_mut().find(|i| i.project.id == info.project.id) { c.measures = *file.report.measures(info.project.default_category.as_deref()); + c.code_progress = ctx.code_progress_sections(&c.measures); } } Ok((info, Ok(None))) => { @@ -122,6 +122,11 @@ pub async fn get_projects( } } + let mut combined_styles = ProgressSections { nonce: ctx.nonce.clone(), ..Default::default() }; + for info in &mut out { + combined_styles.width_classes.append(&mut info.code_progress.width_classes); + } + let current_sort_key = query.sort.as_deref().unwrap_or("updated"); let current_sort = SORT_OPTIONS .iter() @@ -161,8 +166,8 @@ pub async fn get_projects( head { meta charset="utf-8"; title { "Projects • decomp.dev" } - (header()) - (chunks("main", true).await) + (ctx.header().await) + (ctx.chunks("main", Load::Deferred).await) meta name="description" content="Decompilation progress reports"; meta property="og:title" content="Decompilation progress reports"; meta property="og:description" content="Progress reports for matching decompilation projects"; @@ -221,15 +226,16 @@ pub async fn get_projects( } } } + (combined_styles) @for project in out { (project_fragment(project, current_sort, &canonical_url)) } } - (footer(start, current_user.as_ref())) + (ctx.footer(current_user.as_ref())) } } }; - Ok(rendered.into_response()) + Ok((ctx, rendered).into_response()) } fn project_fragment( @@ -270,7 +276,7 @@ fn project_fragment( } } } - (code_progress_sections(&info.measures)) + (info.code_progress) small class="muted" { span title=(date(info.commit.timestamp)) { "Updated " (timeago(info.commit.timestamp)) } " in commit " diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index ef03e22..7214295 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, iter, time::Instant}; +use std::{borrow::Cow, iter}; use anyhow::{Context, Result}; use axum::{ @@ -18,7 +18,7 @@ use decomp_dev_images::{ treemap::{layout_units, unit_color}, }; use image::ImageFormat; -use maud::{DOCTYPE, Markup, PreEscaped, html}; +use maud::{DOCTYPE, PreEscaped, html}; use mime::Mime; use objdiff_core::bindings::report::{Measures, ReportCategory, ReportUnit}; use serde::{Deserialize, Serialize}; @@ -28,9 +28,7 @@ use url::Url; use super::{parse_accept, treemap}; use crate::{ AppState, - handlers::common::{ - chunks, code_progress_sections, data_progress_sections, footer, header, nav_links, size, - }, + handlers::common::{Load, TemplateContext, escape_script, nav_links, size}, proto::{PROTOBUF, Protobuf}, }; @@ -190,8 +188,8 @@ pub async fn get_report( FullUri(uri): FullUri, State(state): State, current_user: Option, + ctx: TemplateContext, ) -> Result { - let start = Instant::now(); let (params, ext) = extract_extension(params); let acceptable = parse_accept(&headers, ext.as_deref()); if acceptable.is_empty() { @@ -230,11 +228,9 @@ pub async fn get_report( let scope = apply_scope(&report, &project_info, &query)?; match query.mode.as_deref().unwrap_or("report").to_ascii_lowercase().as_str() { "shield" => mode_shield(&scope, query, &acceptable), - "report" => mode_report(&scope, &state, uri, query, start, &acceptable, current_user).await, + "report" => mode_report(&scope, &state, uri, query, ctx, &acceptable, current_user).await, "measures" => mode_measures(&scope, &acceptable), - "history" => { - mode_history(&scope, &state, uri, query, start, &acceptable, current_user).await - } + "history" => mode_history(&scope, &state, uri, query, ctx, &acceptable, current_user).await, _ => Err(AppError::Status(StatusCode::BAD_REQUEST)), } } @@ -245,7 +241,7 @@ async fn mode_report( state: &AppState, uri: Uri, query: ReportQuery, - start: Instant, + ctx: TemplateContext, acceptable: &[Mime], current_user: Option, ) -> Result { @@ -253,8 +249,7 @@ async fn mode_report( if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR) || (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML) { - let rendered = render_report(scope, state, uri, current_user, start).await?; - return Ok(rendered.into_response()); + return render_report(scope, state, uri, current_user, ctx).await; } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { let flattened = scope.report.report.flatten(); return Ok(Json(flattened).into_response()); @@ -340,7 +335,7 @@ async fn mode_history( state: &AppState, uri: Uri, query: ReportQuery, - start: Instant, + ctx: TemplateContext, acceptable: &[Mime], current_user: Option, ) -> Result { @@ -385,7 +380,7 @@ async fn mode_history( if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR) || (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML) { - let rendered = render_history(scope, state, uri, current_user, start, result).await?; + let rendered = render_history(scope, state, uri, current_user, ctx, result).await?; return Ok(rendered.into_response()); } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { return Ok(Json(result).into_response()); @@ -536,8 +531,8 @@ async fn render_report( state: &AppState, uri: Uri, current_user: Option, - start: Instant, -) -> Result { + mut ctx: TemplateContext, +) -> Result { let Scope { report, project_info, measures, current_category, current_unit, units, label } = scope; @@ -656,22 +651,28 @@ async fn render_report( } else { project_info.project.name() }; - let project_short_name = if let Some(label) = label { - Cow::Owned(format!("{} ({})", project_info.project.short_name(), label)) + let project_short_name = project_info.project.short_name(); + let project_short_name_with_label = if let Some(label) = label { + Cow::Owned(format!("{} ({})", project_short_name, label)) } else { - Cow::Borrowed(project_info.project.short_name()) + Cow::Borrowed(project_short_name) }; - Ok(html! { + // Load blocking resources first so we don't duplicate them + let header = ctx.header().await; + let report_chunks = ctx.chunks("report", Load::Blocking).await; + + let rendered = html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; - title { (project_short_name) " • Progress Report" } - (header()) - (chunks("main", true).await) + title { (project_short_name_with_label) " • Progress Report" } + (header) + (ctx.chunks("main", Load::Deferred).await) + (ctx.chunks("report", Load::Preload).await) meta name="description" content=(format!("Decompilation progress report for {project_name}")); - meta property="og:title" content=(format!("{project_short_name} is {:.2}% decompiled", measures.matched_code_percent)); + meta property="og:title" content=(format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent)); meta property="og:description" content=(format!("Decompilation progress report for {project_name}")); meta property="og:image" content=(image_url); meta property="og:url" content=(canonical_url); @@ -727,7 +728,7 @@ async fn render_report( } } } - h3 { (format!("{project_short_name} is {:.2}% decompiled", measures.matched_code_percent)) } + h3 { (format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent)) } @if current_unit.is_none() && measures.complete_code_percent > 0.0 { h4 class="muted" { (format!("{:.2}% fully linked", measures.complete_code_percent)) } } @@ -751,14 +752,14 @@ async fn render_report( "Code " small class="muted" { "(" (size(measures.total_code)) ")" } } - (code_progress_sections(&measures)) + (ctx.code_progress_sections(&measures)) } @if measures.total_data > 0 { h6 class="report-header" { "Data " small class="muted" { "(" (size(measures.total_data)) ")" } } - (data_progress_sections(&measures)) + (ctx.data_progress_sections(&measures)) } h6 class="report-header" { "Commit" } div { @@ -774,24 +775,24 @@ async fn render_report( div role="group" { @if let Some(prev_commit_path) = prev_commit_path { a role="button" class="outline secondary" href=(prev_commit_path) { - span .icon-left-open {} + span .icon-left-open .md {} " Previous" } } @else { button disabled class="outline secondary" { - span .icon-left-open {} + span .icon-left-open .md {} " Previous" } } @if let Some(next_commit_path) = next_commit_path { a role="button" class="outline secondary" href=(next_commit_path) { "Next " - span .icon-right-open {} + span .icon-right-open .md {} } } @else { button disabled class="outline secondary" { "Next " - span .icon-right-open {} + span .icon-right-open .md {} } } @if let Some(latest_commit_path) = latest_commit_path { @@ -825,22 +826,25 @@ async fn render_report( } } } - (chunks("report", false).await) - script { - (PreEscaped(r#"document.write('');drawTreemap("treemap","#)) + canvas #treemap {} + (report_chunks) + script nonce=[ctx.nonce.as_deref()] { + (PreEscaped(r#"window.units="#)) + (escape_script(&serde_json::to_string(&units)?)) + (PreEscaped(r#";drawTreemap("treemap","#)) (current_unit.is_none()) - "," - (PreEscaped(serde_json::to_string(&units)?)) - ");" + (PreEscaped(r#",window.units)"#)) } noscript { + style nonce=[ctx.nonce.as_deref()] { "canvas{display:none}" } img #treemap src=(image_url) alt="Progress graph"; } } } - (footer(start, current_user.as_ref())) + (ctx.footer(current_user.as_ref())) } - }) + }; + Ok((ctx, rendered).into_response()) } async fn render_history( @@ -848,9 +852,9 @@ async fn render_history( _state: &AppState, uri: Uri, current_user: Option, - start: Instant, + mut ctx: TemplateContext, result: Vec, -) -> Result { +) -> Result { let Scope { report, project_info, measures, current_category, current_unit, units: _, label } = scope; @@ -905,22 +909,28 @@ async fn render_history( } else { project_info.project.name() }; - let project_short_name = if let Some(label) = label { - Cow::Owned(format!("{} ({})", project_info.project.short_name(), label)) + let project_short_name = project_info.project.short_name(); + let project_short_name_with_label = if let Some(label) = label { + Cow::Owned(format!("{} ({})", project_short_name, label)) } else { - Cow::Borrowed(project_info.project.short_name()) + Cow::Borrowed(project_short_name) }; - Ok(html! { + // Load blocking resources first so we don't duplicate them + let header = ctx.header().await; + let history_chunks = ctx.chunks("history", Load::Blocking).await; + + let rendered = html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; - title { (project_short_name) " • Progress History" } - (header()) - (chunks("main", true).await) + title { (project_short_name_with_label) " • Progress History" } + (header) + (ctx.chunks("main", Load::Deferred).await) + (ctx.chunks("history", Load::Preload).await) meta name="description" content=(format!("Decompilation progress history for {project_name}")); - meta property="og:title" content=(format!("{project_short_name} is {:.2}% decompiled", measures.matched_code_percent)); + meta property="og:title" content=(format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent)); meta property="og:description" content=(format!("Decompilation progress history for {project_name}")); meta property="og:image" content=(image_url); meta property="og:url" content=(canonical_url); @@ -946,7 +956,7 @@ async fn render_history( } } main { - h3 { "History for " (project_short_name) } + h3 { "History for " (project_short_name_with_label) } details class="dropdown" title="Version" { summary { (report.version) } ul { @@ -969,11 +979,12 @@ async fn render_history( } } } - (chunks("history", false).await) - script { - (PreEscaped(r#"document.write('
');renderChart("chart","#)) - (PreEscaped(serde_json::to_string(&result)?)) - (PreEscaped(r#");"#)) + #chart {} + (history_chunks) + script nonce=[ctx.nonce.as_deref()] { + (PreEscaped(r#"window.historyData="#)) + (escape_script(&serde_json::to_string(&result)?)) + (PreEscaped(r#";renderChart("chart",window.historyData)"#)) } hr; div role="group" { @@ -981,7 +992,8 @@ async fn render_history( } } } - (footer(start, current_user.as_ref())) + (ctx.footer(current_user.as_ref())) } - }) + }; + Ok((ctx, rendered).into_response()) } diff --git a/crates/web/src/main.rs b/crates/web/src/main.rs index 5da4798..93a4434 100644 --- a/crates/web/src/main.rs +++ b/crates/web/src/main.rs @@ -16,6 +16,7 @@ use axum::{ Router, extract::{ConnectInfo, FromRef}, http::{Method, Request, header}, + middleware, }; use decomp_dev_core::config::{Config, GitHubConfig}; use decomp_dev_db::Database; @@ -33,7 +34,7 @@ use tower_sessions_sqlx_store::SqliteStore; use tracing::{Level, Span}; use tracing_subscriber::{EnvFilter, filter::LevelFilter}; -use crate::handlers::build_router; +use crate::handlers::{build_router, csp::csp_middleware}; #[derive(Clone, FromRef)] struct AppState { @@ -88,16 +89,51 @@ async fn main() { .await .expect("Failed to create scheduler"); + // Build the router + let port = state.config.server.port; + let router = app(state, session_store).into_make_service_with_connect_info::(); + + // Create the listener + let mut listener = None; + #[cfg(unix)] + { + use std::os::fd::{FromRawFd, IntoRawFd}; + let fds = libsystemd::activation::receive_descriptors_with_names(false) + .expect("Failed to receive fds"); + if let Some((fd, name)) = fds.into_iter().next() { + tracing::info!("Listening on {}", name); + let std_listener = unsafe { std::net::TcpListener::from_raw_fd(fd.into_raw_fd()) }; + std_listener.set_nonblocking(true).expect("Failed to set non-blocking"); + listener = + Some(TcpListener::from_std(std_listener).expect("Failed to create listener")); + } + } + let listener = match listener { + Some(listener) => listener, + None => { + let addr = SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)); + tracing::info!("Listening on {}", addr); + TcpListener::bind(addr).await.expect("bind error") + } + }; + + #[cfg(unix)] + { + libsystemd::daemon::notify(false, &[libsystemd::daemon::NotifyState::Ready]) + .expect("Failed to notify"); + } + // Run our service - let addr = SocketAddr::from((Ipv4Addr::UNSPECIFIED, state.config.server.port)); - tracing::info!("Listening on {}", addr); - axum::serve( - TcpListener::bind(addr).await.expect("bind error"), - app(state, session_store).into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) - .await - .expect("server error"); + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal()) + .await + .expect("server error"); + + #[cfg(unix)] + { + libsystemd::daemon::notify(false, &[libsystemd::daemon::NotifyState::Stopping]) + .expect("Failed to notify"); + } scheduler.shutdown().await.expect("Failed to shut down scheduler"); db.close().await; @@ -120,8 +156,9 @@ fn app(state: AppState, session_store: impl SessionStore + Clone) -> Router { SessionManagerLayer::new(session_store) .with_secure(false) .with_same_site(SameSite::Lax) - .with_expiry(Expiry::OnInactivity(time::Duration::days(1))), + .with_expiry(Expiry::OnInactivity(time::Duration::days(30))), ) + .layer(middleware::from_fn(csp_middleware)) .compression(); let router = build_router(); #[cfg(debug_assertions)] diff --git a/css/main.scss b/css/main.scss index 3e77a47..8fac4dc 100644 --- a/css/main.scss +++ b/css/main.scss @@ -93,6 +93,12 @@ $breakpoints: ( ); @use "@picocss/pico/scss/colors" as *; +@media (min-width: map.get(map.get($breakpoints, "md"), "breakpoint")) { + :root { + scrollbar-gutter: stable both-edges; + } +} + [data-theme=light], :root:not([data-theme=dark]) { --pico-code-kbd-color: #000; @@ -171,8 +177,11 @@ $breakpoints: ( cursor: pointer; } -nav { - > ul:first-child { +nav > ul { + white-space: nowrap; + text-overflow: ellipsis; + + &:first-child { > li:first-child * { color: var(--pico-contrast); } @@ -185,6 +194,12 @@ nav { padding-left: 10px; } + @media (max-width: map.get(map.get($breakpoints, "lg"), "breakpoint")) { + > li:has(+ .md):after { + display: none; + } + } + @media (max-width: map.get(map.get($breakpoints, "md"), "breakpoint")) { > li:not(:first-child) { display: none; @@ -203,13 +218,8 @@ nav { text-decoration: none; } -@keyframes progress-stripes { - 0% { - background-position: 0 0 - } - to { - background-position: calc(2.5rem) 0 - } +pre { + scrollbar-width: thin; } $progress-height: 2rem; @@ -223,7 +233,6 @@ $progress-height: 2rem; margin-bottom: var(--pico-spacing); .progress-section.striped { - //animation: progress-stripes 1s linear infinite; background-image: linear-gradient( 45deg, var(--progress-stripe-color) 25%, @@ -238,25 +247,25 @@ $progress-height: 2rem; } &.code { - .progress-section:nth-child(1) { + .progress-section:nth-of-type(1) { background-color: $jade-400; } - .progress-section:nth-child(2) { + .progress-section:nth-of-type(2) { background-color: $jade-500; } - .progress-section:nth-child(3) { + .progress-section:nth-of-type(3) { background-color: transparent; } } &.data { - .progress-section:nth-child(1) { + .progress-section:nth-of-type(1) { background-color: $azure-400; } - .progress-section:nth-child(2) { + .progress-section:nth-of-type(2) { background-color: $azure-500; } } @@ -323,13 +332,13 @@ $unit-size: 0.75rem; } } -@media (max-width: map.get(map.get($breakpoints, "md"), "breakpoint")) { +@media (max-width: map.get(map.get($breakpoints, "lg"), "breakpoint")) { .md { display: none !important; } } -@media (min-width: map.get(map.get($breakpoints, "md"), "breakpoint")) { +@media (min-width: map.get(map.get($breakpoints, "lg"), "breakpoint")) { .sm { display: none !important; } @@ -339,19 +348,30 @@ $unit-size: 0.75rem; margin-top: calc(var(--pico-block-spacing-vertical) * 2); } -footer { +body > footer { display: flex; justify-content: space-between; // Log out button - input[type="submit"] { - padding: .3em; - margin: 0; - background: none; - border: none; - width: auto; - font-size: .875em; - height: auto; + form { + display: inline; + + input[type="submit"] { + padding: .3em; + margin: 0; + background: none; + border: none; + width: auto; + font-size: .875em; + height: auto; + } + } +} + +@media (max-width: map.get(map.get($breakpoints, "md"), "breakpoint")) { + body > footer { + flex-flow: column; + align-items: center; } } @@ -416,6 +436,7 @@ footer { .actions { float: right; position: relative; + margin-inline-start: 1em; .dropdown { summary { @@ -431,3 +452,7 @@ footer { } } } + +details.dropdown { + clear: right; +} diff --git a/js/entry.ts b/js/entry.ts new file mode 100644 index 0000000..a4e3c08 --- /dev/null +++ b/js/entry.ts @@ -0,0 +1,26 @@ +if (import.meta.env.DEV) { + // For Rspack-loaded scripts, we need to set the nonce attribute on script tags + const currentScript = document.currentScript; + if (currentScript?.nonce) { + __webpack_nonce__ = currentScript.nonce; + // Additionally, for HMR support with Cross-Origin-Embedder-Policy (COEP), + // we need to set the crossorigin attribute on script tags inserted by HMR + const originalAppendChild = Element.prototype.appendChild; + Element.prototype.appendChild = function (node: T): T { + if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'SCRIPT') { + const script = node as unknown as HTMLScriptElement; + script.setAttribute('crossorigin', ''); + } + return originalAppendChild.call(this, node) as T; + }; + } +} + +// Set the theme based on localStorage +let theme: string | null = null; +try { + theme = localStorage.getItem('theme'); +} catch (_) {} +if (theme) { + document.documentElement.setAttribute('data-theme', theme); +} diff --git a/js/env.d.ts b/js/env.d.ts index aad5fcd..b8fdded 100644 --- a/js/env.d.ts +++ b/js/env.d.ts @@ -1,3 +1,5 @@ +/// + type Unit = { name: string; fuzzy_match_percent: number; diff --git a/js/history.ts b/js/history.ts index 19e7e19..1d63ccc 100644 --- a/js/history.ts +++ b/js/history.ts @@ -15,19 +15,26 @@ function percentValue( return rawValue == null ? '' : `${rawValue.toFixed(2)}%`; } -window.renderChart = (id: string, data: ReportHistoryEntry[]) => { +function renderChart(id: string, data: ReportHistoryEntry[]) { const chart = document.getElementById(id); if (!chart) { console.error(`Chart element with id ${id} not found`); return; } + data.reverse(); + function getSize() { + const container = chart!.parentElement; + if (container) { + return { width: container.offsetWidth, height }; + } + return { width: 600, height }; + } + const u = new uPlot( { - id: id, - width: 600, - height: height, + ...getSize(), scales: { x: { time: true, @@ -95,25 +102,22 @@ window.renderChart = (id: string, data: ReportHistoryEntry[]) => { ], }, }, - undefined, + [ + data.map((e) => Date.parse(e.timestamp) / 1000), + data.map((e) => e.measures.fuzzy_match_percent || null), + data.map((e) => e.measures.matched_code_percent || null), + data.map((e) => e.measures.matched_data_percent || null), + data.map((e) => e.measures.complete_code_percent || null), + data.map((e) => e.measures.complete_data_percent || null), + ], chart, ); function updateSize() { - const container = chart?.parentElement; - if (container) { - u.setSize({ width: container.offsetWidth, height }); - } + u.setSize(getSize()); } window.addEventListener('resize', updateSize); - updateSize(); - u.setData([ - data.map((e) => Date.parse(e.timestamp) / 1000), - data.map((e) => e.measures.fuzzy_match_percent || null), - data.map((e) => e.measures.matched_code_percent || null), - data.map((e) => e.measures.matched_data_percent || null), - data.map((e) => e.measures.complete_code_percent || null), - data.map((e) => e.measures.complete_data_percent || null), - ]); -}; +} + +window.renderChart = renderChart; diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..79db12f18405261e6339e08989c23ecb5a51eb9b GIT binary patch literal 7037 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 literal 0 HcmV?d00001 diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..381683a53561fa27e1392ad21f272575604fd2e5 GIT binary patch literal 4058 zcmeAS@N?(olHy`uVBq!ia0y~yU`POA4mJh`hDS5XEf^RW7>k44ofy`glX=O&z(2*) z#WAEJ?(N+2p75h$$Is_$W{HTbNq8?JD$TKNMUKfUyX4Lv?Hi@5a~cxzb(&Q912n(I zIaYi-B30eJ_q$Z}`vdRx)uerYch7qJ*)wy0-@e~9dB&bM`Ol`jw=8}($GEssR4`MJ zfAOR43G9)#JXJVCvx}roOg1~3klE03G-Un{_YBc#3k@1wu66~NE_twHjmhs4v4^i7 zKWI#5=j1HB3gFpLBG25i#mkMm%9d@w4LyQ z)peD|Porn&Kd3&jpXpZ`aOTM)M;3mSKN35YL^C(Ocx$kKuXu=Y)Q^LUx&v0$UdfI> zyFYoe6m+9<&vuV?nAn%p?jc3iCE&9X$|DVk@f4^S;^lJ6`Q^x0Ql<(Jm zpZn`{+q+E@-Oig#K%icuXto!{ox$4i~@Ar#Ns!rF~ zrFS@6k?q9lb-Px{6rE7qbW|*wqe+^dw~^oOheO`3mui(i9<~?Tn4M!}HZd_#k$SLh z_q$Dt+Wo5EY;2dQ`|}6w>F{ZqwQS z=hIHP`R*e6syQ*$Z?_hGK5KruiIw})v@QSNon~m*|M8gg>7UQ%>)Td+>G}TN^7E^fC<-^r&&oAU=1tkbg3+UQ zHj&e!^L9%7;5hC0`_pOt)3)F5Ear=HNWIIYy+*;goi8+cPyGM7ON*;lJAQHeGtVS* zlFg3?&2FbQG5@=KL15=>-g69-1zLD4jvwh1PThRgth@35)$sU}C)MZc%&+<6`MNRY zL1x{=tz7Dl9<^`^r+hwZ-rv}MDMjwcC-eI?#`Ds)6~4c=c6QFzt6@bC8re@pWv`to zk!APmg|g+#C6k}r&fkBQ*VWZo;N>SlcbSEo|Ns3y&B!hz zAUx%VZ_g2nCFzkX=YRU?ncK-C4#_7-AF=;bra4`Jv zvwo|x*uB5+=-;28pRb<#g&Dxt5>DQx<9`CPxvq9b*O7-u?FFkE_bb93soHIb{% zCK#2(wQLLaw+(gclaXBf_uudLr*{-Se$p;q=b`#GsbQXNwbs1bIh%c}SF0p^`1N}I z>B#iCOSQhdnb;h4=zOKc@=v#`Uat-9a=XLFuXVWg+fDba;+wqx<%BXk+H%>iThu=1 ze{!Ftn}N%$|2L!Nd|Xw*{VSxgB;;|+Uj6-lHchb0yk+&4p+&`Y@h#qVd4~Pp z?^QoN#I3(Y$jp-?$zXTKl`whn_?nG(3XjXKw8`NSkl7{Su>Q{e|Np9&s$13zrk^S@ zT`tR@VVrg*V55ukGiyZE}czU)%L`HyXz0}hL!S> z`j3zGcc*IR@BKRMR9#Q-d701O-`)-{b^EH*eR%Gz$KCq-Jg(e3!f?>hzuj2oJEv*C z?Y9{LH-k4$x1CdVpzinE<*Tmo9gIFHAjKhFw=Ku@)hjKd^lvgRFPlrrE%0Aa{q1J@ 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 literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2499744fc5c681bb4a533caf1213c512f6255c89 GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..4667e6d --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..90f90d1 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "decomp.dev", + "short_name": "decomp.dev", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#181c25", + "background_color": "#181c25", + "display": "standalone" +} diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..60ab1203139c53bf0e3a9b1fd4fb1c3e6a456e97 GIT binary patch literal 6796 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', + // Error overlay doesn't work with CSP + overlay: false, + }, + // Always write dist/manifest.json and public files + writeToDisk: (file) => { + return ( + file.endsWith('manifest.json') || + (!file.includes('static/') && !file.includes('hot-update')) + ); }, - // Always write dist/manifest.json - writeToDisk: (file) => file === 'manifest.json', }, });