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 0000000..79db12f Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000..381683a Binary files /dev/null and b/public/favicon-96x96.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..2499744 Binary files /dev/null and b/public/favicon.ico differ 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 0000000..60ab120 Binary files /dev/null and b/public/web-app-manifest-192x192.png differ diff --git a/public/web-app-manifest-512x512.png b/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..d21514d Binary files /dev/null and b/public/web-app-manifest-512x512.png differ diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 8adc8b6..6a49487 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -5,19 +5,36 @@ import { pluginTypeCheck } from '@rsbuild/plugin-type-check'; export default defineConfig({ source: { entry: { + entry: ['./js/entry.ts'], main: ['./js/main.ts', './css/main.scss'], history: ['./js/history.ts', './css/history.css'], manage: ['./js/manage.ts'], report: ['./js/treemap.ts'], }, }, - // Write manifest.json, we pull JS and CSS asset paths from it output: { + // Write manifest.json, we pull JS and CSS asset paths from it manifest: true, + // Copy public files to dist + copy: [{ from: 'public' }], }, - // Disable HTML generation tools: { + // Disable HTML generation htmlPlugin: false, + rspack: { + // Use shared runtime for all entry points + optimization: { + runtimeChunk: 'single', + }, + // Disable bundler info to save a few bytes + experiments: { + rspackFuture: { + bundlerInfo: { + force: false, + }, + }, + }, + }, }, plugins: [pluginSass(), pluginTypeCheck()], server: { @@ -30,8 +47,15 @@ export default defineConfig({ client: { host: 'localhost', port: '', + // 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', }, });