Frontend build improvements, support CSP, add icons

This commit is contained in:
Luke Street
2025-04-29 23:27:41 -06:00
parent 09b68b68be
commit 4379f97bbe
25 changed files with 911 additions and 342 deletions
Generated
+53 -1
View File
@@ -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]]
+1
View File
@@ -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"
+8
View File
@@ -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"
+1 -1
View File
@@ -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"
+4
View File
@@ -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"
+39 -17
View File
@@ -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<LoginQuery>,
headers: HeaderMap,
Query(query): Query<LoginQuery>,
State(config): State<GitHubConfig>,
current_user: Option<CurrentUser>,
mut ctx: TemplateContext,
) -> Result<Response, AppError> {
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<Response, AppError> {
pub async fn logout(
session: Session,
headers: HeaderMap,
Query(query): Query<LoginQuery>,
) -> Result<Response, AppError> {
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<String> {
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('/'))
}
File diff suppressed because it is too large Load Diff
+103
View File
@@ -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<Self> {
extensions.get::<Nonce>().cloned()
}
}
impl<S> FromRequestParts<S> for Nonce
where S: Send + Sync
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(req: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Self::from_extensions(&req.extensions)
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Nonce not found"))
}
}
impl<S> OptionalFromRequestParts<S> for Nonce
where S: Send + Sync
{
type Rejection = Infallible;
async fn from_request_parts(
req: &mut Parts,
_state: &S,
) -> Result<Option<Self>, Self::Rejection> {
Ok(Self::from_extensions(&req.extensions))
}
}
#[derive(Debug, Clone, Default)]
pub struct ExtraDomains(pub Vec<String>);
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::<ExtraDomains>().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
}
+39 -36
View File
@@ -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<AppState>,
current_user: CurrentUser,
) -> Result<Markup, AppError> {
let start = Instant::now();
) -> Result<Response, AppError> {
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::<Vec<_>>();
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(&current_user)))
(ctx.footer(Some(&current_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<AppState>,
current_user: CurrentUser,
) -> Result<Response, AppError> {
render_new(&state, &current_user, None, None).await
render_new(ctx, &state, &current_user, None, None).await
}
async fn render_new(
mut ctx: TemplateContext,
state: &AppState,
current_user: &CurrentUser,
message: Option<&str>,
prefill: Option<&Project>,
) -> Result<Response, AppError> {
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<AppState>,
current_user: CurrentUser,
Form(form): Form<NewForm>,
@@ -245,6 +245,7 @@ pub async fn new_save(
Err(e) => {
tracing::error!("Failed to fetch repository: {:?}", e);
return render_new(
ctx,
&state,
&current_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,
&current_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, &current_user, Some(&message), Some(&project)).await;
return render_new(ctx, &state, &current_user, Some(&message), Some(&project)).await;
}
};
project.workflow_id = Some(workflow_id);
@@ -292,8 +294,8 @@ pub async fn manage_project(
Path(params): Path<ProjectParams>,
State(state): State<AppState>,
current_user: CurrentUser,
) -> Result<Markup, AppError> {
let start = Instant::now();
ctx: TemplateContext,
) -> Result<Response, AppError> {
let Some(project_info) = state.db.get_project_info(&params.owner, &params.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, &current_user, Message::None).await)
render_manage_project(ctx, &state, &project_info, &current_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<Response, AppError> {
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<bool, D::Error>
@@ -535,11 +538,11 @@ pub async fn manage_project_save(
}
pub async fn manage_project_refresh(
ctx: TemplateContext,
Path(params): Path<ProjectParams>,
State(state): State<AppState>,
current_user: CurrentUser,
) -> Result<Markup, AppError> {
let start = Instant::now();
) -> Result<Response, AppError> {
let Some(project_info) = state.db.get_project_info(&params.owner, &params.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, &current_user, message).await)
render_manage_project(ctx, &state, &project_info, &current_user, message).await
}
+29 -2
View File
@@ -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<AppState> {
Router::new()
.nest_service("/static", ServeDir::new("dist/static"))
.nest_service(
"/static",
<ServeDir as ServiceExt<Request>>::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(<ServeDir as ServiceExt<Request>>::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))
+17 -11
View File
@@ -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<AppState>,
Query(query): Query<ProjectsQuery>,
FullUri(uri): FullUri,
current_user: Option<CurrentUser>,
) -> Result<Response, AppError> {
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::<Vec<_>>();
@@ -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 "
+70 -58
View File
@@ -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<AppState>,
current_user: Option<CurrentUser>,
ctx: TemplateContext,
) -> Result<Response, AppError> {
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<CurrentUser>,
) -> Result<Response, AppError> {
@@ -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<CurrentUser>,
) -> Result<Response, AppError> {
@@ -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<CurrentUser>,
start: Instant,
) -> Result<Markup> {
mut ctx: TemplateContext,
) -> Result<Response, AppError> {
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('<canvas id="treemap" width="100%"></canvas>');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<CurrentUser>,
start: Instant,
mut ctx: TemplateContext,
result: Vec<ReportHistoryEntry>,
) -> Result<Markup> {
) -> Result<Response, AppError> {
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('<div id="chart" width="100%"></div>');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())
}
+48 -11
View File
@@ -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::<SocketAddr>();
// 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::<SocketAddr>(),
)
.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)]
+51 -26
View File
@@ -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;
}
+26
View File
@@ -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 <T extends Node>(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);
}
Vendored
+2
View File
@@ -1,3 +1,5 @@
/// <reference types="@rsbuild/core/types" />
type Unit = {
name: string;
fuzzy_match_percent: number;
+23 -19
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More