diff --git a/.sqlx/query-e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21.json b/.sqlx/query-415ca8dbe5fb5b2d573200257e96f2689efdbd0af3397ebdc9f4dec0a8709bd3.json similarity index 80% rename from .sqlx/query-e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21.json rename to .sqlx/query-415ca8dbe5fb5b2d573200257e96f2689efdbd0af3397ebdc9f4dec0a8709bd3.json index c7416d5..c2f3252 100644 --- a/.sqlx/query-e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21.json +++ b/.sqlx/query-415ca8dbe5fb5b2d573200257e96f2689efdbd0af3397ebdc9f4dec0a8709bd3.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT id AS \"id!\", owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id\n FROM projects\n WHERE owner = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE\n ", + "query": "\n SELECT id AS \"id!\", owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled\n FROM projects\n WHERE owner = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE\n ", "describe": { "columns": [ { @@ -57,6 +57,11 @@ "name": "header_image_id", "ordinal": 10, "type_info": "Blob" + }, + { + "name": "enabled", + "ordinal": 11, + "type_info": "Bool" } ], "parameters": { @@ -73,8 +78,9 @@ true, true, false, - true + true, + false ] }, - "hash": "e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21" + "hash": "415ca8dbe5fb5b2d573200257e96f2689efdbd0af3397ebdc9f4dec0a8709bd3" } diff --git a/.sqlx/query-5bdd491e42bf700a271a5fc8431e2511d3f1668d07956ac60a1450ddd8901135.json b/.sqlx/query-5bdd491e42bf700a271a5fc8431e2511d3f1668d07956ac60a1450ddd8901135.json new file mode 100644 index 0000000..8695018 --- /dev/null +++ b/.sqlx/query-5bdd491e42bf700a271a5fc8431e2511d3f1668d07956ac60a1450ddd8901135.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "5bdd491e42bf700a271a5fc8431e2511d3f1668d07956ac60a1450ddd8901135" +} diff --git a/.sqlx/query-5de3235fb918d2e691289b998a5899e71f5f491f72864b134b4debe85bb57fa9.json b/.sqlx/query-5de3235fb918d2e691289b998a5899e71f5f491f72864b134b4debe85bb57fa9.json deleted file mode 100644 index 497ee2a..0000000 --- a/.sqlx/query-5de3235fb918d2e691289b998a5899e71f5f491f72864b134b4debe85bb57fa9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n ON CONFLICT (id) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "5de3235fb918d2e691289b998a5899e71f5f491f72864b134b4debe85bb57fa9" -} diff --git a/.sqlx/query-c0ffba953513be9fccf5427dd818ef17184498e031c3f19aae4b27af4ab9fb0d.json b/.sqlx/query-c0ffba953513be9fccf5427dd818ef17184498e031c3f19aae4b27af4ab9fb0d.json new file mode 100644 index 0000000..845a245 --- /dev/null +++ b/.sqlx/query-c0ffba953513be9fccf5427dd818ef17184498e031c3f19aae4b27af4ab9fb0d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n ON CONFLICT (id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "c0ffba953513be9fccf5427dd818ef17184498e031c3f19aae4b27af4ab9fb0d" +} diff --git a/.sqlx/query-36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364.json b/.sqlx/query-d66c20bc973f3199c05ca1e7f2e3b87cb3bf82646329034caeea9c48d9586823.json similarity index 81% rename from .sqlx/query-36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364.json rename to .sqlx/query-d66c20bc973f3199c05ca1e7f2e3b87cb3bf82646329034caeea9c48d9586823.json index 171f246..5946f66 100644 --- a/.sqlx/query-36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364.json +++ b/.sqlx/query-d66c20bc973f3199c05ca1e7f2e3b87cb3bf82646329034caeea9c48d9586823.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id\n FROM projects\n WHERE id = ?\n ", + "query": "\n SELECT owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled\n FROM projects\n WHERE id = ?\n ", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "name": "header_image_id", "ordinal": 9, "type_info": "Blob" + }, + { + "name": "enabled", + "ordinal": 10, + "type_info": "Bool" } ], "parameters": { @@ -67,8 +72,9 @@ true, true, false, - true + true, + false ] }, - "hash": "36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364" + "hash": "d66c20bc973f3199c05ca1e7f2e3b87cb3bf82646329034caeea9c48d9586823" } diff --git a/.sqlx/query-e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6.json b/.sqlx/query-e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6.json deleted file mode 100644 index e2ba8e1..0000000 --- a/.sqlx/query-e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6" -} diff --git a/.sqlx/query-c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978.json b/.sqlx/query-f7bf7260f20ecc8e216bfcf1c62d5c50ae1bb982ce29a63073f89a7723fc6f11.json similarity index 58% rename from .sqlx/query-c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978.json rename to .sqlx/query-f7bf7260f20ecc8e216bfcf1c62d5c50ae1bb982ce29a63073f89a7723fc6f11.json index 31a80c6..878cef0 100644 --- a/.sqlx/query-c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978.json +++ b/.sqlx/query-f7bf7260f20ecc8e216bfcf1c62d5c50ae1bb982ce29a63073f89a7723fc6f11.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n UPDATE projects\n SET owner = ?, repo = ?, name = ?, short_name = ?, default_category = ?, default_version = ?, platform = ?, workflow_id = ?, enable_pr_comments = ?, header_image_id = ?, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n ", + "query": "\n UPDATE projects\n SET owner = ?, repo = ?, name = ?, short_name = ?, default_category = ?, default_version = ?, platform = ?, workflow_id = ?, enable_pr_comments = ?, header_image_id = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n ", "describe": { "columns": [], "parameters": { - "Right": 11 + "Right": 12 }, "nullable": [] }, - "hash": "c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978" + "hash": "f7bf7260f20ecc8e216bfcf1c62d5c50ae1bb982ce29a63073f89a7723fc6f11" } diff --git a/.sqlx/query-927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a.json b/.sqlx/query-fc441fa93f0d46f6a0a541ae625154cc07efb5d4496d80d489f23a39f0e6b032.json similarity index 72% rename from .sqlx/query-927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a.json rename to .sqlx/query-fc441fa93f0d46f6a0a541ae625154cc07efb5d4496d80d489f23a39f0e6b032.json index 696cb6b..91f0fe5 100644 --- a/.sqlx/query-927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a.json +++ b/.sqlx/query-fc441fa93f0d46f6a0a541ae625154cc07efb5d4496d80d489f23a39f0e6b032.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n projects.id AS \"project_id!\",\n owner AS \"owner!\",\n repo AS \"repo!\",\n name,\n short_name,\n default_category,\n default_version,\n platform,\n workflow_id,\n enable_pr_comments AS \"enable_pr_comments!\",\n header_image_id,\n git_commit,\n git_commit_message,\n MAX(timestamp) AS \"timestamp: time::OffsetDateTime\",\n JSON_GROUP_ARRAY(version ORDER BY version)\n FILTER (WHERE version IS NOT NULL) AS versions\n FROM projects LEFT JOIN reports ON (\n reports.project_id = projects.id\n AND reports.timestamp = (\n SELECT MAX(timestamp)\n FROM reports\n WHERE project_id = projects.id\n )\n )\n GROUP BY projects.id\n ORDER BY MAX(timestamp) DESC\n ", + "query": "\n SELECT\n projects.id AS \"project_id!\",\n owner AS \"owner!\",\n repo AS \"repo!\",\n name,\n short_name,\n default_category,\n default_version,\n platform,\n workflow_id,\n enable_pr_comments AS \"enable_pr_comments!\",\n header_image_id,\n enabled AS \"enabled!\",\n git_commit,\n git_commit_message,\n MAX(timestamp) AS \"timestamp: time::OffsetDateTime\",\n JSON_GROUP_ARRAY(version ORDER BY version)\n FILTER (WHERE version IS NOT NULL) AS versions\n FROM projects LEFT JOIN reports ON (\n reports.project_id = projects.id\n AND reports.timestamp = (\n SELECT MAX(timestamp)\n FROM reports\n WHERE project_id = projects.id\n )\n )\n GROUP BY projects.id\n ORDER BY MAX(timestamp) DESC\n ", "describe": { "columns": [ { @@ -59,24 +59,29 @@ "type_info": "Blob" }, { - "name": "git_commit", + "name": "enabled!", "ordinal": 11, - "type_info": "Text" + "type_info": "Bool" }, { - "name": "git_commit_message", + "name": "git_commit", "ordinal": 12, "type_info": "Text" }, { - "name": "timestamp: time::OffsetDateTime", + "name": "git_commit_message", "ordinal": 13, "type_info": "Text" }, { - "name": "versions", + "name": "timestamp: time::OffsetDateTime", "ordinal": 14, "type_info": "Text" + }, + { + "name": "versions", + "ordinal": 15, + "type_info": "Text" } ], "parameters": { @@ -97,8 +102,9 @@ true, true, true, + true, true ] }, - "hash": "927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a" + "hash": "fc441fa93f0d46f6a0a541ae625154cc07efb5d4496d80d489f23a39f0e6b032" } diff --git a/Cargo.lock b/Cargo.lock index 3d335ee..c99ed21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1208,6 +1208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -2186,6 +2187,15 @@ dependencies = [ "glob", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -5676,18 +5686,23 @@ dependencies = [ [[package]] name = "zip" -version = "2.6.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +checksum = "95ab361742de920c5535880f89bbd611ee62002bf11341d16a5f057bb8ba6899" dependencies = [ "arbitrary", "crc32fast", - "crossbeam-utils", "flate2", "indexmap", "memchr", ] +[[package]] +name = "zlib-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index c41ae11..68a5766 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ [workspace.package] version = "0.1.0" edition = "2024" +rust-version = "1.88" [workspace.dependencies] anyhow = "1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..740c503 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# [decomp.dev](https://decomp.dev) + +Decompilation progress website & GitHub bot. + +For more information, see the [wiki](https://wiki.decomp.dev/tools/decomp-dev). + +## Backend Setup + +1. Install Rust via [rustup](https://rustup.rs/): + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + ``` +2. Clone the repository: + ```bash + git clone https://github.com/encounter/decomp.dev.git + cd decomp.dev + ``` +3. Copy `config.example.yml` to `config.yml`: + ```bash + cp config.example.yml config.yml + ``` +4. [Create a new GitHub personal access token](https://github.com/settings/tokens/new?description=decomp.dev&scopes=workflow,write:discussion) with scopes `workflow`, `write:discussion`. Set it in `config.yml`: + ```yaml + github: + token: ghp_abcd1234abcd1234abcd1234abcd1234abcd + ``` +5. Install [bacon](https://dystroy.org/bacon/): + ```bash + cargo install --locked bacon + ``` +6. Run the backend with automatic rebuild on changes: + ```bash + bacon + ``` + +When modifying Rust code, it will take a few seconds to build and restart the server. +Once the server updates, the browser page will automatically reload. + +## Frontend Setup + +1. Install dependencies: + ```bash + npm install + ``` +2. Start the frontend development server: + ```bash + npm start + ``` + +When the frontend development server is running, changes to files under `css/`, `js/` and `assets/` will live reload in the browser. + +## Testing + +When running both the backend and frontend, the site will be accessible at http://localhost:3000. + +By default, `server.dev_mode` in `config.yml` is enabled, allowing any visitor to log in as a superuser without authentication. + +To add projects to the site, visit http://localhost:3000/manage/new, using an existing project from [decomp.dev](https://decomp.dev/) as reference: + +![New project page](/docs/manage_new.png) diff --git a/config.example.yml b/config.example.yml index 6fd1f82..3df1072 100644 --- a/config.example.yml +++ b/config.example.yml @@ -1,5 +1,8 @@ server: port: 3000 + # Enable development mode + # WARNING: Allows anyone to log in as a super admin + dev_mode: true db: url: sqlite:./db.sqlite @@ -7,6 +10,3 @@ db: github: # Personal access token for public repo access token: ghp_abcd1234abcd1234abcd1234abcd1234abcd - -openai: - api_key: sk-proj-abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234 diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index 65b5b84..3076391 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -6,7 +6,10 @@ use axum::{ response::{IntoResponse, Redirect, Response}, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use decomp_dev_core::{AppError, config::GitHubConfig}; +use decomp_dev_core::{ + AppError, + config::{Config, GitHubConfig}, +}; use decomp_dev_github::graphql::{ CurrentUserResponse, RepositoryPermission, fetch_current_user, fetch_simple_current_user, }; @@ -46,18 +49,25 @@ pub type Profile = Author; #[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct CurrentUser { - pub oauth: StoredOAuth, + pub oauth: Option, pub data: CurrentUserResponse, #[serde(skip, default)] pub super_admin: bool, } impl CurrentUser { - pub fn client(&self) -> Result { - Octocrab::builder() - .oauth(self.oauth.clone().into()) - .build() - .context("Failed to create GitHub client") + pub fn client(&self, config: &GitHubConfig) -> Result { + if let Some(oauth) = &self.oauth { + Octocrab::builder() + .oauth(oauth.clone().into()) + .build() + .context("Failed to create GitHub client") + } else { + Octocrab::builder() + .personal_token(config.token.clone()) + .build() + .context("Failed to create GitHub client") + } } pub fn permissions_for_repo(&self, id: u64) -> RepositoryPermission { @@ -202,7 +212,7 @@ async fn fetch_access_token(config: &GitHubConfig, code: &str) -> Result FromRequestParts for CurrentUser where - GitHubConfig: FromRef, + Config: FromRef, S: Send + Sync, { type Rejection = Response; @@ -268,7 +278,7 @@ where impl OptionalFromRequestParts for CurrentUser where - GitHubConfig: FromRef, + Config: FromRef, S: Send + Sync, { type Rejection = (StatusCode, &'static str); @@ -278,7 +288,7 @@ where state: &S, ) -> Result, Self::Rejection> { let session = Session::from_request_parts(parts, state).await?; - let config = GitHubConfig::from_ref(state); + let config = Config::from_ref(state); let mut user = match session.get::(CURRENT_USER).await { Ok(Some(user)) => user, Ok(None) => return Ok(None), @@ -289,7 +299,7 @@ where }; // Refresh user data if the ID is 0 if user.data.id == 0 { - match user.client() { + match user.client(&config.github) { Ok(client) => match fetch_simple_current_user(&client).await { Ok(data) => { user.data = @@ -315,35 +325,37 @@ where } } } - user.super_admin = config.super_admin_ids.contains(&user.data.id); - if let Some(expires_at) = user.oauth.expires_at { - if (UtcDateTime::now() + Duration::seconds(30)) > expires_at { - // Access token expired, attempt to refresh - if let Err(e) = session.remove_value(CURRENT_USER).await { - tracing::error!("Failed to remove user from session: {}", e); - }; - if let Some(refresh_token) = &user.oauth.refresh_token { - if let Some(refresh_token_expires_at) = user.oauth.refresh_token_expires_at { - if UtcDateTime::now() >= refresh_token_expires_at { - // Refresh token expired + user.super_admin = (config.server.dev_mode && user.data.id == u64::MAX) + || config.github.super_admin_ids.contains(&user.data.id); + if let Some(oauth) = &user.oauth + && let Some(expires_at) = oauth.expires_at + && (UtcDateTime::now() + Duration::seconds(30)) > expires_at + { + // Access token expired, attempt to refresh + if let Err(e) = session.remove_value(CURRENT_USER).await { + tracing::error!("Failed to remove user from session: {}", e); + }; + if let Some(refresh_token) = &oauth.refresh_token { + if let Some(refresh_token_expires_at) = oauth.refresh_token_expires_at + && UtcDateTime::now() >= refresh_token_expires_at + { + // Refresh token expired + return Ok(None); + } + let current_user = + match refresh_access_token(&config.github, refresh_token, &user).await { + Ok(current_user) => current_user, + Err(e) => { + tracing::error!("Failed to refresh access token: {:?}", e); return Ok(None); } - } - let current_user = - match refresh_access_token(&config, refresh_token, &user).await { - Ok(current_user) => current_user, - Err(e) => { - tracing::error!("Failed to refresh access token: {:?}", e); - return Ok(None); - } - }; - if let Err(e) = session.insert(CURRENT_USER, current_user.clone()).await { - tracing::error!("Failed to insert user into session: {}", e); - } - return Ok(Some(current_user)); + }; + if let Err(e) = session.insert(CURRENT_USER, current_user.clone()).await { + tracing::error!("Failed to insert user into session: {}", e); } - return Ok(None); + return Ok(Some(current_user)); } + return Ok(None); } Ok(Some(user)) } diff --git a/crates/cli/src/cmd/changes.rs b/crates/cli/src/cmd/changes.rs index ca7eb8d..44227ee 100644 --- a/crates/cli/src/cmd/changes.rs +++ b/crates/cli/src/cmd/changes.rs @@ -35,9 +35,9 @@ pub fn run(args: Args) -> Result<()> { let comment = generate_comment(&previous_report, ¤t_report, None, None, None, changes); if let Some(out_path) = &args.output { std::fs::write(out_path.with_platform_encoding(), comment) - .with_context(|| format!("Failed to write output file '{}'", out_path))?; + .with_context(|| format!("Failed to write output file '{out_path}'"))?; } else { - println!("{}", comment); + println!("{comment}"); } Ok(()) } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 624fbc2..12a9d29 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -11,6 +11,8 @@ pub struct Config { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerConfig { pub port: u16, + #[serde(default)] + pub dev_mode: bool, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1fe0856..0b92e1c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -25,7 +25,7 @@ impl IntoResponse for AppError { Self::Status(status) => status.into_response(), Self::Internal(err) => { tracing::error!("{:?}", err); - (StatusCode::INTERNAL_SERVER_ERROR, format!("Something went wrong: {}", err)) + (StatusCode::INTERNAL_SERVER_ERROR, format!("Something went wrong: {err}")) .into_response() } } diff --git a/crates/core/src/models.rs b/crates/core/src/models.rs index 26c7e07..f72605c 100644 --- a/crates/core/src/models.rs +++ b/crates/core/src/models.rs @@ -20,6 +20,7 @@ pub struct Project { pub workflow_id: Option, pub enable_pr_comments: bool, pub header_image_id: Option, + pub enabled: bool, } impl Default for Project { @@ -36,12 +37,13 @@ impl Default for Project { workflow_id: None, enable_pr_comments: true, header_image_id: None, + enabled: true, } } } impl Project { - pub fn name(&self) -> Cow { + pub fn name(&self) -> Cow<'_, str> { if let Some(name) = self.name.as_ref() { Cow::Borrowed(name) } else { @@ -54,6 +56,8 @@ impl Project { } pub fn repo_url(&self) -> String { format!("https://github.com/{}/{}", self.owner, self.repo) } + + pub fn default_category(&self) -> &str { self.default_category.as_deref().unwrap_or("all") } } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] @@ -225,3 +229,21 @@ impl FromStr for Platform { } } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectVisibility { + Visible, + Hidden, + Disabled, +} + +pub fn project_visibility(project: &Project, measures: Option<&Measures>) -> ProjectVisibility { + // Hide projects with less than 0.5% matched code or if the project is disabled + if !project.enabled { + ProjectVisibility::Disabled + } else if measures.is_none_or(|m| m.matched_code_percent < 0.5) { + ProjectVisibility::Hidden + } else { + ProjectVisibility::Visible + } +} diff --git a/crates/core/src/util.rs b/crates/core/src/util.rs index a747b6d..b8fb147 100644 --- a/crates/core/src/util.rs +++ b/crates/core/src/util.rs @@ -31,10 +31,8 @@ impl UrlExt for Url { pairs.append_pair(&k, &v); } } - if !updated { - if let Some(value) = value { - pairs.append_pair(key, value); - } + if !updated && let Some(value) = value { + pairs.append_pair(key, value); } drop(pairs); if out.query() == Some("") { diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 381ef00..3936711 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -86,10 +86,11 @@ impl Database { ) -> Result<()> { let mut tx = self.pool.begin().await?; let project_id = project.id as i64; + let header_image_id = project.header_image_id.as_ref().map(|b| b.as_slice()); sqlx::query!( r#" - INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING "#, project_id, @@ -99,6 +100,11 @@ impl Database { project.short_name, project.default_category, project.default_version, + project.platform, + project.workflow_id, + project.enable_pr_comments, + header_image_id, + project.enabled, ) .execute(&mut *tx) .await?; @@ -358,7 +364,7 @@ impl Database { let mut conn = self.pool.acquire().await?; let project = match sqlx::query!( r#" - SELECT id AS "id!", owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id + SELECT id AS "id!", owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled FROM projects WHERE owner = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE "#, @@ -380,6 +386,7 @@ impl Database { workflow_id: row.workflow_id, enable_pr_comments: row.enable_pr_comments, header_image_id: row.header_image_id.and_then(|b| b.try_into().ok()), + enabled: row.enabled, }, None => return Ok(None), }; @@ -395,7 +402,7 @@ impl Database { let project_id_db = project_id as i64; let project = match sqlx::query!( r#" - SELECT owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id + SELECT owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled FROM projects WHERE id = ? "#, @@ -416,6 +423,7 @@ impl Database { workflow_id: row.workflow_id, enable_pr_comments: row.enable_pr_comments, header_image_id: row.header_image_id.and_then(|b| b.try_into().ok()), + enabled: row.enabled, }, None => return Ok(None), }; @@ -550,6 +558,7 @@ impl Database { workflow_id, enable_pr_comments AS "enable_pr_comments!", header_image_id, + enabled AS "enabled!", git_commit, git_commit_message, MAX(timestamp) AS "timestamp: time::OffsetDateTime", @@ -583,6 +592,7 @@ impl Database { workflow_id: row.workflow_id, enable_pr_comments: row.enable_pr_comments, header_image_id: row.header_image_id.and_then(|b| b.try_into().ok()), + enabled: row.enabled, }, commit: match (row.git_commit, row.timestamp) { (Some(sha), Some(timestamp)) => Some(Commit { @@ -964,7 +974,7 @@ impl Database { sqlx::query!( r#" UPDATE projects - SET owner = ?, repo = ?, name = ?, short_name = ?, default_category = ?, default_version = ?, platform = ?, workflow_id = ?, enable_pr_comments = ?, header_image_id = ?, updated_at = CURRENT_TIMESTAMP + SET owner = ?, repo = ?, name = ?, short_name = ?, default_category = ?, default_version = ?, platform = ?, workflow_id = ?, enable_pr_comments = ?, header_image_id = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "#, project.owner, @@ -977,6 +987,7 @@ impl Database { project.workflow_id, project.enable_pr_comments, header_image_id, + project.enabled, project_id, ) .execute(&mut *conn) @@ -987,10 +998,11 @@ impl Database { pub async fn create_project(&self, project: &Project) -> Result<()> { let mut conn = self.pool.acquire().await?; let project_id = project.id as i64; + let header_image_id = project.header_image_id.as_ref().map(|b| b.as_slice()); sqlx::query!( r#" - INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) "#, project_id, project.owner, @@ -1002,6 +1014,8 @@ impl Database { project.platform, project.workflow_id, project.enable_pr_comments, + header_image_id, + project.enabled, ) .execute(&mut *conn) .await?; @@ -1108,7 +1122,7 @@ thread_local! { fn compress(data: &[u8]) -> Vec { COMPRESSOR.with_borrow_mut(|z| z.compress(data).unwrap()) } -fn decompress(data: &[u8]) -> Result> { +fn decompress(data: &[u8]) -> Result> { match zstd::zstd_safe::get_frame_content_size(data) { Ok(Some(size)) => { Ok(Cow::Owned(DECOMPRESSOR.with_borrow_mut(|z| z.decompress(data, size as usize))?)) diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 9692c3f..636d77b 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -24,4 +24,4 @@ sha2 = "0.10" time.workspace = true tokio.workspace = true tracing.workspace = true -zip = { version = "2.6", default-features = false, features = ["flate2", "deflate-flate2"] } +zip = { version = "4.2", default-features = false, features = ["deflate-flate2-zlib-rs"] } diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index d86f885..1e7d575 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -127,7 +127,7 @@ async fn list_installation_repositories( page += 1; response = app_client .get( - &format!("/installation/repositories?page={}", page), + &format!("/installation/repositories?page={page}"), Some(&PageParams { per_page: Some(100), page: Some(page) }), ) .await?; @@ -247,13 +247,13 @@ pub async fn refresh_project( .get_project_info_by_id(repo_id, None) .await .context("Failed to fetch project info")? - .with_context(|| format!("Failed to fetch project info for ID {}", repo_id))?; + .with_context(|| format!("Failed to fetch project info for ID {repo_id}"))?; let repo = client_override .unwrap_or(&github.client) .repos_by_id(project_info.project.id) .get() .await - .with_context(|| format!("Failed to fetch repo for ID {}", repo_id))?; + .with_context(|| format!("Failed to fetch repo for ID {repo_id}"))?; let branch = repo.default_branch.as_deref().unwrap_or("main"); let owner = repo.owner.context("Repository has no owner")?; @@ -270,7 +270,7 @@ pub async fn refresh_project( .get_project_info_by_id(repo_id, None) .await .context("Failed to fetch project info")? - .with_context(|| format!("Failed to fetch project info for ID {}", repo_id))?; + .with_context(|| format!("Failed to fetch project info for ID {repo_id}"))?; } let project = &project_info.project; @@ -324,16 +324,15 @@ pub async fn refresh_project( } Err(e) => { return Err(e) - .with_context(|| format!("Failed to fetch workflows page {}", page)); + .with_context(|| format!("Failed to fetch workflows page {page}")); } }; for run in items { - if !full_refresh { - if let Some(commit) = project_info.commit.as_ref() { - if run.head_sha == commit.sha { - break 'outer; - } - } + if !full_refresh + && let Some(commit) = project_info.commit.as_ref() + && run.head_sha == commit.sha + { + break 'outer; } runs.push(run); } @@ -644,3 +643,37 @@ pub async fn check_for_reports( } Err(anyhow!("No workflow runs containing reports found.")) } + +pub fn extract_github_url(url: &str) -> Option<(&str, &str)> { + static REGEX: OnceLock = OnceLock::new(); + let caps = REGEX + .get_or_init(|| { + Regex::new(r"^https?://github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?(?:/|$)") + .unwrap() + }) + .captures(url)?; + let owner = caps.name("owner").map(|m| m.as_str()).unwrap_or_default(); + let repo = caps.name("repo").map(|m| m.as_str()).unwrap_or_default(); + Some((owner, repo)) +} + +#[cfg(test)] +mod tests { + use super::extract_github_url; + + #[test] + fn test_extract_github_url() { + let cases: &[(&str, Option<(&str, &str)>)] = &[ + ("https://github.com/foo/bar", Some(("foo", "bar"))), + ("http://github.com/foo/bar/", Some(("foo", "bar"))), + ("https://github.com/foo/bar.git", Some(("foo", "bar"))), + ("https://github.com/foo/bar/issues/17", Some(("foo", "bar"))), + ("https://gitlab.com/foo/bar", None), + ("https://github.com/foo", None), + ("https://github.com/foo/bar.git/issues", Some(("foo", "bar"))), + ]; + for &(url, expected) in cases { + assert_eq!(extract_github_url(url), expected); + } + } +} diff --git a/crates/github/src/webhook.rs b/crates/github/src/webhook.rs index af57df7..2924b43 100644 --- a/crates/github/src/webhook.rs +++ b/crates/github/src/webhook.rs @@ -118,14 +118,12 @@ pub async fn webhook(GitHubEvent { event, state }: GitHubEvent) -> Result { - if inner.action == PullRequestWebhookEventAction::Opened - || inner.action == PullRequestWebhookEventAction::Synchronize + if (inner.action == PullRequestWebhookEventAction::Opened + || inner.action == PullRequestWebhookEventAction::Synchronize) + && let Err(e) = handle_pull_request_update(&state, client, inner.pull_request).await { - if let Err(e) = handle_pull_request_update(&state, client, inner.pull_request).await - { - tracing::error!("Error handling pull_request event: {e}"); - return Ok((StatusCode::OK, "Internal error").into_response()); - } + tracing::error!("Error handling pull_request event: {e}"); + return Ok((StatusCode::OK, "Internal error").into_response()); } } WebhookEventPayload::Installation(inner) => { diff --git a/crates/images/src/badge.rs b/crates/images/src/badge.rs index 5c2bfbb..759cdf6 100644 --- a/crates/images/src/badge.rs +++ b/crates/images/src/badge.rs @@ -30,14 +30,14 @@ pub struct ShieldResponse { label_color: Option, } -fn format_percent(value: f32) -> String { format!("{:.2}%", value) } +fn format_percent(value: f32) -> String { format!("{value:.2}%") } fn format_bytes(a: u64, b: u64) -> String { let mut a_buf = num_format::Buffer::default(); let mut b_buf = num_format::Buffer::default(); a_buf.write_formatted(&a, &num_format::Locale::en); b_buf.write_formatted(&b, &num_format::Locale::en); - format!("{} B / {} B", a_buf, b_buf) + format!("{a_buf} B / {b_buf} B") } fn format_num(a: u32, b: u32) -> String { @@ -45,7 +45,7 @@ fn format_num(a: u32, b: u32) -> String { let mut b_buf = num_format::Buffer::default(); a_buf.write_formatted(&a, &num_format::Locale::en); b_buf.write_formatted(&b, &num_format::Locale::en); - format!("{} / {}", a_buf, b_buf) + format!("{a_buf} / {b_buf}") } fn format_size(a: u64, b: u64) -> String { format!("{} / {}", size(a), size(b)) } diff --git a/crates/web/src/cron.rs b/crates/web/src/cron.rs index ae348d4..a3e2783 100644 --- a/crates/web/src/cron.rs +++ b/crates/web/src/cron.rs @@ -64,6 +64,14 @@ pub async fn create( pub async fn refresh_projects(state: &AppState, full_refresh: bool) -> Result<()> { for project_info in state.db.get_projects().await? { + if !project_info.project.enabled { + log::debug!( + "Skipping disabled project {}/{}", + project_info.project.owner, + project_info.project.repo + ); + continue; + } if !full_refresh { // Skip projects with active app installations if let Some(installations) = &state.github.installations { diff --git a/crates/web/src/frogress/mod.rs b/crates/web/src/frogress/mod.rs index 257265f..d5ffb1c 100644 --- a/crates/web/src/frogress/mod.rs +++ b/crates/web/src/frogress/mod.rs @@ -41,11 +41,9 @@ pub async fn migrate_data(state: &mut AppState) -> Result<()> { .get_project_info_by_id(project_id, None) .await? .ok_or_else(|| anyhow!("No project ID {}", project_id))?; - let data = reqwest::get(&format!( - "https://progress.decomp.club/data/{}/{}?mode=all", - slug, version - )) - .await?; + let data = + reqwest::get(&format!("https://progress.decomp.club/data/{slug}/{version}?mode=all")) + .await?; let data: FrogressAllData = data.json().await?; let mut data_iter = data.iter(); let (data_slug, version_data) = @@ -196,7 +194,7 @@ pub async fn migrate_data(state: &mut AppState) -> Result<()> { } if report.measures.is_none() { if report.categories.is_empty() { - log::warn!("No measures for {}/{}/{}", slug, version, category); + log::warn!("No measures for {slug}/{version}/{category}"); reports.remove(&entry.git_hash); } else { let mut all_measures = Measures::default(); diff --git a/crates/web/src/handlers/auth.rs b/crates/web/src/handlers/auth.rs index 4291a13..8833e47 100644 --- a/crates/web/src/handlers/auth.rs +++ b/crates/web/src/handlers/auth.rs @@ -3,8 +3,9 @@ use axum::{ 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, util::UrlExt}; +use decomp_dev_auth::{CURRENT_USER, CurrentUser, GITHUB_OAUTH_STATE, RETURN_TO, generate_nonce}; +use decomp_dev_core::{AppError, config::Config, util::UrlExt}; +use decomp_dev_github::graphql::CurrentUserResponse; use maud::{DOCTYPE, html}; use tower_sessions::Session; @@ -19,14 +20,30 @@ pub async fn login( session: Session, headers: HeaderMap, Query(query): Query, - State(config): State, + State(config): State, current_user: Option, mut ctx: TemplateContext, ) -> Result { if current_user.is_some() { return Ok(Redirect::to("/").into_response()); } - let Some(config) = &config.oauth else { + let Some(config) = &config.github.oauth else { + // Dev mode override + if config.server.dev_mode { + session + .insert(CURRENT_USER, CurrentUser { + oauth: None, + data: CurrentUserResponse { + id: u64::MAX, + login: "devuser".to_string(), + url: String::new(), + repositories: vec![], + }, + super_admin: true, + }) + .await?; + return Ok(Redirect::to("/").into_response()); + } tracing::warn!("No GitHub OAuth config found"); return Ok((StatusCode::INTERNAL_SERVER_ERROR, "No GitHub OAuth config").into_response()); }; diff --git a/crates/web/src/handlers/common.rs b/crates/web/src/handlers/common.rs index cbe73a3..c478f4f 100644 --- a/crates/web/src/handlers/common.rs +++ b/crates/web/src/handlers/common.rs @@ -381,9 +381,9 @@ impl ProgressSections { } pub fn push(&mut self, class: &str, percent: f32, tooltip: String) { - let width_class = format!("width-{:.2}", percent).replace('.', "p"); + let width_class = format!("width-{percent:.2}").replace('.', "p"); let rendered = html! { .(class).(width_class) data-tooltip=(tooltip) {} }; - self.width_classes.insert(width_class, format!("width:{}%", percent)); + self.width_classes.insert(width_class, format!("width:{percent}%")); self.rendered.0.push_str(&rendered.0); } } @@ -454,7 +454,7 @@ pub async fn get_robots() -> Result { Ok(text) } -pub fn escape_script(text: &str) -> PreEscaped> { +pub fn escape_script(text: &str) -> PreEscaped> { static REGEX: LazyLock = LazyLock::new(|| Regex::new("<(?i:!--|/?script)").unwrap()); PreEscaped(REGEX.replace_all(text, |caps: ®ex::Captures| format!("\\x3C{}", &caps[0][1..]))) } diff --git a/crates/web/src/handlers/csp.rs b/crates/web/src/handlers/csp.rs index 895209c..e27a13c 100644 --- a/crates/web/src/handlers/csp.rs +++ b/crates/web/src/handlers/csp.rs @@ -58,7 +58,7 @@ pub async fn csp_middleware(mut req: Request, next: Next) -> Response { 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); + let nonce_value = format!("'nonce-{nonce}'"); header.push_str(&nonce_value); #[cfg(debug_assertions)] { diff --git a/crates/web/src/handlers/images.rs b/crates/web/src/handlers/images.rs index 0ca3727..8a7b46d 100644 --- a/crates/web/src/handlers/images.rs +++ b/crates/web/src/handlers/images.rs @@ -54,21 +54,21 @@ pub async fn get_image( let mut transforms = Vec::::new(); let mut current_width = image_width; let mut current_height = image_height; - if let Some(width) = query.width { - if current_width > width { - // Maintaining aspect ratio - let height = (current_height as f32 * (width as f32 / current_width as f32)) as u32; - current_width = width; - current_height = height; - } + if let Some(width) = query.width + && current_width > width + { + // Maintaining aspect ratio + let height = (current_height as f32 * (width as f32 / current_width as f32)) as u32; + current_width = width; + current_height = height; } - if let Some(height) = query.height { - if current_height > height { - // Maintaining aspect ratio - let width = (current_width as f32 * (height as f32 / current_height as f32)) as u32; - current_width = width; - current_height = height; - } + if let Some(height) = query.height + && current_height > height + { + // Maintaining aspect ratio + let width = (current_width as f32 * (height as f32 / current_height as f32)) as u32; + current_width = width; + current_height = height; } if current_width != image_width || current_height != image_height { transforms.push(Transform::Resize(current_width, current_height)); diff --git a/crates/web/src/handlers/manage.rs b/crates/web/src/handlers/manage.rs index 032587a..9c6aa08 100644 --- a/crates/web/src/handlers/manage.rs +++ b/crates/web/src/handlers/manage.rs @@ -1,6 +1,6 @@ use std::io::Cursor; -use anyhow::Result; +use anyhow::{Context, Result}; use axum::{ Form, extract::{Path, State}, @@ -12,9 +12,14 @@ use bytes::Bytes; use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, - models::{ALL_PLATFORMS, Project, ProjectInfo}, + models::{ + ALL_PLATFORMS, CachedReportFile, Project, ProjectInfo, ProjectVisibility, + project_visibility, + }, +}; +use decomp_dev_github::{ + check_for_reports, extract_github_url, graphql::RepositoryPermission, refresh_project, }; -use decomp_dev_github::{check_for_reports, graphql::RepositoryPermission, refresh_project}; use itertools::Itertools; use maud::{DOCTYPE, Markup, html}; use serde::Deserialize; @@ -82,9 +87,9 @@ pub async fn manage( fn project_fragment(info: &ProjectInfo) -> Markup { let project_path = format!("/manage/{}/{}", info.project.owner, info.project.repo); html! { - article .project { + article.project { .project-header { - h3 .project-title { + h3.project-title { a href=(project_path) { (info.project.name()) } } } @@ -123,6 +128,7 @@ async fn render_new( .sorted_by(|a, b| lexicmp::lexical_cmp(&a.1, &b.1)) .collect::>(); + let current_url = prefill.as_ref().map(|p| p.repo_url()).unwrap_or_default(); let current_name = prefill.as_ref().and_then(|p| p.name.as_deref()).unwrap_or(""); let current_short_name = prefill.as_ref().and_then(|p| p.short_name.as_deref()).unwrap_or(""); let current_platform = prefill.as_ref().and_then(|p| p.platform.as_deref()); @@ -172,11 +178,16 @@ async fn render_new( fieldset { label { "Repository" + @if current_user.super_admin { + input name="repository_url" type="url" required + aria-invalid=[message.map(|_| "true")] + value=(current_url); + } @else { + select name="repository_id" aria-invalid=[message.map(|_| "true")] { (repo_options) } + } @if let Some(message) = message { - select name="repo" aria-invalid="true" { (repo_options) } small { (message) } } @else { - select name="repo" { (repo_options) } small { "Repository must be public. Admin permissions are required." } } } @@ -225,7 +236,8 @@ fn platform_options(current_platform: Option<&str>) -> Markup { #[derive(Deserialize)] pub struct NewForm { - repo: u64, + repository_id: Option, + repository_url: Option, name: String, short_name: String, platform: String, @@ -237,27 +249,54 @@ pub async fn new_save( current_user: CurrentUser, Form(form): Form, ) -> Result { - if let Some(existing) = state.db.get_project_info_by_id(form.repo, None).await? { + let client = current_user.client(&state.config.github)?; + let (repository_id, repo) = match (form.repository_id, form.repository_url) { + (Some(id), _) => (id, None), + (None, Some(url)) => { + let Some((owner, repo)) = extract_github_url(&url) else { + return render_new( + ctx, + &state, + ¤t_user, + Some("Invalid repository URL."), + None, + ) + .await; + }; + let Ok(repo) = client.repos(owner, repo).get().await else { + return render_new(ctx, &state, ¤t_user, Some("Repository not found."), None) + .await; + }; + (repo.id.into_inner(), Some(repo)) + } + (None, None) => { + return render_new(ctx, &state, ¤t_user, Some("Repository is required."), None) + .await; + } + }; + if let Some(existing) = state.db.get_project_info_by_id(repository_id, None).await? { return Ok(Redirect::to(&format!("/{}/{}", existing.project.owner, existing.project.repo)) .into_response()); } let Some(platform) = ALL_PLATFORMS.iter().find(|p| p.to_str() == form.platform) else { return Err(AppError::Status(StatusCode::BAD_REQUEST)); }; - let client = current_user.client()?; - let repo = match client.repos_by_id(form.repo).get().await { - Ok(repo) => repo, - Err(e) => { - tracing::error!("Failed to fetch repository: {:?}", e); - return render_new( - ctx, - &state, - ¤t_user, - Some("Failed to fetch repository information."), - None, - ) - .await; - } + let repo = match repo { + Some(repo) => repo, + None => match client.repos_by_id(repository_id).get().await { + Ok(repo) => repo, + Err(e) => { + tracing::error!("Failed to fetch repository: {:?}", e); + return render_new( + ctx, + &state, + ¤t_user, + Some("Failed to fetch repository information."), + None, + ) + .await; + } + }, }; let name = form.name.trim(); @@ -271,7 +310,7 @@ pub async fn new_save( platform: Some(platform.to_str().to_string()), ..Default::default() }; - if repo.permissions.as_ref().is_none_or(|p| !p.admin) { + if !current_user.super_admin && repo.permissions.as_ref().is_none_or(|p| !p.admin) { return render_new( ctx, &state, @@ -301,15 +340,28 @@ pub async fn manage_project( current_user: CurrentUser, ctx: TemplateContext, ) -> Result { - let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? - else { + let Some(info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? else { return Err(AppError::Status(StatusCode::NOT_FOUND)); }; - if !current_user.can_manage_repo(project_info.project.id) { + if !current_user.can_manage_repo(info.project.id) { return Err(AppError::Status(StatusCode::FORBIDDEN)); } - render_manage_project(ctx, &state, &project_info, ¤t_user, Message::None).await + let report = if let (Some(version), Some(commit)) = (info.default_version(), &info.commit) { + state + .db + .get_report(&info.project.owner, &info.project.repo, &commit.sha, version) + .await + .with_context(|| { + format!( + "Failed to fetch report for {}/{} sha {} version {}", + info.project.owner, info.project.repo, commit.sha, version + ) + })? + } else { + None + }; + render_manage_project(ctx, &state, &info, report.as_ref(), ¤t_user, Message::None).await } enum Message { @@ -322,10 +374,10 @@ fn render_message(message: &Message) -> Markup { match message { Message::None => Markup::default(), Message::Info(msg) => html! { - article .info-card { (msg) } + article.info-card { (msg) } }, Message::Error(msg) => html! { - article .error-card { (msg) } + article.error-card { (msg) } }, } } @@ -334,6 +386,7 @@ async fn render_manage_project( mut ctx: TemplateContext, state: &AppState, project_info: &ProjectInfo, + latest_report: Option<&CachedReportFile>, current_user: &CurrentUser, message: Message, ) -> Result { @@ -356,6 +409,10 @@ async fn render_manage_project( None }; + // Check if the project is hidden based on matched code percentage + let visibility = + project_visibility(&project_info.project, latest_report.map(|r| &r.report.measures)); + let rendered = html! { (DOCTYPE) html lang="en" { @@ -386,8 +443,24 @@ async fn render_manage_project( main { h3 { "Edit " (project_short_name) } (render_message(&message)) + @match visibility { + ProjectVisibility::Visible => {}, + ProjectVisibility::Disabled => { + article.warning-card { "This project is disabled." } + } + ProjectVisibility::Hidden => { + article.warning-card { "This project is hidden until it has reached a minimum of 0.5% matched code." } + } + } form method="post" enctype="multipart/form-data" data-loading="Saving..." { fieldset { + label { + input name="enabled" type="checkbox" role="switch" + checked[project_info.project.enabled]; + "Enable project" + br; + small.muted { "Disabled projects will not be listed, and reports will not be fetched." } + } label { "Repository" input type="text" readonly disabled value=(project_info.project.repo_url()); @@ -489,6 +562,7 @@ pub struct ProjectForm { pub enable_pr_comments: Option, pub header_image: Option, pub clear_header_image: Option, + pub enabled: Option, } #[derive(Deserialize)] @@ -553,6 +627,7 @@ pub async fn manage_project_save( project_info.project.enable_pr_comments }, header_image_id, + enabled: form.enabled.is_some_and(|v| v == "on"), }; state.db.update_project(&project).await?; let redirect_url = format!("/{}/{}", params.owner, params.repo); @@ -565,28 +640,38 @@ pub async fn manage_project_refresh( State(state): State, current_user: CurrentUser, ) -> Result { - let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? - else { + let Some(info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? else { return Err(AppError::Status(StatusCode::NOT_FOUND)); }; - if !current_user.can_manage_repo(project_info.project.id) { + if !current_user.can_manage_repo(info.project.id) { return Err(AppError::Status(StatusCode::FORBIDDEN)); } - let client = current_user.client()?; - let message = match refresh_project( - &state.github, - &state.db, - project_info.project.id, - Some(&client), - true, - ) - .await - { - Ok(inserted_reports) => Message::Info(format!("Fetched {} new reports", inserted_reports)), - Err(e) => { - tracing::error!("Failed to refresh project: {:?}", e); - Message::Error(format!("Failed to refresh project: {}", e)) - } + let client = current_user.client(&state.config.github)?; + let message = + match refresh_project(&state.github, &state.db, info.project.id, Some(&client), true).await + { + Ok(inserted_reports) => { + Message::Info(format!("Fetched {inserted_reports} new reports")) + } + Err(e) => { + tracing::error!("Failed to refresh project: {:?}", e); + Message::Error(format!("Failed to refresh project: {e}")) + } + }; + + let report = if let (Some(version), Some(commit)) = (info.default_version(), &info.commit) { + state + .db + .get_report(&info.project.owner, &info.project.repo, &commit.sha, version) + .await + .with_context(|| { + format!( + "Failed to fetch report for {}/{} sha {} version {}", + info.project.owner, info.project.repo, commit.sha, version + ) + })? + } else { + None }; - render_manage_project(ctx, &state, &project_info, ¤t_user, message).await + render_manage_project(ctx, &state, &info, report.as_ref(), ¤t_user, message).await } diff --git a/crates/web/src/handlers/mod.rs b/crates/web/src/handlers/mod.rs index 6476a77..89f1256 100644 --- a/crates/web/src/handlers/mod.rs +++ b/crates/web/src/handlers/mod.rs @@ -89,7 +89,7 @@ fn add_charset(response: &mut Response) { return; }; if mime.type_() == mime::TEXT && mime.get_param("charset").is_none() { - existing.insert(format!("{};charset=utf-8", mime).parse().unwrap()); + existing.insert(format!("{mime};charset=utf-8").parse().unwrap()); } } diff --git a/crates/web/src/handlers/project.rs b/crates/web/src/handlers/project.rs index c25011d..465d7ed 100644 --- a/crates/web/src/handlers/project.rs +++ b/crates/web/src/handlers/project.rs @@ -10,7 +10,10 @@ use axum::{ use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, FullUri, - models::{CachedReportFile, Commit, Platform, ProjectInfo, ReportInner}, + models::{ + CachedReportFile, Commit, Platform, ProjectInfo, ProjectVisibility, ReportInner, + project_visibility, + }, util::{UrlExt, size}, }; use maud::{DOCTYPE, Markup, html}; @@ -55,13 +58,6 @@ const SORT_OPTIONS: &[SortOption] = &[ SortOption { key: "total_code", name: "Total Code" }, ]; -#[derive(Serialize, Clone)] -pub struct ProgressSection { - pub class: String, - pub percent: f32, - pub tooltip: String, -} - #[derive(serde::Serialize)] pub struct ProjectsResponse { pub projects: Vec, @@ -218,6 +214,11 @@ pub async fn get_projects( } } + // Hide projects that are disabled or don't meet visibility criteria + out.retain(|c| { + project_visibility(&c.info.project, Some(&c.measures)) == ProjectVisibility::Visible + }); + let current_sort_key = query.sort.as_deref().unwrap_or("updated"); let current_sort = SORT_OPTIONS .iter() diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 6f427c9..4bf53c1 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -10,7 +10,7 @@ use axum::{ use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, FullUri, - models::{FullReportFile, ProjectInfo}, + models::{FullReportFile, ProjectInfo, ProjectVisibility, project_visibility}, util::{UrlExt, size}, }; use decomp_dev_images::{ @@ -162,41 +162,37 @@ fn is_valid_extension(ext: &str) -> bool { fn extract_extension(params: ReportParams) -> (ReportParams, Option) { if let Some(commit) = params.commit.as_deref() { - if let Some((commit, ext)) = commit.rsplit_once('.') { - if is_valid_extension(ext) { - return ( - ReportParams { commit: Some(commit.to_string()), ..params }, - Some(ext.to_string()), - ); - } + if let Some((commit, ext)) = commit.rsplit_once('.') + && is_valid_extension(ext) + { + return ( + ReportParams { commit: Some(commit.to_string()), ..params }, + Some(ext.to_string()), + ); } } else if let Some(version) = params.version.as_deref() { - if let Some((version, ext)) = version.rsplit_once('.') { - if is_valid_extension(ext) { - return ( - ReportParams { version: Some(version.to_string()), ..params }, - Some(ext.to_string()), - ); - } + if let Some((version, ext)) = version.rsplit_once('.') + && is_valid_extension(ext) + { + return ( + ReportParams { version: Some(version.to_string()), ..params }, + Some(ext.to_string()), + ); } } else if let Some(repo) = params.repo.as_deref() { - if let Some((repo, ext)) = repo.rsplit_once('.') { - if is_valid_extension(ext) { - return ( - ReportParams { repo: Some(repo.to_string()), ..params }, - Some(ext.to_string()), - ); - } - } - } else if let Some(id) = params.id.as_deref() { - if let Some((id, ext)) = id.rsplit_once('.') { - if is_valid_extension(ext) { - return ( - ReportParams { id: Some(id.to_string()), ..params }, - Some(ext.to_string()), - ); - } + if let Some((repo, ext)) = repo.rsplit_once('.') + && is_valid_extension(ext) + { + return ( + ReportParams { repo: Some(repo.to_string()), ..params }, + Some(ext.to_string()), + ); } + } else if let Some(id) = params.id.as_deref() + && let Some((id, ext)) = id.rsplit_once('.') + && is_valid_extension(ext) + { + return (ReportParams { id: Some(id.to_string()), ..params }, Some(ext.to_string())); } (params, None) } @@ -547,14 +543,13 @@ fn apply_scope<'a>( .units .iter() .filter_map(|unit| { - if let Some(category_id) = &category_id_filter { - if !unit + if let Some(category_id) = &category_id_filter + && !unit .metadata .as_ref() .is_some_and(|m| m.progress_categories.iter().any(|c| c == category_id)) - { - return None; - } + { + return None; } let measures = unit.measures.as_ref()?; if measures.total_code == 0 { @@ -644,9 +639,14 @@ async fn render_report( format!("/manage/{}/{}", project_info.project.owner, project_info.project.repo); let can_manage = current_user.as_ref().is_some_and(|u| u.can_manage_repo(project_info.project.id)); + let default_category = project_info.project.default_category(); let is_default_version = project_info.default_version() == Some(report.version.as_str()); let is_latest_commit = project_info.next_commit.is_none(); + let is_default_category = current_category.is_none_or(|c| c.id == default_category); + let is_primary_view = + is_latest_commit && is_default_version && is_default_category && current_unit.is_none(); + let canonical_url = if is_default_version && is_latest_commit { request_url .with_path(&format!("/{}/{}", project_info.project.owner, project_info.project.repo)) @@ -691,14 +691,8 @@ async fn render_report( }) .collect::>(); - let all_url = canonical_url.query_param( - "category", - if project_info.project.default_category.as_deref().is_none_or(|c| c == "all") { - None - } else { - Some("all") - }, - ); + let all_url = canonical_url + .query_param("category", if default_category == "all" { None } else { Some("all") }); let all_category = ReportCategoryItem { id: "all", name: "All", path: all_url.path_and_query().to_string() }; let current_category = current_category @@ -754,11 +748,18 @@ async fn render_report( }; 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)) + Cow::Owned(format!("{project_short_name} ({label})")) } else { Cow::Borrowed(project_short_name) }; + // Only show visibility banners if we're on the primary view + let visibility = if is_primary_view { + project_visibility(&project_info.project, Some(measures)) + } else { + ProjectVisibility::Visible + }; + // Load blocking resources first so we don't duplicate them let header = ctx.header().await; let report_chunks = ctx.chunks("report", Load::Blocking).await; @@ -784,7 +785,7 @@ async fn render_report( 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); - @if !is_latest_commit || !is_default_version { + @if !is_primary_view { // Prevent search engines from indexing anything but the primary report meta name="robots" content="noindex"; } @@ -819,6 +820,15 @@ async fn render_report( } } main { + @match visibility { + ProjectVisibility::Visible => {}, + ProjectVisibility::Disabled => { + article.warning-card { "This project is disabled." } + } + ProjectVisibility::Hidden => { + article.warning-card { "This project is hidden until it has reached a minimum of 0.5% matched code." } + } + } .actions { details.dropdown { summary {} @@ -949,18 +959,28 @@ async fn render_report( } } } - 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(r#",window.units)"#)) - } - noscript { - style nonce=[ctx.nonce.as_deref()] { "canvas{display:none}" } - img #treemap src=(image_url) alt="Progress graph"; + @if units.is_empty() { + p.muted { + @if current_unit.is_some() { + "No function information available." + } @else { + "No unit information available." + } + } + } @else { + 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(r#",window.units)"#)) + } + noscript { + style nonce=[ctx.nonce.as_deref()] { "canvas{display:none}" } + img #treemap src=(image_url) alt="Progress graph"; + } } } } @@ -1036,7 +1056,7 @@ async fn render_history( }; 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)) + Cow::Owned(format!("{project_short_name} ({label})")) } else { Cow::Borrowed(project_short_name) }; diff --git a/crates/web/src/main.rs b/crates/web/src/main.rs index 93a4434..91f2788 100644 --- a/crates/web/src/main.rs +++ b/crates/web/src/main.rs @@ -150,7 +150,7 @@ fn app(state: AppState, session_store: impl SessionStore + Clone) -> Router { .make_span_with(MyMakeSpan { level: Level::INFO }) .on_response(DefaultOnResponse::new().level(Level::INFO)), ) - .layer(TimeoutLayer::new(Duration::from_secs(60))) + .layer(TimeoutLayer::new(Duration::from_secs(120))) .layer(CorsLayer::new().allow_methods([Method::GET]).allow_origin(cors::Any)) .layer( SessionManagerLayer::new(session_store) diff --git a/css/main.scss b/css/main.scss index d850aec..6c15605 100644 --- a/css/main.scss +++ b/css/main.scss @@ -533,9 +533,18 @@ body > footer { details.dropdown { clear: right; + + summary + ul { + max-height: 50vh; + overflow-y: auto; + } } .repo-link { float: right; margin-inline-start: 1em; } + +label:has(> input[type="checkbox"]) { + margin-bottom: var(--pico-spacing); +} diff --git a/docs/manage_new.png b/docs/manage_new.png new file mode 100644 index 0000000..d891fb2 Binary files /dev/null and b/docs/manage_new.png differ diff --git a/migrations/19_project_enabled.sql b/migrations/19_project_enabled.sql new file mode 100644 index 0000000..76f6e78 --- /dev/null +++ b/migrations/19_project_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE projects ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;