Merge pull request 'impl webhook route' (#1) from webhook into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -12,8 +12,18 @@
|
|||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
"SHELL": "/bin/bash"
|
"SHELL": "/bin/bash"
|
||||||
},
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml", "fill-labs.dependi"],
|
||||||
|
"settings": {
|
||||||
|
"[rust]": {
|
||||||
|
"editor.defaultFormatter": "rust-lang.rust-analyzer",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind",
|
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind",
|
||||||
"workspaceFolder": "/workspaces/herald",
|
"workspaceFolder": "/workspaces/herald",
|
||||||
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
|
|
||||||
"appPort": [3000]
|
"appPort": [3000]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+107
@@ -100,6 +100,15 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -145,6 +154,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmov"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -155,6 +170,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -181,6 +202,45 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctutils"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||||
|
dependencies = [
|
||||||
|
"cmov",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
"ctutils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -353,13 +413,34 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bytes",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -405,6 +486,15 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -1183,6 +1273,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1469,6 +1570,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|||||||
@@ -11,3 +11,9 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
hmac = "0.13"
|
||||||
|
sha2 = "0.11"
|
||||||
|
hex = "0.4"
|
||||||
|
subtle = "2.6"
|
||||||
|
bytes = "1.11"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
{
|
||||||
|
"action": "created",
|
||||||
|
"issue": {
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://gitea.example.com/api/v1/repos/username/repo-name/issues/1",
|
||||||
|
"html_url": "https://gitea.example.com/username/repo-name/pulls/1",
|
||||||
|
"number": 1,
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "username",
|
||||||
|
"login_name": "",
|
||||||
|
"source_id": 0,
|
||||||
|
"full_name": "",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar_url": "https://gitea.example.com/avatars/aabbccdd",
|
||||||
|
"html_url": "https://gitea.example.com/username",
|
||||||
|
"language": "en-US",
|
||||||
|
"is_admin": true,
|
||||||
|
"last_login": "2026-01-01T00:00:00+02:00",
|
||||||
|
"created": "2025-01-01T00:00:00+02:00",
|
||||||
|
"restricted": false,
|
||||||
|
"active": true,
|
||||||
|
"prohibit_login": false,
|
||||||
|
"location": "",
|
||||||
|
"website": "",
|
||||||
|
"description": "",
|
||||||
|
"visibility": "public",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"starred_repos_count": 0,
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"original_author": "",
|
||||||
|
"original_author_id": 0,
|
||||||
|
"title": "impl webhook route",
|
||||||
|
"body": "",
|
||||||
|
"ref": "",
|
||||||
|
"assets": [],
|
||||||
|
"labels": [],
|
||||||
|
"milestone": null,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": null,
|
||||||
|
"state": "open",
|
||||||
|
"is_locked": false,
|
||||||
|
"comments": 1,
|
||||||
|
"created_at": "2026-01-01T00:00:00+02:00",
|
||||||
|
"updated_at": "2026-01-01T00:00:00+02:00",
|
||||||
|
"closed_at": null,
|
||||||
|
"due_date": null,
|
||||||
|
"time_estimate": 0,
|
||||||
|
"pull_request": {
|
||||||
|
"merged": false,
|
||||||
|
"merged_at": null,
|
||||||
|
"draft": false,
|
||||||
|
"html_url": "https://gitea.example.com/username/repo-name/pulls/1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"id": 8,
|
||||||
|
"name": "repo-name",
|
||||||
|
"owner": "username",
|
||||||
|
"full_name": "username/repo-name"
|
||||||
|
},
|
||||||
|
"pin_order": 0,
|
||||||
|
"content_version": 0
|
||||||
|
},
|
||||||
|
"pull_request": {
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://gitea.example.com/username/repo-name/pulls/1",
|
||||||
|
"number": 1,
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "username",
|
||||||
|
"login_name": "",
|
||||||
|
"source_id": 0,
|
||||||
|
"full_name": "",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar_url": "https://gitea.example.com/avatars/aabbccdd",
|
||||||
|
"html_url": "https://gitea.example.com/username",
|
||||||
|
"language": "en-US",
|
||||||
|
"is_admin": true,
|
||||||
|
"last_login": "2026-01-01T00:00:00+02:00",
|
||||||
|
"created": "2025-01-01T00:00:00+02:00",
|
||||||
|
"restricted": false,
|
||||||
|
"active": true,
|
||||||
|
"prohibit_login": false,
|
||||||
|
"location": "",
|
||||||
|
"website": "",
|
||||||
|
"description": "",
|
||||||
|
"visibility": "public",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"starred_repos_count": 0,
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"title": "impl webhook route",
|
||||||
|
"body": "",
|
||||||
|
"labels": [],
|
||||||
|
"milestone": null,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": [],
|
||||||
|
"requested_reviewers": [],
|
||||||
|
"requested_reviewers_teams": [],
|
||||||
|
"state": "open",
|
||||||
|
"draft": false,
|
||||||
|
"is_locked": false,
|
||||||
|
"comments": 1,
|
||||||
|
"review_comments": 0,
|
||||||
|
"additions": 3,
|
||||||
|
"deletions": 3,
|
||||||
|
"changed_files": 2,
|
||||||
|
"html_url": "https://gitea.example.com/username/repo-name/pulls/1",
|
||||||
|
"diff_url": "https://gitea.example.com/username/repo-name/pulls/1.diff",
|
||||||
|
"patch_url": "https://gitea.example.com/username/repo-name/pulls/1.patch",
|
||||||
|
"mergeable": true,
|
||||||
|
"merged": false,
|
||||||
|
"merged_at": null,
|
||||||
|
"merge_commit_sha": null,
|
||||||
|
"merged_by": null,
|
||||||
|
"allow_maintainer_edit": false,
|
||||||
|
"base": {
|
||||||
|
"label": "main",
|
||||||
|
"ref": "main",
|
||||||
|
"sha": "aabbccdd00112233445566778899aabbccdd0011",
|
||||||
|
"repo_id": 8,
|
||||||
|
"repo": {
|
||||||
|
"id": 8,
|
||||||
|
"owner": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "username",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar_url": "https://gitea.example.com/avatars/aabbccdd",
|
||||||
|
"html_url": "https://gitea.example.com/username",
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"name": "repo-name",
|
||||||
|
"full_name": "username/repo-name",
|
||||||
|
"description": "A self-hosted Gitea bot.",
|
||||||
|
"html_url": "https://gitea.example.com/username/repo-name",
|
||||||
|
"url": "https://gitea.example.com/api/v1/repos/username/repo-name",
|
||||||
|
"ssh_url": "git@gitea.example.com:username/repo-name.git",
|
||||||
|
"clone_url": "https://gitea.example.com/username/repo-name.git",
|
||||||
|
"default_branch": "main"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"head": {
|
||||||
|
"label": "webhook",
|
||||||
|
"ref": "webhook",
|
||||||
|
"sha": "eeff00112233445566778899aabbccddeeff0011",
|
||||||
|
"repo_id": 8,
|
||||||
|
"repo": {
|
||||||
|
"id": 8,
|
||||||
|
"owner": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "username",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar_url": "https://gitea.example.com/avatars/aabbccdd",
|
||||||
|
"html_url": "https://gitea.example.com/username",
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"name": "repo-name",
|
||||||
|
"full_name": "username/repo-name",
|
||||||
|
"description": "A self-hosted Gitea bot.",
|
||||||
|
"html_url": "https://gitea.example.com/username/repo-name",
|
||||||
|
"url": "https://gitea.example.com/api/v1/repos/username/repo-name",
|
||||||
|
"ssh_url": "git@gitea.example.com:username/repo-name.git",
|
||||||
|
"clone_url": "https://gitea.example.com/username/repo-name.git",
|
||||||
|
"default_branch": "main"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"merge_base": "aabbccdd00112233445566778899aabbccdd0011",
|
||||||
|
"due_date": null,
|
||||||
|
"created_at": "2026-01-01T00:00:00+02:00",
|
||||||
|
"updated_at": "2026-01-01T00:00:00+02:00",
|
||||||
|
"closed_at": null
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"id": 3,
|
||||||
|
"html_url": "https://gitea.example.com/username/repo-name/pulls/1#issuecomment-3",
|
||||||
|
"pull_request_url": "https://gitea.example.com/username/repo-name/pulls/1",
|
||||||
|
"issue_url": "",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "username",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar_url": "https://gitea.example.com/avatars/aabbccdd",
|
||||||
|
"html_url": "https://gitea.example.com/username",
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"original_author": "",
|
||||||
|
"original_author_id": 0,
|
||||||
|
"body": "Test comment",
|
||||||
|
"assets": [],
|
||||||
|
"created_at": "2026-01-01T00:00:00+02:00",
|
||||||
|
"updated_at": "2026-01-01T00:00:00+02:00"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"id": 8,
|
||||||
|
"owner": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "username",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar_url": "https://gitea.example.com/avatars/aabbccdd",
|
||||||
|
"html_url": "https://gitea.example.com/username",
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"name": "repo-name",
|
||||||
|
"full_name": "username/repo-name",
|
||||||
|
"description": "A self-hosted Gitea bot.",
|
||||||
|
"html_url": "https://gitea.example.com/username/repo-name",
|
||||||
|
"url": "https://gitea.example.com/api/v1/repos/username/repo-name",
|
||||||
|
"ssh_url": "git@gitea.example.com:username/repo-name.git",
|
||||||
|
"clone_url": "https://gitea.example.com/username/repo-name.git",
|
||||||
|
"default_branch": "main"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "username",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar_url": "https://gitea.example.com/avatars/aabbccdd",
|
||||||
|
"html_url": "https://gitea.example.com/username",
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"is_pull": true
|
||||||
|
}
|
||||||
+93
-9
@@ -1,17 +1,101 @@
|
|||||||
use axum::Router;
|
use axum::body::{Bytes, to_bytes};
|
||||||
use axum::routing::get;
|
use axum::extract::{FromRef, FromRequest};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use hmac::{Hmac, KeyInit, Mac};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
use crate::env;
|
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use crate::gitea::WebhookType;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn start_api(config: env::EnvConfig) -> anyhow::Result<()> {
|
pub async fn start(app_state: AppState) -> anyhow::Result<()> {
|
||||||
let app = Router::new().route("/", get(root));
|
let http_port = app_state.config.http_port;
|
||||||
let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.http_port)).await?;
|
|
||||||
|
|
||||||
axum::serve(listerner, app)
|
let app = Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/webhook", post(webhook))
|
||||||
|
.with_state(app_state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?;
|
||||||
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!(e))
|
.map_err(anyhow::Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root() -> &'static str {
|
async fn root() -> &'static str {
|
||||||
"Hello, World!"
|
"Hi, i'm Herald :)"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn webhook(WebhookExtract(wb): WebhookExtract) -> Result<Response, AppError> {
|
||||||
|
Ok("lol".into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WebhookExtract(pub WebhookType);
|
||||||
|
|
||||||
|
impl<S> FromRequest<S> for WebhookExtract
|
||||||
|
where
|
||||||
|
AppState: FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let app_state = AppState::from_ref(state);
|
||||||
|
let headers = req.headers();
|
||||||
|
|
||||||
|
let sig_header = extract_header(GITEA_SIG_HEADER_NAME, headers)?;
|
||||||
|
let type_header = extract_header(GITEA_EVENT_TYPE_HEADER_NAME, headers)?;
|
||||||
|
let body_bytes = read_body(req.into_body()).await?;
|
||||||
|
|
||||||
|
verify_signature(
|
||||||
|
app_state.config.webhook_secret.as_bytes(),
|
||||||
|
&sig_header,
|
||||||
|
&body_bytes,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let webhook = parse_webhook(&type_header, &app_state.config.bot_name, &body_bytes)?;
|
||||||
|
Ok(WebhookExtract(webhook))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_header(key: &str, headers: &axum::http::HeaderMap) -> Result<String, AppError> {
|
||||||
|
let value = headers
|
||||||
|
.get(key)
|
||||||
|
.ok_or(AppError::WebHookMissingHeaderErr(key.into()))?
|
||||||
|
.to_str()
|
||||||
|
.map_err(anyhow::Error::from)?;
|
||||||
|
|
||||||
|
Ok(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_body(body: axum::body::Body) -> Result<Bytes, AppError> {
|
||||||
|
to_bytes(body, MAX_WEBHOOK_BODY_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::from)
|
||||||
|
.map_err(AppError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_webhook(header: &str, bot_name: &str, body_bytes: &[u8]) -> Result<WebhookType, AppError> {
|
||||||
|
let Json(value) =
|
||||||
|
Json::<Value>::from_bytes(body_bytes).map_err(|_| AppError::MalformedJsonErr)?;
|
||||||
|
|
||||||
|
WebhookType::from_event(header, bot_name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_signature(secret_key: &[u8], sig_header: &str, body: &[u8]) -> Result<(), AppError> {
|
||||||
|
let sig_header_decoded =
|
||||||
|
hex::decode(sig_header).map_err(|_| AppError::WebHookSigHeaderInvalidErr)?;
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret_key).map_err(anyhow::Error::from)?;
|
||||||
|
|
||||||
|
mac.update(body);
|
||||||
|
|
||||||
|
let generated_hmac = mac.finalize().into_bytes();
|
||||||
|
bool::from(generated_hmac.ct_eq(&sig_header_decoded))
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(AppError::WebHookSigHeaderInvalidErr)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
use crate::{env::EnvConfig, gitea::WebhookType};
|
||||||
|
|
||||||
|
pub struct Bot {
|
||||||
|
config: EnvConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bot {
|
||||||
|
pub fn new(config: EnvConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exec(&self, webhook: WebhookType) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature";
|
||||||
|
pub const GITEA_EVENT_TYPE_HEADER_NAME: &str = "x-gitea-event-type";
|
||||||
|
pub const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 MiB
|
||||||
+20
-3
@@ -1,19 +1,36 @@
|
|||||||
|
use anyhow::anyhow;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct EnvConfig {
|
pub struct EnvConfig {
|
||||||
pub http_port: u16,
|
pub http_port: u16,
|
||||||
|
pub webhook_secret: String,
|
||||||
|
pub open_router_api_key: String,
|
||||||
pub bot_name: String,
|
pub bot_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_config() -> anyhow::Result<EnvConfig> {
|
pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
let http_port = std::env::var("HTTP_PORT")?.parse()?;
|
let http_port = try_get_env("HTTP_PORT")?.parse()?;
|
||||||
|
let bot_name = try_get_env("BOT_NAME")?;
|
||||||
let bot_name = std::env::var("BOT_NAME")?;
|
let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?;
|
||||||
|
let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?;
|
||||||
|
|
||||||
Ok(EnvConfig {
|
Ok(EnvConfig {
|
||||||
http_port,
|
http_port,
|
||||||
|
webhook_secret,
|
||||||
bot_name,
|
bot_name,
|
||||||
|
open_router_api_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn try_get_env(key: &str) -> anyhow::Result<String> {
|
||||||
|
let env = std::env::var(key)?;
|
||||||
|
|
||||||
|
if env.trim().is_empty() {
|
||||||
|
return Err(anyhow!(format!("env var {} is empty", key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(env)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use axum::response::IntoResponse;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Unauthorized user id")]
|
||||||
|
UnauthorizedUserErr,
|
||||||
|
|
||||||
|
#[error("Unknow gitea event")]
|
||||||
|
UnknownEventErr,
|
||||||
|
|
||||||
|
#[error("Malformed Json")]
|
||||||
|
MalformedJsonErr,
|
||||||
|
|
||||||
|
#[error("WebHook header not found")]
|
||||||
|
WebHookMissingHeaderErr(String),
|
||||||
|
|
||||||
|
#[error("WebHook sig header is invalid")]
|
||||||
|
WebHookSigHeaderInvalidErr,
|
||||||
|
|
||||||
|
#[error("WebHook have bad action")]
|
||||||
|
InvalidActionErr,
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
BadJsonStructErr(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
match self {
|
||||||
|
AppError::InvalidActionErr => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"WebHook have bad action".to_string(),
|
||||||
|
),
|
||||||
|
AppError::UnknownEventErr => {
|
||||||
|
(StatusCode::BAD_REQUEST, "Unknow gitea event".to_string())
|
||||||
|
}
|
||||||
|
AppError::UnauthorizedUserErr => (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Unauthorized user name".to_string(),
|
||||||
|
),
|
||||||
|
AppError::MalformedJsonErr => (StatusCode::BAD_REQUEST, "Malformed Json".to_string()),
|
||||||
|
AppError::BadJsonStructErr(err) => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Json not contains mandatory fields: {}", err),
|
||||||
|
),
|
||||||
|
AppError::WebHookMissingHeaderErr(h) => {
|
||||||
|
(StatusCode::BAD_REQUEST, format!("header {} is missing", h))
|
||||||
|
}
|
||||||
|
AppError::WebHookSigHeaderInvalidErr => (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"WebHook sig header is invalid".to_string(),
|
||||||
|
),
|
||||||
|
AppError::Other(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error".to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WebhookType {
|
||||||
|
Review(ReviewPayload),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ReviewPayload {
|
||||||
|
pub action: String,
|
||||||
|
pub pull_request: PullRequest,
|
||||||
|
pub comment: Comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct PullRequest {
|
||||||
|
pub id: u64,
|
||||||
|
pub diff_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Comment {
|
||||||
|
pub id: u64,
|
||||||
|
pub body: String,
|
||||||
|
pub user: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebhookType {
|
||||||
|
pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> {
|
||||||
|
let wb = match event {
|
||||||
|
"pull_request_comment" => Ok(WebhookType::Review(serde_json::from_value(json)?)),
|
||||||
|
_ => Err(AppError::UnknownEventErr),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let pr_body = match &wb {
|
||||||
|
WebhookType::Review(review_payload) => &review_payload.comment.body,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !pr_body.starts_with(&format!("@{}", bot_name)) {
|
||||||
|
return Err(AppError::UnauthorizedUserErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = match &wb {
|
||||||
|
WebhookType::Review(review_payload) => &review_payload.action,
|
||||||
|
};
|
||||||
|
|
||||||
|
if action != "created" {
|
||||||
|
return Err(AppError::InvalidActionErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(wb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_event_valid_pull_request_comment() {
|
||||||
|
let json = json!({
|
||||||
|
"action": "created",
|
||||||
|
"pull_request": {
|
||||||
|
"id": 42,
|
||||||
|
"diff_url": "https://mydiff.fr"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"id": 7,
|
||||||
|
"body": "@test_bot LGTM",
|
||||||
|
"user": {
|
||||||
|
"id": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
match result.unwrap() {
|
||||||
|
WebhookType::Review(payload) => {
|
||||||
|
assert_eq!(payload.action, "created");
|
||||||
|
assert_eq!(payload.pull_request.id, 42);
|
||||||
|
assert_eq!(payload.comment.id, 7);
|
||||||
|
assert_eq!(payload.comment.body, "@test_bot LGTM");
|
||||||
|
assert_eq!(payload.comment.user.id, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_event_unknown_event() {
|
||||||
|
let json = json!({});
|
||||||
|
let result = WebhookType::from_event("push", "test_bot", json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
match result.unwrap_err() {
|
||||||
|
AppError::UnknownEventErr => {}
|
||||||
|
_ => panic!("expected UnknownEventErr"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_event_malformed_json() {
|
||||||
|
let json = json!({
|
||||||
|
"action": "created"
|
||||||
|
// pull_request and comment are missing
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
match result.unwrap_err() {
|
||||||
|
AppError::BadJsonStructErr(_) => {}
|
||||||
|
_ => panic!("expected BadJsonStructErr"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_event_rejects_non_created_action() {
|
||||||
|
let json = json!({
|
||||||
|
"action": "edited",
|
||||||
|
"pull_request": {
|
||||||
|
"id": 1,
|
||||||
|
"diff_url": "https://mydiff.fr"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"id": 1,
|
||||||
|
"body": "@test_bot body",
|
||||||
|
"user": {
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
match result.unwrap_err() {
|
||||||
|
AppError::InvalidActionErr => {}
|
||||||
|
_ => panic!("expected InvalidActionErr"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_review_payload() {
|
||||||
|
let json = json!({
|
||||||
|
"action": "created",
|
||||||
|
"pull_request": {
|
||||||
|
"id": 99,
|
||||||
|
"diff_url": "https://mydiff.fr"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"id": 12,
|
||||||
|
"body": "Needs work",
|
||||||
|
"user": {
|
||||||
|
"id": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload: ReviewPayload = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(payload.action, "created");
|
||||||
|
assert_eq!(payload.pull_request.id, 99);
|
||||||
|
assert_eq!(payload.comment.id, 12);
|
||||||
|
assert_eq!(payload.comment.body, "Needs work");
|
||||||
|
assert_eq!(payload.comment.user.id, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_event_empty_json() {
|
||||||
|
let result = WebhookType::from_event("pull_request_comment", "test_bot", json!({}));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), AppError::BadJsonStructErr(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_event_rejects_wrong_bot_name() {
|
||||||
|
let json = json!({
|
||||||
|
"action": "created",
|
||||||
|
"pull_request": {
|
||||||
|
"id": 1,
|
||||||
|
"diff_url": "https://mydiff.fr"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"id": 1,
|
||||||
|
"body": "@other_bot do something",
|
||||||
|
"user": {
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
|
||||||
|
assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_event_rejects_no_bot_prefix() {
|
||||||
|
let json = json!({
|
||||||
|
"action": "created",
|
||||||
|
"pull_request": {
|
||||||
|
"id": 1,
|
||||||
|
"diff_url": "https://mydiff.fr"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"id": 1,
|
||||||
|
"body": "just a comment without bot mention",
|
||||||
|
"user": {
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
|
||||||
|
assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
-1
@@ -1,9 +1,25 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::{bot::Bot, state::AppState};
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
|
mod bot;
|
||||||
|
mod consts;
|
||||||
mod env;
|
mod env;
|
||||||
|
mod errors;
|
||||||
|
mod gitea;
|
||||||
|
mod state;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let config = env::load_config()?;
|
let config = env::load_config()?;
|
||||||
|
|
||||||
api::start_api(config).await
|
let app_state = AppState {
|
||||||
|
bot: Arc::new(Mutex::new(Bot::new(config.clone()))),
|
||||||
|
config: config,
|
||||||
|
};
|
||||||
|
|
||||||
|
api::start(app_state).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::{bot::Bot, env::EnvConfig};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub bot: Arc<Mutex<Bot>>,
|
||||||
|
pub config: EnvConfig,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user