From 98701f886e282f9fb25f7d4c250ec70b18470909 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 23 May 2025 12:08:08 -0600 Subject: [PATCH] Add hero images & CLI dev tool --- .cargo/config.toml | 5 + ...3544ab883b28fe61646b61507930e76d5752.json} | 4 +- ...8951387da8f8a42623be91ca2d251c745364.json} | 12 +- ...3200bc709b34be62126cfd84308ac7d26437f.json | 12 + ...a063666e5dbc76c8bd56bf926e45f6d1333d.json} | 4 +- ...bce28ccc632a70a65bb547b627714b8cf4389.json | 12 + ...fa9d70bb07ed984a7029f4df6b70e16aebe43.json | 12 + ...80f8af3f6e2113654f3dd4a24ff5efb0855a.json} | 20 +- ...b92c86d27bc26d6db402c0039026be5ade978.json | 12 + ...1206ebf8b688a2803cfa5a3a7a8d060ec79d4.json | 12 + ...2956d174591989dec05c4cfe42eaedfebd21.json} | 12 +- ...cb6a8c6627de9d8e5f4a823c30cc52abe57d6.json | 12 + ...3b7a09e1e7601a467b0832d519a0754bf62f4.json | 38 ++ Cargo.lock | 422 +++++++++++++++++- Cargo.toml | 15 + crates/auth/src/lib.rs | 56 ++- crates/cli/Cargo.toml | 17 + crates/cli/src/cmd/changes.rs | 43 ++ crates/cli/src/cmd/mod.rs | 1 + crates/cli/src/main.rs | 124 +++++ crates/cli/src/util.rs | 6 + crates/core/src/config.rs | 2 + crates/core/src/models.rs | 5 + crates/db/src/lib.rs | 99 +++- crates/github/graphql/queries.graphql | 9 + crates/github/src/changes.rs | 22 +- crates/github/src/graphql.rs | 30 +- crates/github/src/webhook.rs | 9 +- crates/images/Cargo.toml | 2 +- crates/images/src/lib.rs | 39 +- crates/web/Cargo.toml | 7 +- crates/web/src/cron.rs | 1 + crates/web/src/handlers/common.rs | 16 +- crates/web/src/handlers/images.rs | 137 ++++++ crates/web/src/handlers/manage.rs | 89 ++-- crates/web/src/handlers/mod.rs | 10 +- crates/web/src/handlers/project.rs | 30 +- crates/web/src/handlers/report.rs | 110 +++-- crates/web/src/handlers/treemap.rs | 2 +- css/main.scss | 88 +++- migrations/18_images_table.sql | 12 + 41 files changed, 1425 insertions(+), 145 deletions(-) create mode 100644 .cargo/config.toml rename .sqlx/{query-756e4c1b9b1d5e0bf5100247a18e8998ecc0596dd1b9f388f1d6116c2209ef96.json => query-04a6f30bd4c7529178b100f327993544ab883b28fe61646b61507930e76d5752.json} (53%) rename .sqlx/{query-23a6be61c86478402eb00bb9fd64642188eec62eec0846a4a25a8110459997f1.json => query-36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364.json} (80%) create mode 100644 .sqlx/query-38ef605692e5dacc4bdea1029eb3200bc709b34be62126cfd84308ac7d26437f.json rename .sqlx/{query-0ba3585fd6ddf3c941d59b360fe177083607758c56bd1d8279c2bdb1590ae3d6.json => query-3d364e78b24eeee0a277780d8fcba063666e5dbc76c8bd56bf926e45f6d1333d.json} (54%) create mode 100644 .sqlx/query-623f7e3b25e9ef7dc62faca1010bce28ccc632a70a65bb547b627714b8cf4389.json create mode 100644 .sqlx/query-73202b4ec319f5f7f2b2bf0df5cfa9d70bb07ed984a7029f4df6b70e16aebe43.json rename .sqlx/{query-912ad9086ec8b4caa4acf3e6642cfce34ebcff88a5fe321b31a56af31e8eda83.json => query-927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a.json} (69%) create mode 100644 .sqlx/query-c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978.json create mode 100644 .sqlx/query-da8a779446519cb73f09759767a1206ebf8b688a2803cfa5a3a7a8d060ec79d4.json rename .sqlx/{query-ef595aa7d32d7d21d9721a794295a7d10d0edc60d25c1809bb94935e468873fd.json => query-e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21.json} (78%) create mode 100644 .sqlx/query-e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6.json create mode 100644 .sqlx/query-f4585da58b5c5c0d7349dd621e43b7a09e1e7601a467b0832d519a0754bf62f4.json create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/src/cmd/changes.rs create mode 100644 crates/cli/src/cmd/mod.rs create mode 100644 crates/cli/src/main.rs create mode 100644 crates/cli/src/util.rs create mode 100644 crates/web/src/handlers/images.rs create mode 100644 migrations/18_images_table.sql diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..f6db0a5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# Force rust-analyzer to use prepared sqlx data +# sqlx within a workspace crate doesn't resolve the db path +# https://github.com/launchbadge/sqlx/issues/3099 +[env] +SQLX_OFFLINE = "true" diff --git a/.sqlx/query-756e4c1b9b1d5e0bf5100247a18e8998ecc0596dd1b9f388f1d6116c2209ef96.json b/.sqlx/query-04a6f30bd4c7529178b100f327993544ab883b28fe61646b61507930e76d5752.json similarity index 53% rename from .sqlx/query-756e4c1b9b1d5e0bf5100247a18e8998ecc0596dd1b9f388f1d6116c2209ef96.json rename to .sqlx/query-04a6f30bd4c7529178b100f327993544ab883b28fe61646b61507930e76d5752.json index 5d16ba9..bca593d 100644 --- a/.sqlx/query-756e4c1b9b1d5e0bf5100247a18e8998ecc0596dd1b9f388f1d6116c2209ef96.json +++ b/.sqlx/query-04a6f30bd4c7529178b100f327993544ab883b28fe61646b61507930e76d5752.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n UPDATE projects\n SET owner = ?, repo = ?\n WHERE id = ?\n ", + "query": "\n UPDATE projects\n SET owner = ?, repo = ?, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "756e4c1b9b1d5e0bf5100247a18e8998ecc0596dd1b9f388f1d6116c2209ef96" + "hash": "04a6f30bd4c7529178b100f327993544ab883b28fe61646b61507930e76d5752" } diff --git a/.sqlx/query-23a6be61c86478402eb00bb9fd64642188eec62eec0846a4a25a8110459997f1.json b/.sqlx/query-36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364.json similarity index 80% rename from .sqlx/query-23a6be61c86478402eb00bb9fd64642188eec62eec0846a4a25a8110459997f1.json rename to .sqlx/query-36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364.json index f436972..171f246 100644 --- a/.sqlx/query-23a6be61c86478402eb00bb9fd64642188eec62eec0846a4a25a8110459997f1.json +++ b/.sqlx/query-36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364.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\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\n FROM projects\n WHERE id = ?\n ", "describe": { "columns": [ { @@ -47,6 +47,11 @@ "name": "enable_pr_comments", "ordinal": 8, "type_info": "Bool" + }, + { + "name": "header_image_id", + "ordinal": 9, + "type_info": "Blob" } ], "parameters": { @@ -61,8 +66,9 @@ true, true, true, - false + false, + true ] }, - "hash": "23a6be61c86478402eb00bb9fd64642188eec62eec0846a4a25a8110459997f1" + "hash": "36261e887badca80fc0c19ac0a368951387da8f8a42623be91ca2d251c745364" } diff --git a/.sqlx/query-38ef605692e5dacc4bdea1029eb3200bc709b34be62126cfd84308ac7d26437f.json b/.sqlx/query-38ef605692e5dacc4bdea1029eb3200bc709b34be62126cfd84308ac7d26437f.json new file mode 100644 index 0000000..b1dd8ef --- /dev/null +++ b/.sqlx/query-38ef605692e5dacc4bdea1029eb3200bc709b34be62126cfd84308ac7d26437f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM images\n WHERE id NOT IN (\n SELECT header_image_id FROM projects\n WHERE header_image_id IS NOT NULL\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "38ef605692e5dacc4bdea1029eb3200bc709b34be62126cfd84308ac7d26437f" +} diff --git a/.sqlx/query-0ba3585fd6ddf3c941d59b360fe177083607758c56bd1d8279c2bdb1590ae3d6.json b/.sqlx/query-3d364e78b24eeee0a277780d8fcba063666e5dbc76c8bd56bf926e45f6d1333d.json similarity index 54% rename from .sqlx/query-0ba3585fd6ddf3c941d59b360fe177083607758c56bd1d8279c2bdb1590ae3d6.json rename to .sqlx/query-3d364e78b24eeee0a277780d8fcba063666e5dbc76c8bd56bf926e45f6d1333d.json index d3144de..9416896 100644 --- a/.sqlx/query-0ba3585fd6ddf3c941d59b360fe177083607758c56bd1d8279c2bdb1590ae3d6.json +++ b/.sqlx/query-3d364e78b24eeee0a277780d8fcba063666e5dbc76c8bd56bf926e45f6d1333d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n UPDATE projects\n SET workflow_id = ?\n WHERE id = ?\n ", + "query": "\n UPDATE projects\n SET workflow_id = ?, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "0ba3585fd6ddf3c941d59b360fe177083607758c56bd1d8279c2bdb1590ae3d6" + "hash": "3d364e78b24eeee0a277780d8fcba063666e5dbc76c8bd56bf926e45f6d1333d" } diff --git a/.sqlx/query-623f7e3b25e9ef7dc62faca1010bce28ccc632a70a65bb547b627714b8cf4389.json b/.sqlx/query-623f7e3b25e9ef7dc62faca1010bce28ccc632a70a65bb547b627714b8cf4389.json new file mode 100644 index 0000000..45348a0 --- /dev/null +++ b/.sqlx/query-623f7e3b25e9ef7dc62faca1010bce28ccc632a70a65bb547b627714b8cf4389.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM reports\n WHERE project_id NOT IN (SELECT id FROM projects)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "623f7e3b25e9ef7dc62faca1010bce28ccc632a70a65bb547b627714b8cf4389" +} diff --git a/.sqlx/query-73202b4ec319f5f7f2b2bf0df5cfa9d70bb07ed984a7029f4df6b70e16aebe43.json b/.sqlx/query-73202b4ec319f5f7f2b2bf0df5cfa9d70bb07ed984a7029f4df6b70e16aebe43.json new file mode 100644 index 0000000..d8ab71b --- /dev/null +++ b/.sqlx/query-73202b4ec319f5f7f2b2bf0df5cfa9d70bb07ed984a7029f4df6b70e16aebe43.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE projects\n SET header_image_id = ?, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "73202b4ec319f5f7f2b2bf0df5cfa9d70bb07ed984a7029f4df6b70e16aebe43" +} diff --git a/.sqlx/query-912ad9086ec8b4caa4acf3e6642cfce34ebcff88a5fe321b31a56af31e8eda83.json b/.sqlx/query-927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a.json similarity index 69% rename from .sqlx/query-912ad9086ec8b4caa4acf3e6642cfce34ebcff88a5fe321b31a56af31e8eda83.json rename to .sqlx/query-927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a.json index 9c12b17..696cb6b 100644 --- a/.sqlx/query-912ad9086ec8b4caa4acf3e6642cfce34ebcff88a5fe321b31a56af31e8eda83.json +++ b/.sqlx/query-927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a.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 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 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": [ { @@ -54,24 +54,29 @@ "type_info": "Bool" }, { - "name": "git_commit", + "name": "header_image_id", "ordinal": 10, - "type_info": "Text" + "type_info": "Blob" }, { - "name": "git_commit_message", + "name": "git_commit", "ordinal": 11, "type_info": "Text" }, { - "name": "timestamp: time::OffsetDateTime", + "name": "git_commit_message", "ordinal": 12, "type_info": "Text" }, { - "name": "versions", + "name": "timestamp: time::OffsetDateTime", "ordinal": 13, "type_info": "Text" + }, + { + "name": "versions", + "ordinal": 14, + "type_info": "Text" } ], "parameters": { @@ -91,8 +96,9 @@ true, true, true, + true, true ] }, - "hash": "912ad9086ec8b4caa4acf3e6642cfce34ebcff88a5fe321b31a56af31e8eda83" + "hash": "927b8e137db05ddd2996e9b2d04b80f8af3f6e2113654f3dd4a24ff5efb0855a" } diff --git a/.sqlx/query-c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978.json b/.sqlx/query-c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978.json new file mode 100644 index 0000000..31a80c6 --- /dev/null +++ b/.sqlx/query-c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978.json @@ -0,0 +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 ", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "c6e1281fcba9403a87315a09483b92c86d27bc26d6db402c0039026be5ade978" +} diff --git a/.sqlx/query-da8a779446519cb73f09759767a1206ebf8b688a2803cfa5a3a7a8d060ec79d4.json b/.sqlx/query-da8a779446519cb73f09759767a1206ebf8b688a2803cfa5a3a7a8d060ec79d4.json new file mode 100644 index 0000000..fc05a09 --- /dev/null +++ b/.sqlx/query-da8a779446519cb73f09759767a1206ebf8b688a2803cfa5a3a7a8d060ec79d4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO images (id, mime_type, width, height, data, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n ON CONFLICT (id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "da8a779446519cb73f09759767a1206ebf8b688a2803cfa5a3a7a8d060ec79d4" +} diff --git a/.sqlx/query-ef595aa7d32d7d21d9721a794295a7d10d0edc60d25c1809bb94935e468873fd.json b/.sqlx/query-e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21.json similarity index 78% rename from .sqlx/query-ef595aa7d32d7d21d9721a794295a7d10d0edc60d25c1809bb94935e468873fd.json rename to .sqlx/query-e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21.json index 753237b..c7416d5 100644 --- a/.sqlx/query-ef595aa7d32d7d21d9721a794295a7d10d0edc60d25c1809bb94935e468873fd.json +++ b/.sqlx/query-e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21.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\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\n FROM projects\n WHERE owner = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE\n ", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "name": "enable_pr_comments", "ordinal": 9, "type_info": "Bool" + }, + { + "name": "header_image_id", + "ordinal": 10, + "type_info": "Blob" } ], "parameters": { @@ -67,8 +72,9 @@ true, true, true, - false + false, + true ] }, - "hash": "ef595aa7d32d7d21d9721a794295a7d10d0edc60d25c1809bb94935e468873fd" + "hash": "e679ddb4ba6c9a8e4e460b2654312956d174591989dec05c4cfe42eaedfebd21" } diff --git a/.sqlx/query-e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6.json b/.sqlx/query-e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6.json new file mode 100644 index 0000000..e2ba8e1 --- /dev/null +++ b/.sqlx/query-e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6.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, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "e9bddc3272d50fb0ca0c4375deecb6a8c6627de9d8e5f4a823c30cc52abe57d6" +} diff --git a/.sqlx/query-f4585da58b5c5c0d7349dd621e43b7a09e1e7601a467b0832d519a0754bf62f4.json b/.sqlx/query-f4585da58b5c5c0d7349dd621e43b7a09e1e7601a467b0832d519a0754bf62f4.json new file mode 100644 index 0000000..16c231f --- /dev/null +++ b/.sqlx/query-f4585da58b5c5c0d7349dd621e43b7a09e1e7601a467b0832d519a0754bf62f4.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT mime_type, width, height, data\n FROM images\n WHERE id = ?\n ", + "describe": { + "columns": [ + { + "name": "mime_type", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "width", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "height", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "data", + "ordinal": 3, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f4585da58b5c5c0d7349dd621e43b7a09e1e7601a467b0832d519a0754bf62f4" +} diff --git a/Cargo.lock b/Cargo.lock index 0588c70..3d335ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "0.7.20" @@ -118,6 +129,27 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "argp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7409aa6f1dd8464eac2e56cf538e1e5f7f79678caa32f198d214a3db8d5075c1" +dependencies = [ + "argp_derive", +] + +[[package]] +name = "argp_derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9b949411282939e3f7d8923127e3f18aa474b46da4e8bb0ddf2cb8c81f963a" +dependencies = [ + "proc-macro2", + "pulldown-cmark", + "quote", + "syn 2.0.100", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -232,6 +264,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -278,6 +311,41 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "axum_typed_multipart" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8986dff40423605627d31ebd1cc903d39001b6370c0c7337b02a8093ba484521" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum_typed_multipart_macros", + "bytes", + "chrono", + "futures-core", + "futures-util", + "rust_decimal", + "tempfile", + "thiserror 2.0.12", + "tokio", + "uuid", +] + +[[package]] +name = "axum_typed_multipart_macros" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58e854cb9d7f3ea26701fbbbb38f6cec456d8129f86c1b91c7171711a5d30d7" +dependencies = [ + "darling", + "heck 0.5.0", + "proc-macro-error2", + "quote", + "syn 2.0.100", + "ubyte", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -370,6 +438,18 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake3" version = "1.8.2" @@ -392,6 +472,29 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "brotli" version = "7.0.0" @@ -431,6 +534,28 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.22.0" @@ -677,6 +802,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.100", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.100", +] + [[package]] name = "data-url" version = "0.3.1" @@ -703,6 +863,22 @@ dependencies = [ "url", ] +[[package]] +name = "decomp-dev-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "argp", + "decomp-dev-core", + "decomp-dev-github", + "objdiff-core", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", + "typed-path", +] + [[package]] name = "decomp-dev-core" version = "0.1.0" @@ -780,6 +956,7 @@ dependencies = [ "streemap", "tokio", "tracing", + "webp", ] [[package]] @@ -788,12 +965,14 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum_typed_multipart", "bytes", "decomp-dev-auth", "decomp-dev-core", "decomp-dev-db", "decomp-dev-github", "decomp-dev-images", + "hex", "image", "itertools 0.14.0", "lexicmp", @@ -1093,6 +1272,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -1216,6 +1401,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -1259,6 +1453,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "graphql-introspection-query" version = "0.2.0" @@ -1346,6 +1546,15 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -1363,7 +1572,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1688,6 +1897,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -1761,7 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1961,6 +2176,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -2160,6 +2385,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multimap" version = "0.10.0" @@ -2682,6 +2924,37 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2774,6 +3047,38 @@ dependencies = [ "prost", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.9.0", + "getopts", + "memchr", + "unicase", +] + [[package]] name = "qoi" version = "0.4.1" @@ -2858,6 +3163,12 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -3040,6 +3351,15 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.15" @@ -3126,6 +3446,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rmp" version = "0.8.14" @@ -3174,6 +3523,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust_decimal" +version = "1.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -3506,12 +3871,18 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" source = "git+https://github.com/encounter/similar.git?branch=no_std#3568aba9173fad9f66740344dbdc6bc4c1970aab" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -3647,7 +4018,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.15.2", "hashlink", "indexmap", "log", @@ -3846,6 +4217,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -3923,6 +4300,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4451,6 +4834,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" + [[package]] name = "unicase" version = "2.8.1" @@ -4508,6 +4897,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4738,6 +5133,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f53152f51fb5af0c08484c33d16cca96175881d1f3dec068c23b31a158c2d99" +dependencies = [ + "image", + "libwebp-sys", +] + [[package]] name = "weezl" version = "0.1.8" @@ -5161,6 +5566,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xmlwriter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b86332b..c41ae11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/auth", + "crates/cli", "crates/core", "crates/db", "crates/github", @@ -36,3 +37,17 @@ url = { version = "2.5", features = ["serde"] } [profile.dev.package.sqlx-macros] opt-level = 3 +[profile.dev.package.image] +opt-level = 3 +[profile.dev.package.webp] +opt-level = 3 +[profile.dev.package.image-webp] +opt-level = 3 +[profile.dev.package.rgb] +opt-level = 3 +[profile.dev.package.png] +opt-level = 3 +[profile.dev.package.zune-core] +opt-level = 3 +[profile.dev.package.zune-jpeg] +opt-level = 3 diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index 795b98a..65b5b84 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -7,7 +7,9 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use decomp_dev_core::{AppError, config::GitHubConfig}; -use decomp_dev_github::graphql::{CurrentUserResponse, RepositoryPermission, fetch_current_user}; +use decomp_dev_github::graphql::{ + CurrentUserResponse, RepositoryPermission, fetch_current_user, fetch_simple_current_user, +}; use octocrab::{Octocrab, models::Author}; use rand::{TryRngCore, rngs::OsRng}; use time::{Duration, UtcDateTime}; @@ -46,6 +48,8 @@ pub type Profile = Author; pub struct CurrentUser { pub oauth: StoredOAuth, pub data: CurrentUserResponse, + #[serde(skip, default)] + pub super_admin: bool, } impl CurrentUser { @@ -57,6 +61,9 @@ impl CurrentUser { } pub fn permissions_for_repo(&self, id: u64) -> RepositoryPermission { + if self.super_admin { + return RepositoryPermission::Admin; + } self.data .repositories .iter() @@ -185,8 +192,17 @@ async fn fetch_access_token(config: &GitHubConfig, code: &str) -> Result FromRequestParts for CurrentUser @@ -262,7 +279,7 @@ where ) -> Result, Self::Rejection> { let session = Session::from_request_parts(parts, state).await?; let config = GitHubConfig::from_ref(state); - let user = match session.get::(CURRENT_USER).await { + let mut user = match session.get::(CURRENT_USER).await { Ok(Some(user)) => user, Ok(None) => return Ok(None), Err(e) => { @@ -270,6 +287,35 @@ where return Ok(None); } }; + // Refresh user data if the ID is 0 + if user.data.id == 0 { + match user.client() { + Ok(client) => match fetch_simple_current_user(&client).await { + Ok(data) => { + user.data = + CurrentUserResponse { repositories: user.data.repositories, ..data }; + if let Err(e) = session.insert(CURRENT_USER, user.clone()).await { + tracing::error!("Failed to insert user into session: {}", e); + } + } + Err(e) => { + tracing::error!("Failed to fetch user data: {}", e); + if let Err(e) = session.remove_value(CURRENT_USER).await { + tracing::error!("Failed to remove user from session: {}", e); + } + return Ok(None); + } + }, + Err(e) => { + tracing::error!("Failed to create GitHub client: {}", e); + if let Err(e) = session.remove_value(CURRENT_USER).await { + tracing::error!("Failed to remove user from session: {}", e); + } + return Ok(None); + } + } + } + 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 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..aee3e44 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "decomp-dev-cli" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +argp = "0.4" +decomp-dev-core = { path = "../core" } +decomp-dev-github = { path = "../github" } +objdiff-core.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing.workspace = true +typed-path = "0.11" diff --git a/crates/cli/src/cmd/changes.rs b/crates/cli/src/cmd/changes.rs new file mode 100644 index 0000000..ca7eb8d --- /dev/null +++ b/crates/cli/src/cmd/changes.rs @@ -0,0 +1,43 @@ +use anyhow::{Context, Result}; +use argp::FromArgs; +use decomp_dev_github::changes::{generate_changes, generate_comment}; +use objdiff_core::bindings::report::Report; +use typed_path::Utf8NativePathBuf; + +use crate::util::native_path; + +#[derive(FromArgs, PartialEq, Eq, Debug)] +/// Calculate changes between two reports and print a markdown summary. +#[argp(subcommand, name = "changes")] +pub struct Args { + #[argp(option, short = '1', from_str_fn(native_path))] + /// previous report file + previous: Utf8NativePathBuf, + #[argp(option, short = '2', from_str_fn(native_path))] + /// current report file + current: Utf8NativePathBuf, + #[argp(option, short = 'o', from_str_fn(native_path))] + /// write markdown changes to output file + output: Option, +} + +pub fn run(args: Args) -> Result<()> { + let previous_report_data = std::fs::read(args.previous.with_platform_encoding()) + .with_context(|| format!("Failed to read {}", args.previous))?; + let current_report_data = std::fs::read(args.current.with_platform_encoding()) + .with_context(|| format!("Failed to read {}", args.current))?; + let previous_report = Report::parse(previous_report_data.as_slice()) + .with_context(|| format!("Failed to parse {}", args.previous))?; + let current_report = Report::parse(current_report_data.as_slice()) + .with_context(|| format!("Failed to parse {}", args.current))?; + let changes = generate_changes(&previous_report, ¤t_report) + .context("Failed to generate changes")?; + 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))?; + } else { + println!("{}", comment); + } + Ok(()) +} diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs new file mode 100644 index 0000000..7aaa634 --- /dev/null +++ b/crates/cli/src/cmd/mod.rs @@ -0,0 +1 @@ +pub mod changes; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..1e0dbce --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,124 @@ +mod cmd; +mod util; + +use std::{env, ffi::OsStr, fmt::Display, path::PathBuf, process::exit, str::FromStr}; + +use anyhow::Error; +use argp::{FromArgValue, FromArgs, HelpStyle}; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl FromStr for LogLevel { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(match s { + "error" => Self::Error, + "warn" => Self::Warn, + "info" => Self::Info, + "debug" => Self::Debug, + "trace" => Self::Trace, + _ => return Err(()), + }) + } +} + +impl Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + LogLevel::Error => "error", + LogLevel::Warn => "warn", + LogLevel::Info => "info", + LogLevel::Debug => "debug", + LogLevel::Trace => "trace", + }) + } +} + +impl FromArgValue for LogLevel { + fn from_arg_value(value: &OsStr) -> Result { + String::from_arg_value(value) + .and_then(|s| Self::from_str(&s).map_err(|_| "Invalid log level".to_string())) + } +} + +#[derive(FromArgs, Debug)] +/// Yet another GameCube/Wii decompilation toolkit. +struct TopLevel { + #[argp(subcommand)] + command: SubCommand, + #[argp(option, short = 'C')] + /// Change working directory. + chdir: Option, + #[argp(option, short = 'L')] + /// Minimum logging level. (Default: info) + /// Possible values: error, warn, info, debug, trace + log_level: Option, + /// Print version information and exit. + #[argp(switch, short = 'V')] + #[allow(dead_code)] + version: bool, + /// Disable color output. (env: NO_COLOR) + #[argp(switch)] + no_color: bool, +} + +#[derive(FromArgs, Debug)] +#[argp(subcommand)] +enum SubCommand { + Changes(cmd::changes::Args), + // Import(cmd::import::Args), +} + +fn main() { + let args: TopLevel = argp::parse_args_or_exit(&HelpStyle::default()); + + let use_colors = !args.no_color; + let format = + tracing_subscriber::fmt::format().with_ansi(use_colors).with_target(false).without_time(); + let builder = tracing_subscriber::fmt().event_format(format); + if let Some(level) = args.log_level { + builder + .with_max_level(match level { + LogLevel::Error => LevelFilter::ERROR, + LogLevel::Warn => LevelFilter::WARN, + LogLevel::Info => LevelFilter::INFO, + LogLevel::Debug => LevelFilter::DEBUG, + LogLevel::Trace => LevelFilter::TRACE, + }) + .init(); + } else { + builder + .with_env_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .init(); + } + + let mut result = Ok(()); + if let Some(dir) = &args.chdir { + result = env::set_current_dir(dir).map_err(|e| { + Error::new(e) + .context(format!("Failed to change working directory to '{}'", dir.display())) + }); + } + result = result.and_then(|_| match args.command { + SubCommand::Changes(c_args) => cmd::changes::run(c_args), + // SubCommand::Import(c_args) => cmd::import::run(c_args), + }); + if let Err(e) = result { + eprintln!("Failed: {e:?}"); + exit(1); + } +} diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs new file mode 100644 index 0000000..ce52caa --- /dev/null +++ b/crates/cli/src/util.rs @@ -0,0 +1,6 @@ +use typed_path::Utf8NativePathBuf; + +// For argp::FromArgs +pub fn native_path(value: &str) -> Result { + Ok(Utf8NativePathBuf::from(value)) +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index a4fa003..624fbc2 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -23,6 +23,8 @@ pub struct GitHubConfig { pub token: String, pub app: Option, pub oauth: Option, + #[serde(default)] + pub super_admin_ids: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/core/src/models.rs b/crates/core/src/models.rs index 82fbf69..26c7e07 100644 --- a/crates/core/src/models.rs +++ b/crates/core/src/models.rs @@ -4,6 +4,9 @@ use objdiff_core::bindings::report::{Measures, Report, ReportCategory, ReportUni use serde::Serialize; use time::UtcDateTime; +// BLAKE3 hash of the image data +pub type ImageId = [u8; 32]; + #[derive(Debug, Clone, Eq, PartialEq, Serialize)] pub struct Project { pub id: u64, @@ -16,6 +19,7 @@ pub struct Project { pub platform: Option, pub workflow_id: Option, pub enable_pr_comments: bool, + pub header_image_id: Option, } impl Default for Project { @@ -31,6 +35,7 @@ impl Default for Project { platform: None, workflow_id: None, enable_pr_comments: true, + header_image_id: None, } } } diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 9d24a9b..381ef00 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -6,7 +6,7 @@ use decomp_dev_core::{ config::DbConfig, models::{ CachedReport, CachedReportFile, Commit, FrogressMapping, FullReport, FullReportFile, - Project, ProjectInfo, UnitKey, + ImageId, Project, ProjectInfo, UnitKey, }, }; use futures_util::TryStreamExt; @@ -358,7 +358,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 + SELECT id AS "id!", owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id FROM projects WHERE owner = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE "#, @@ -379,6 +379,7 @@ impl Database { platform: row.platform, 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()), }, None => return Ok(None), }; @@ -394,7 +395,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 + SELECT owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, header_image_id FROM projects WHERE id = ? "#, @@ -414,6 +415,7 @@ impl Database { platform: row.platform, 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()), }, None => return Ok(None), }; @@ -547,6 +549,7 @@ impl Database { platform, workflow_id, enable_pr_comments AS "enable_pr_comments!", + header_image_id, git_commit, git_commit_message, MAX(timestamp) AS "timestamp: time::OffsetDateTime", @@ -579,6 +582,7 @@ impl Database { platform: row.platform, 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()), }, commit: match (row.git_commit, row.timestamp) { (Some(sha), Some(timestamp)) => Some(Commit { @@ -956,10 +960,11 @@ impl Database { pub async fn update_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#" UPDATE projects - SET owner = ?, repo = ?, name = ?, short_name = ?, default_category = ?, default_version = ?, platform = ?, workflow_id = ?, enable_pr_comments = ?, updated_at = CURRENT_TIMESTAMP + SET owner = ?, repo = ?, name = ?, short_name = ?, default_category = ?, default_version = ?, platform = ?, workflow_id = ?, enable_pr_comments = ?, header_image_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "#, project.owner, @@ -971,6 +976,7 @@ impl Database { project.platform, project.workflow_id, project.enable_pr_comments, + header_image_id, project_id, ) .execute(&mut *conn) @@ -1001,6 +1007,91 @@ impl Database { .await?; Ok(()) } + + pub async fn create_image( + &self, + mime_type: &str, + width: u32, + height: u32, + data: &[u8], + ) -> Result { + let mut conn = self.pool.acquire().await?; + let key: ImageId = blake3::hash(data).into(); + let key_db = &key[..]; + sqlx::query!( + r#" + INSERT INTO images (id, mime_type, width, height, data, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO NOTHING + "#, + key_db, + mime_type, + width, + height, + data, + ) + .execute(&mut *conn) + .await?; + Ok(key) + } + + pub async fn get_image(&self, id: ImageId) -> Result)>> { + let mut conn = self.pool.acquire().await?; + let id_db = &id[..]; + let row = sqlx::query!( + r#" + SELECT mime_type, width, height, data + FROM images + WHERE id = ? + "#, + id_db, + ) + .fetch_optional(&mut *conn) + .await?; + Ok(row.map(|row| (row.mime_type, row.width as u32, row.height as u32, row.data))) + } + + pub async fn update_project_header(&self, project_id: u64, image_id: ImageId) -> Result<()> { + let mut conn = self.pool.acquire().await?; + let image_id = &image_id[..]; + let project_id_db = project_id as i64; + sqlx::query!( + r#" + UPDATE projects + SET header_image_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "#, + image_id, + project_id_db, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + + pub async fn cleanup_images(&self) -> Result<()> { + let mut conn = self.pool.acquire().await?; + conn.execute("PRAGMA foreign_keys = OFF").await?; + let mut tx = conn.begin().await?; + let deleted_images = sqlx::query!( + r#" + DELETE FROM images + WHERE id NOT IN ( + SELECT header_image_id FROM projects + WHERE header_image_id IS NOT NULL + ) + "#, + ) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + conn.execute("PRAGMA foreign_keys = ON").await?; + if deleted_images > 0 { + tracing::info!("Deleted {} orphaned images", deleted_images); + } + Ok(()) + } } thread_local! { diff --git a/crates/github/graphql/queries.graphql b/crates/github/graphql/queries.graphql index c6aa2d7..c7e7ff1 100644 --- a/crates/github/graphql/queries.graphql +++ b/crates/github/graphql/queries.graphql @@ -1,5 +1,6 @@ query ViewerQuery($after: String) { viewer { + databaseId login url repositories( @@ -19,6 +20,14 @@ query ViewerQuery($after: String) { } } +query SimpleViewerQuery { + viewer { + databaseId + login + url + } +} + fragment repositoryFields on Repository { __typename databaseId diff --git a/crates/github/src/changes.rs b/crates/github/src/changes.rs index 77a3889..c5faa7a 100644 --- a/crates/github/src/changes.rs +++ b/crates/github/src/changes.rs @@ -1,13 +1,11 @@ use std::cmp::Ordering; use anyhow::Result; -use decomp_dev_core::models::{Commit, FullReportFile}; +use decomp_dev_core::models::Commit; use objdiff_core::bindings::report::{ ChangeItem, ChangeItemInfo, ChangeUnit, Changes, Report, ReportItem, ReportUnit, }; -use crate::ProcessArtifactResult; - pub fn generate_changes(previous: &Report, current: &Report) -> Result { let mut changes = Changes { from: previous.measures, to: current.measures, units: vec![] }; for prev_unit in &previous.units { @@ -165,20 +163,22 @@ fn measure_line_simple(name: &str, from: u64, to: u64) -> String { } pub fn generate_comment( - from: &FullReportFile, - to: &ProcessArtifactResult, - to_commit: &Commit, + from: &Report, + to: &Report, + version: Option<&str>, + from_commit: Option<&Commit>, + to_commit: Option<&Commit>, changes: Changes, ) -> String { let mut comment = format!( "### Report for {} ({} - {})\n\n", - to.version, - &from.commit.sha[..7], - &to_commit.sha[..7] + version.unwrap_or("unknown"), + from_commit.map_or("", |c| &c.sha[..7]), + to_commit.map_or("", |c| &c.sha[..7]) ); let mut measure_written = false; - let from_measures = from.report.measures; - let to_measures = to.report.measures.unwrap_or_default(); + let from_measures = from.measures.unwrap_or_default(); + let to_measures = to.measures.unwrap_or_default(); if from_measures.total_code != to_measures.total_code { comment.push_str(&measure_line_bytes( "Total code", diff --git a/crates/github/src/graphql.rs b/crates/github/src/graphql.rs index b089c2c..e951749 100644 --- a/crates/github/src/graphql.rs +++ b/crates/github/src/graphql.rs @@ -13,8 +13,18 @@ type URI = String; )] pub struct ViewerQuery; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "graphql/schema.graphql", + query_path = "graphql/queries.graphql", + response_derives = "Debug, Clone" +)] +pub struct SimpleViewerQuery; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CurrentUserResponse { + #[serde(default)] + pub id: u64, pub login: String, pub url: String, pub repositories: Vec, @@ -67,14 +77,19 @@ async fn run_query( } pub async fn fetch_current_user(client: &Octocrab) -> Result { - let mut result = - CurrentUserResponse { login: String::new(), url: String::new(), repositories: Vec::new() }; + let mut result = CurrentUserResponse { + id: 0, + login: String::new(), + url: String::new(), + repositories: Vec::new(), + }; let mut after = None; loop { let data = run_query::(client, viewer_query::Variables { after: after.clone() }) .await? .viewer; + result.id = data.database_id.map(|x| x as u64).unwrap_or(u64::MAX); result.login = data.login; result.url = data.url; for repo in data.repositories.nodes.unwrap_or_default().into_iter().flatten() { @@ -101,3 +116,14 @@ pub async fn fetch_current_user(client: &Octocrab) -> Result Result { + let data = + run_query::(client, simple_viewer_query::Variables {}).await?.viewer; + Ok(CurrentUserResponse { + id: data.database_id.map(|x| x as u64).unwrap_or(u64::MAX), + login: data.login, + url: data.url, + repositories: Vec::new(), + }) +} diff --git a/crates/github/src/webhook.rs b/crates/github/src/webhook.rs index 255cc18..af57df7 100644 --- a/crates/github/src/webhook.rs +++ b/crates/github/src/webhook.rs @@ -314,7 +314,14 @@ async fn handle_workflow_run_completed( let report_file = state.db.upgrade_report(&cached_report).await?; let report = report_file.report.flatten(); let changes = generate_changes(&report, &artifact.report)?; - let comment_text = generate_comment(&report_file, artifact, &commit, changes); + let comment_text = generate_comment( + &report, + &artifact.report, + Some(&report_file.version), + Some(&report_file.commit), + Some(&commit), + changes, + ); let existing_comment = existing_comments .items .iter() diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index 42ee22f..b23e869 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -19,4 +19,4 @@ serde.workspace = true streemap = "0.1" tokio.workspace = true tracing.workspace = true - +webp = "0.3" diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 75befb8..5fa4ff9 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -2,18 +2,20 @@ pub mod badge; pub mod svg; pub mod treemap; -use std::str::FromStr; +use std::{io::Cursor, str::FromStr}; use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; use decomp_dev_core::{AppError, util::join_normalized}; +use image::{ + DynamicImage, ExtendedColorType, ImageError, ImageFormat, error::UnsupportedErrorKind, +}; use mime::Mime; pub fn image_mime_from_ext(ext: &str) -> Option { - image::ImageFormat::from_extension(ext) - .map(|format| Mime::from_str(format.to_mime_type()).unwrap()) + ImageFormat::from_extension(ext).map(|format| Mime::from_str(format.to_mime_type()).unwrap()) } pub async fn get_og() -> Result { @@ -21,10 +23,39 @@ pub async fn get_og() -> Result { let svg_src = tokio::fs::read_to_string(&path) .await .map_err(|_| AppError::Status(StatusCode::NOT_FOUND))?; - let data = svg::render_image(&svg_src, image::ImageFormat::Png)?; + let data = svg::render_image(&svg_src, ImageFormat::Png)?; Ok(( [(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=3600")], data, ) .into_response()) } + +pub fn encode_image(image: &DynamicImage, format: ImageFormat) -> Result, anyhow::Error> { + match format { + ImageFormat::WebP => { + let encoder = webp::Encoder::from_image(image) + .map_err(|s| anyhow::anyhow!("Failed to create WebP encoder: {}", s))?; + let memory = encoder.encode(75.0); + Ok(memory.to_vec()) + } + format => { + let mut buf = Cursor::new(Vec::new()); + match image.write_to(&mut buf, format) { + Ok(()) => {} + Err(ImageError::Unsupported(e)) + if matches!( + e.kind(), + UnsupportedErrorKind::Color(ExtendedColorType::Rgba8) + ) => + { + // Convert to RGB and try again + let image = image.to_rgb8(); + image.write_to(&mut buf, format)?; + } + Err(e) => return Err(anyhow::Error::from(e).context("Failed to encode image")), + } + Ok(buf.into_inner()) + } + } +} diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index a4e8112..1f5465f 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -3,15 +3,19 @@ name = "decomp-dev-web" version.workspace = true edition.workspace = true publish = false +default-run = "decomp-dev-web" [dependencies] anyhow.workspace = true axum.workspace = true +axum_typed_multipart = "0.16" decomp-dev-auth = { path = "../auth" } decomp-dev-core = { path = "../core" } decomp-dev-db = { path = "../db" } decomp-dev-github = { path = "../github" } decomp-dev-images = { path = "../images" } +hex.workspace = true +image.workspace = true itertools = "0.14" lexicmp = "0.2" maud.workspace = true @@ -35,9 +39,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing.workspace = true url.workspace = true -# image -image.workspace = true - # proto bytes = "1.7" prost = "0.13" diff --git a/crates/web/src/cron.rs b/crates/web/src/cron.rs index c67aa83..ae348d4 100644 --- a/crates/web/src/cron.rs +++ b/crates/web/src/cron.rs @@ -40,6 +40,7 @@ pub async fn create( let state = state.clone(); Box::pin(async move { state.db.cleanup_report_units().await.expect("Failed to clean up report units"); + state.db.cleanup_images().await.expect("Failed to clean up images"); }) })?) .await?; diff --git a/crates/web/src/handlers/common.rs b/crates/web/src/handlers/common.rs index c05090a..cbe73a3 100644 --- a/crates/web/src/handlers/common.rs +++ b/crates/web/src/handlers/common.rs @@ -259,29 +259,29 @@ impl TemplateContext { let elapsed = self.start.elapsed(); html! { footer { - span class="section" { - small class="muted" { "Generated in " (elapsed.as_millis()) "ms" } + span.section { + small.muted { "Generated in " (elapsed.as_millis()) "ms" } " | " - small class="muted" { + small.muted { a href="https://github.com/encounter/decomp.dev" { "Code" } " by " a href="https://github.com/encounter" { "@encounter" } } } @if let Some(user) = current_user { - span class="section" { - small class="muted" { + span.section { + small.muted { "Logged in as " a href=(user.data.url) { "@" (user.data.login) } } " | " form action="/logout" method="post" { - input type="submit" class="button outline secondary" value="Logout"; + input.button.outline.secondary type="submit" value="Logout"; } } } @else { - span class="section" { - small class="muted" { + span.section { + small.muted { a href="/login" { "Login" } } } diff --git a/crates/web/src/handlers/images.rs b/crates/web/src/handlers/images.rs new file mode 100644 index 0000000..0ca3727 --- /dev/null +++ b/crates/web/src/handlers/images.rs @@ -0,0 +1,137 @@ +use std::{io::Cursor, str::FromStr}; + +use anyhow::anyhow; +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use decomp_dev_core::{AppError, models::ImageId}; +use decomp_dev_images::encode_image; +use image::{ImageFormat, ImageReader}; + +use crate::{AppState, handlers::parse_accept}; + +#[derive(serde::Deserialize)] +pub struct ImageParams { + pub id: String, +} + +#[derive(serde::Deserialize)] +pub struct ImageQuery { + #[serde(alias = "w")] + pub width: Option, + #[serde(alias = "h")] + pub height: Option, + #[serde(alias = "b")] + pub blur: Option, +} + +enum Transform { + Resize(u32, u32), + Blur(f32), +} + +pub async fn get_image( + Path(params): Path, + Query(query): Query, + State(state): State, + headers: HeaderMap, +) -> Result { + let (params, ext) = extract_extension(params); + let acceptable = parse_accept(&headers, ext.as_deref()); + if acceptable.is_empty() { + return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)); + } + + let id = hex::decode(¶ms.id).map_err(|_| AppError::Status(StatusCode::NOT_FOUND))?; + let id: ImageId = + id.as_slice().try_into().map_err(|_| AppError::Status(StatusCode::NOT_FOUND))?; + let (mime_str, image_width, image_height, data) = + state.db.get_image(id).await?.ok_or_else(|| AppError::Status(StatusCode::NOT_FOUND))?; + let mime_type = mime::Mime::from_str(&mime_str)?; + + 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(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 current_width != image_width || current_height != image_height { + transforms.push(Transform::Resize(current_width, current_height)); + } + if let Some(blur) = query.blur { + transforms.push(Transform::Blur(blur)); + } + + let mut out_headers = HeaderMap::new(); + out_headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + if ext.is_none() { + out_headers.insert(header::VARY, HeaderValue::from_static("accept")); + } + + // If no transformations are needed, and the mime type is acceptable, return the original image + let orig_acceptable = acceptable.iter().any(|m| { + let essence = m.essence_str(); + essence == mime_type || essence == mime::IMAGE_STAR || essence == mime::STAR_STAR + }); + if transforms.is_empty() && orig_acceptable { + out_headers.insert(header::CONTENT_TYPE, mime_str.parse()?); + return Ok((out_headers, data).into_response()); + } + + let format = ImageFormat::from_mime_type(&mime_str) + .ok_or_else(|| anyhow!("Invalid image mime type: {}", mime_str))?; + let mut image = ImageReader::with_format(Cursor::new(&data[..]), format).decode()?; + for transform in transforms { + match transform { + Transform::Resize(width, height) => { + image = image.resize_exact(width, height, image::imageops::FilterType::Lanczos3); + } + Transform::Blur(blur) => { + image = image.blur(blur); + } + } + } + + let mut out_format = None; + for mime in acceptable { + if mime.type_() != mime::IMAGE { + continue; + } + // If a specific image format is requested, use that + if let Some(format) = ImageFormat::from_mime_type(mime.essence_str()) { + out_format = Some(format); + break; + } + } + // Otherwise, use WebP as the default + let out_format = out_format.unwrap_or(ImageFormat::WebP); + let encoded = encode_image(&image, out_format)?; + + out_headers.insert(header::CONTENT_TYPE, out_format.to_mime_type().parse()?); + Ok((out_headers, encoded).into_response()) +} + +fn extract_extension(params: ImageParams) -> (ImageParams, Option) { + if let Some((id, ext)) = params.id.rsplit_once('.') { + return (ImageParams { id: id.to_string(), ..params }, Some(ext.to_string())); + } + (params, None) +} diff --git a/crates/web/src/handlers/manage.rs b/crates/web/src/handlers/manage.rs index 26ad599..032587a 100644 --- a/crates/web/src/handlers/manage.rs +++ b/crates/web/src/handlers/manage.rs @@ -1,3 +1,5 @@ +use std::io::Cursor; + use anyhow::Result; use axum::{ Form, @@ -5,6 +7,8 @@ use axum::{ http::StatusCode, response::{IntoResponse, Redirect, Response}, }; +use axum_typed_multipart::{TryFromMultipart, TypedMultipart}; +use bytes::Bytes; use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, @@ -382,7 +386,7 @@ async fn render_manage_project( main { h3 { "Edit " (project_short_name) } (render_message(&message)) - form method="post" data-loading="Saving..." { + form method="post" enctype="multipart/form-data" data-loading="Saving..." { fieldset { label { "Repository" @@ -408,11 +412,8 @@ async fn render_manage_project( "Default version" select name="default_version" { @for version in &project_info.report_versions { - @if default_version == Some(version.as_str()) { - option value=(version) selected { (version) } - } @else { - option value=(version) { (version) } - } + @let selected = default_version == Some(version.as_str()); + option value=(version) selected[selected] { (version) } } } } @@ -422,22 +423,33 @@ async fn render_manage_project( small { "The GitHub Actions workflow that contains report artifacts." } } label { - @if installation_id.is_some() { - @if project_info.project.enable_pr_comments { - input name="enable_pr_comments" type="checkbox" role="switch" checked; - } @else { - input name="enable_pr_comments" type="checkbox" role="switch"; - } - "Enable PR comments" - } @else { - @if project_info.project.enable_pr_comments { - input name="enable_pr_comments" type="checkbox" role="switch" disabled checked; - } @else { - input name="enable_pr_comments" type="checkbox" role="switch" disabled; - } - "Enable PR comments (requires GitHub App installation)" + input name="enable_pr_comments" type="checkbox" role="switch" + disabled[installation_id.is_none()] + checked[project_info.project.enable_pr_comments]; + "Enable PR comments" + @if installation_id.is_none() { + " (requires GitHub App installation)" } } + hr; + label { + "Hero image " + small { "(optional)" } + input name="header_image" type="file" accept="image/*"; + small { + "Image should be at least 1024×256. A common size is 1920×620." + br; + "Upload the best quality/resolution available; the image will be resized to fit." + br; + a href="https://www.steamgriddb.com/heroes" target="_blank" { "SteamGridDB" } + " has a large collection of images." + } + } + label { + input name="clear_header_image" type="checkbox" role="switch" + disabled[project_info.project.header_image_id.is_none()]; + "Clear the current hero image" + } } button type="submit" { "Save" } } @@ -467,24 +479,16 @@ async fn render_manage_project( Ok((ctx, rendered).into_response()) } -fn form_bool<'de, D>(deserializer: D) -> Result -where D: serde::Deserializer<'de> { - match <&str>::deserialize(deserializer)? { - "on" => Ok(true), - "off" => Ok(false), - other => Err(serde::de::Error::unknown_variant(other, &["on", "off"])), - } -} - -#[derive(Deserialize)] +#[derive(Debug, TryFromMultipart)] pub struct ProjectForm { pub name: String, pub short_name: String, pub platform: String, pub default_version: Option, pub workflow_id: String, - #[serde(default, deserialize_with = "form_bool")] - pub enable_pr_comments: bool, + pub enable_pr_comments: Option, + pub header_image: Option, + pub clear_header_image: Option, } #[derive(Deserialize)] @@ -497,7 +501,7 @@ pub async fn manage_project_save( Path(params): Path, State(state): State, current_user: CurrentUser, - Form(form): Form, + TypedMultipart(form): TypedMultipart, ) -> Result { let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? else { @@ -506,6 +510,22 @@ pub async fn manage_project_save( if !current_user.can_manage_repo(project_info.project.id) { return Err(AppError::Status(StatusCode::FORBIDDEN)); } + if !ALL_PLATFORMS.iter().any(|p| p.to_str() == form.platform) { + return Err(AppError::Status(StatusCode::BAD_REQUEST)); + }; + + let mut header_image_id = project_info.project.header_image_id; + if let Some(header_image) = form.header_image.filter(|b| !b.is_empty()) { + let format = image::guess_format(&header_image)?; + let (width, height) = + image::ImageReader::with_format(Cursor::new(&header_image[..]), format) + .into_dimensions()?; + let id = state.db.create_image(format.to_mime_type(), width, height, &header_image).await?; + header_image_id = Some(id); + } else if form.clear_header_image.is_some_and(|v| v == "on") { + header_image_id = None; + } + let installation_id = if let Some(installations) = &state.github.installations { let installations = installations.lock().await; installations.repo_to_installation.get(&project_info.project.id).cloned() @@ -528,10 +548,11 @@ pub async fn manage_project_save( workflow_id: (!workflow_id.is_empty()).then_some(workflow_id.to_string()), // If there's no installation ID, use the existing value enable_pr_comments: if installation_id.is_some() { - form.enable_pr_comments + form.enable_pr_comments.is_some_and(|v| v == "on") } else { project_info.project.enable_pr_comments }, + header_image_id, }; state.db.update_project(&project).await?; let redirect_url = format!("/{}/{}", params.owner, params.repo); diff --git a/crates/web/src/handlers/mod.rs b/crates/web/src/handlers/mod.rs index e4e3a5e..6476a77 100644 --- a/crates/web/src/handlers/mod.rs +++ b/crates/web/src/handlers/mod.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use axum::{ Router, - extract::Request, + extract::{DefaultBodyLimit, Request}, http::{HeaderMap, HeaderValue, header, header::Entry}, response::Response, routing::{get, post}, @@ -18,6 +18,7 @@ mod api; mod auth; mod common; pub mod csp; +mod images; mod manage; mod project; mod report; @@ -62,13 +63,18 @@ pub fn build_router() -> Router { .route("/manage/new", get(manage::new)) .route("/manage/new", post(manage::new_save)) .route("/manage/{owner}/{repo}", get(manage::manage_project)) - .route("/manage/{owner}/{repo}", post(manage::manage_project_save)) + .route( + "/manage/{owner}/{repo}", + post(manage::manage_project_save) + .layer(DefaultBodyLimit::max(50 * 1000 * 1000 /* 50MB */)), + ) .route("/manage/{owner}/{repo}/refresh", post(manage::manage_project_refresh)) .route("/og.png", get(decomp_dev_images::get_og)) .route("/", get(project::get_projects)) .route("/projects", get(project::get_projects)) .route("/projects.json", get(project::get_projects)) .route("/projects/{id}", get(report::get_report)) + .route("/images/{id}", get(images::get_image)) .route("/{owner}/{repo}", get(report::get_report)) .route("/{owner}/{repo}/{version}", get(report::get_report)) .route("/{owner}/{repo}/{version}/{commit}", get(report::get_report)) diff --git a/crates/web/src/handlers/project.rs b/crates/web/src/handlers/project.rs index f6a3322..c25011d 100644 --- a/crates/web/src/handlers/project.rs +++ b/crates/web/src/handlers/project.rs @@ -318,8 +318,8 @@ async fn render_project( li { a href="/projects" { "Projects" } } - li class="md" { - details class="dropdown" { + li.md { + details.dropdown { summary { (current_sort.name) } ul { @for option in SORT_OPTIONS { @@ -335,7 +335,7 @@ async fn render_project( } (nav_links()) } - div class="title-group" { + .title-group { h3 { "Progress Reports" } blockquote { "Matching decompilation projects attempt to write source code (C, C++)" @@ -348,7 +348,7 @@ async fn render_project( } } main { - details class="dropdown sm" { + details.dropdown.sm { summary { (current_sort.name) } ul { @for option in SORT_OPTIONS { @@ -384,15 +384,25 @@ fn project_fragment( let project_path = canonical_url.with_path(&format!("/{}/{}", project.owner, project.repo)); let commit_url = format!("https://github.com/{}/{}/commit/{}", project.owner, project.repo, commit.sha); + let header_image_id = project.header_image_id.map(hex::encode); + const HEADER_QUERY: &str = "?w=1024&h=256"; html! { - article class="project" { - div class="project-header" { - h3 class="project-title" { - a href=(project_path) { (project.name()) } + article.project { + a.project-link href=(project_path) aria-label="View project" {} + @if let Some(header_image_id) = header_image_id { + .project-image-container { + picture { + source srcset=(format!("/images/{header_image_id}.avif{HEADER_QUERY}")) type="image/avif"; + source srcset=(format!("/images/{header_image_id}.webp{HEADER_QUERY}")) type="image/webp"; + img.project-image src=(format!("/images/{header_image_id}.jpg{HEADER_QUERY}")) alt=(project.name()) loading="lazy"; + } } + } + .project-header { + h3.project-title { (project.name()) } @if let Some(platform) = &project.platform { @let platform_name = Platform::from_str(platform).map(|p| p.name()).unwrap_or(platform); - span class=(format!("platform-icon icon-{platform}")) title=(platform_name) {} + span.platform-icon.(format!("icon-{platform}")) title=(platform_name) {} } } h6 { @@ -412,7 +422,7 @@ fn project_fragment( } } (ctx.code_progress) - small class="muted" { + small.muted { span title=(date(commit.timestamp)) { "Updated " (timeago(commit.timestamp)) } " in commit " a href=(commit_url) target="_blank" { (commit.sha[..7]) } diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 4554605..6f427c9 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -644,10 +644,27 @@ 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 canonical_url = request_url.with_path(&format!( - "/{}/{}/{}/{}", - project_info.project.owner, project_info.project.repo, report.version, report.commit.sha - )); + + let is_default_version = project_info.default_version() == Some(report.version.as_str()); + let is_latest_commit = project_info.next_commit.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)) + } else if is_latest_commit { + request_url.with_path(&format!( + "/{}/{}/{}", + project_info.project.owner, project_info.project.repo, report.version + )) + } else { + request_url.with_path(&format!( + "/{}/{}/{}/{}", + project_info.project.owner, + project_info.project.repo, + report.version, + report.commit.sha + )) + }; + let image_url = canonical_url .with_path(&format!("{}.png", canonical_url.path())) .query_param("mode", Some("report")); @@ -656,10 +673,20 @@ async fn render_report( .report_versions .iter() .map(|version| { - let version_url = request_url.with_path(&format!( - "/{}/{}/{}/{}", - project_info.project.owner, project_info.project.repo, version, report.commit.sha - )); + let version_url = if is_latest_commit { + request_url.with_path(&format!( + "/{}/{}/{}", + project_info.project.owner, project_info.project.repo, version + )) + } else { + request_url.with_path(&format!( + "/{}/{}/{}/{}", + project_info.project.owner, + project_info.project.repo, + version, + report.commit.sha + )) + }; ReportTemplateVersion { id: version, path: version_url.path_and_query().to_string() } }) .collect::>(); @@ -746,11 +773,21 @@ async fn render_report( (ctx.chunks("main", Load::Deferred).await) (ctx.chunks("report", Load::Preload).await) link rel="canonical" href=(canonical_url); + @if let Some(prev_commit_path) = prev_commit_path.as_deref() { + link rel="prev" href=(prev_commit_path); + } + @if let Some(next_commit_path) = next_commit_path.as_deref() { + link rel="next" href=(next_commit_path); + } meta name="description" content=(format!("Decompilation progress report for {project_name}")); 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); + @if !is_latest_commit || !is_default_version { + // Prevent search engines from indexing anything but the primary report + meta name="robots" content="noindex"; + } } body { header { @@ -765,8 +802,8 @@ async fn render_report( li { a href=(project_base_path) { (project_short_name) } } - li class="md" { - details class="dropdown" { + li.md { + details.dropdown { summary { (report.version) } ul { @for version in &versions { @@ -783,42 +820,47 @@ async fn render_report( } main { .actions { - details class="dropdown" { + details.dropdown { summary {} ul dir="rtl" { li { a href=(format!("/api?project={}", project_info.project.id)) { "API " - span .icon-code { " " } + span.icon-code { " " } } } li { a href=(project_history_path) { "History " - span .icon-chart-line { " " } + span.icon-chart-line { " " } } } @if can_manage { li { a href=(project_manage_path) { "Manage " - span .icon-cog { " " } + span.icon-cog { " " } } } } } } } - h3 { (format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent)) } + a.secondary.outline.repo-link role="button" + href=(project_info.project.repo_url()) target="_blank" { + span.icon-github { " " } + span.md { "Repository" } + } + h3.report-title { (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)) } + h4.muted { (format!("{:.2}% fully linked", measures.complete_code_percent)) } } @if let Some(source_file_url) = source_file_url { - h4 class="muted" { + h4.muted { a href=(source_file_url) target="_blank" { "View source file" } } } - details class="dropdown sm" { + details.dropdown.sm { summary { (report.version) } ul { @for version in &versions { @@ -829,20 +871,20 @@ async fn render_report( } } @if measures.total_code > 0 { - h6 class="report-header" { + h6 { "Code " - small class="muted" { "(" (size(measures.total_code)) ")" } + small.muted { "(" (size(measures.total_code)) ")" } } (ctx.code_progress_sections(&measures)) } @if measures.total_data > 0 { - h6 class="report-header" { + h6 { "Data " - small class="muted" { "(" (size(measures.total_data)) ")" } + small.muted { "(" (size(measures.total_data)) ")" } } (ctx.data_progress_sections(&measures)) } - h6 class="report-header" { "Commit" } + h6 { "Commit" } div { @if let Some(message) = commit_message { pre { @@ -855,47 +897,47 @@ 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) { + a.outline.secondary role="button" href=(prev_commit_path) { span .icon-left-open .md {} " Previous" } } @else { - button disabled class="outline secondary" { + button.outline.secondary disabled { 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) { + a.outline.secondary role="button" href=(next_commit_path) { "Next " span .icon-right-open .md {} } } @else { - button disabled class="outline secondary" { + button.outline.secondary disabled { "Next " span .icon-right-open .md {} } } @if let Some(latest_commit_path) = latest_commit_path { - a role="button" class="primary" href=(latest_commit_path) { + a.primary role="button" href=(latest_commit_path) { "Latest" } } @else { - button disabled class="primary" { + button.primary disabled { "Latest" } } } } @if current_unit.is_some() { - h6 class="report-header" { "Functions" } + h6 { "Functions" } div role="group" { a role="button" href=(units_path) { "Back to units" } } } @else { - h6 class="report-header" { "Units" } + h6 { "Units" } @if categories.len() > 1 { - details class="dropdown" { + details.dropdown { summary { (current_category.name) } ul { @for category in &categories { @@ -1041,7 +1083,7 @@ async fn render_history( } main { h3 { "History for " (project_short_name_with_label) } - details class="dropdown" title="Version" { + details.dropdown title="Version" { summary { (report.version) } ul { @for version in &versions { @@ -1052,7 +1094,7 @@ async fn render_history( } } @if current_unit.is_none() && categories.len() > 1 { - details class="dropdown" title="Category" { + details.dropdown title="Category" { summary { (current_category.name) } ul { @for category in &categories { diff --git a/crates/web/src/handlers/treemap.rs b/crates/web/src/handlers/treemap.rs index 874bb51..891fc6b 100644 --- a/crates/web/src/handlers/treemap.rs +++ b/crates/web/src/handlers/treemap.rs @@ -11,7 +11,7 @@ pub fn render_svg(units: &[ReportTemplateUnit], w: u32, h: u32) -> String { svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox=(format!("0 0 {w} {h}")) { style { ".unit { stroke: #000; stroke-width: 1; }" } @for unit in units { - rect class="unit" + rect.unit width=(format!("{}%", unit.w * 100.0)) height=(format!("{}%", unit.h * 100.0)) x=(format!("{}%", unit.x * 100.0)) diff --git a/css/main.scss b/css/main.scss index 0c42a85..d850aec 100644 --- a/css/main.scss +++ b/css/main.scss @@ -71,7 +71,7 @@ $breakpoints: ( "forms/checkbox-radio-switch": true, "forms/input-color": true, "forms/input-date": false, - "forms/input-file": false, + "forms/input-file": true, "forms/input-range": false, "forms/input-search": false, @@ -103,8 +103,9 @@ $breakpoints: ( :root:not([data-theme=dark]) { --pico-code-kbd-color: #000; --pico-code-kbd-background-color: #fff; - --progress-background-color: #{$slate-100}; + --progress-background-color: #{rgba($slate-100, 0.75)}; --progress-stripe-color: rgba(0, 0, 0, 0.15); + --text-shadow: none; } @mixin pico-theme-dark { @@ -113,8 +114,11 @@ $breakpoints: ( --pico-dropdown-background-color: #1b1f29; --pico-code-kbd-color: #fff; --pico-code-kbd-background-color: #333; - --progress-background-color: #{$slate-800}; + --progress-background-color: #{rgba($slate-800, 0.75)}; --progress-stripe-color: rgba(255, 255, 255, 0.15); + --text-shadow: 0px 1px 2px var(--text-shadow-color, oklab(0% 0 0/.2)), + 0px 3px 2px var(--text-shadow-color, oklab(0% 0 0/.2)), + 0px 4px 8px var(--text-shadow-color, oklab(0% 0 0/.2)); } [data-theme=dark] { @@ -158,7 +162,7 @@ $breakpoints: ( .icon-cog:before { content: '\e801'; } .icon-theme-light-dark:before { content: '\e802'; } .icon-chart-line:before { content: '\e803'; } -.icon-github-circled:before { content: '\e804'; } +.icon-github:before { content: '\e804'; } .icon-left-open:before { content: '\e805'; } .icon-right-open:before { content: '\e806'; } .icon-copy:before { content: '\e807'; } @@ -234,6 +238,8 @@ $progress-height: 2rem; border-radius: var(--pico-border-radius); background-color: var(--progress-background-color); margin-bottom: var(--pico-spacing); + pointer-events: auto; + box-shadow: var(--pico-box-shadow); .progress-section.striped { background-image: linear-gradient( @@ -319,6 +325,69 @@ $unit-size: 0.75rem; --tooltip-color: var(--pico-contrast-inverse); } +.project { + position: relative; + z-index: 0; + pointer-events: none; + border-radius: var(--pico-border-radius); + contain: content; + overflow: hidden; + transition: box-shadow var(--pico-transition); + text-shadow: var(--text-shadow); + + a, [title] { + cursor: pointer; + pointer-events: auto; + user-select: none; + } + + // Move the focus ring to the outer container, otherwise + // contain: content or overflow: hidden will clip it + &:has(> .project-link:focus) { + box-shadow: 0 0 0 var(--pico-outline-width) var(--pico-primary-focus); + } +} + +.project-link { + position: absolute; + inset: 0; + z-index: -1; + + // See above + &:focus { + box-shadow: none; + } +} + +.project-image-container { + position: absolute; + inset: 0; + z-index: -1; + overflow: hidden; + contain: strict; +} + +.project-image { + object-fit: cover; + width: 100%; + height: 100%; + opacity: 0.1; + transition: all .5s ease; + filter: blur(3px); + transform: scale(1); + will-change: transform; +} + +.project { + &:hover, &:focus-within { + .project-image { + transform: scale(1.02); + opacity: 0.3; + filter: blur(0); + } + } +} + .project-header { display: flex; align-items: center; @@ -328,10 +397,12 @@ $unit-size: 0.75rem; .project-title { flex: 1 1 auto; margin-bottom: 0; + text-wrap: balance; } .platform-icon { font-size: 1.5rem; + align-self: start; } } @@ -351,6 +422,10 @@ $unit-size: 0.75rem; margin-top: calc(var(--pico-block-spacing-vertical) * 2); } +blockquote { + text-wrap: balance; +} + body > footer { display: flex; justify-content: space-between; @@ -459,3 +534,8 @@ body > footer { details.dropdown { clear: right; } + +.repo-link { + float: right; + margin-inline-start: 1em; +} diff --git a/migrations/18_images_table.sql b/migrations/18_images_table.sql new file mode 100644 index 0000000..d1edd55 --- /dev/null +++ b/migrations/18_images_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE images +( + id BLOB PRIMARY KEY, -- BLAKE3 hash of the image data (256 bits) + mime_type TEXT NOT NULL, -- MIME type of the image (e.g., image/png, image/jpeg) + width INTEGER NOT NULL, -- Width of the image in pixels + height INTEGER NOT NULL, -- Height of the image in pixels + data BLOB NOT NULL, -- Image data + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +ALTER TABLE projects ADD COLUMN header_image_id BLOB;