mirror of
https://codeberg.org/Mo8it/git-webhook-client
synced 2024-11-21 11:06:32 +00:00
Merge branch 'axum'
This commit is contained in:
commit
f0561a10c5
15 changed files with 431 additions and 319 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
|||
*.json
|
||||
*.log
|
||||
/Cargo.lock
|
||||
/db/
|
||||
*.json
|
||||
*.log
|
||||
/scripts/
|
||||
/target/
|
||||
*.yaml
|
||||
|
|
28
Cargo.toml
28
Cargo.toml
|
@ -1,27 +1,29 @@
|
|||
[package]
|
||||
name = "git-webhook-client"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
authors = ["Mo Bitar <mo8it@proton.me>"]
|
||||
edition = "2021"
|
||||
readme = "README.adoc"
|
||||
readme = "README.md"
|
||||
repository = "https://codeberg.org/Mo8it/git-webhook-client"
|
||||
license-file = "LICENSE.txt"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
chrono = { version = "0.4", default-features = false }
|
||||
diesel = { version = "2.0", features = [
|
||||
"r2d2",
|
||||
"sqlite",
|
||||
"returning_clauses_for_sqlite_3_35",
|
||||
"without-deprecated",
|
||||
] }
|
||||
askama = { git = "https://github.com/djc/askama.git" }
|
||||
askama_axum = { git = "https://github.com/djc/askama.git", package = "askama_axum" }
|
||||
axum = { version = "0.6", default-features = false, features = ["http1", "tokio", "macros", "query"] }
|
||||
axum-extra = { version = "0.4", features = ["spa"] }
|
||||
bytes = "1.3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
diesel = { version = "2.0", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35", "without-deprecated"] }
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
log = "0.4"
|
||||
rocket = "0.5.0-rc.2"
|
||||
rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["tera"] }
|
||||
lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "hostname", "rustls-tls", "pool", "builder"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
sha2 = "0.10"
|
||||
simplelog = "0.12"
|
||||
tokio = { version = "1.23", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = "0.3"
|
||||
|
|
91
README.adoc
91
README.adoc
|
@ -1,91 +0,0 @@
|
|||
= Git Webhook Client
|
||||
|
||||
Git webhook client that runs commands after a webhook event and shows their output.
|
||||
|
||||
Currently, only Gitea is supported. If you want support for Gitlab or Github, then please open an issue.
|
||||
|
||||
== Features
|
||||
|
||||
* Verify the webhook event with a secret.
|
||||
* Run a configured command to a specific repository on a webhook event.
|
||||
* Save the output of the command.
|
||||
* Show an output by visiting the url of the client.
|
||||
* Supported configuration for multiple repositories.
|
||||
* Written in Rust :D
|
||||
|
||||
== Getting started
|
||||
|
||||
=== Requirements
|
||||
|
||||
* `cargo` to compile the source code.
|
||||
* Development package for SQLite (`sqlite-devel` on Fedora)
|
||||
|
||||
=== Configuration
|
||||
|
||||
The program looks for the configuration file configured with the environment variable `GWC_CONFIG_FILE` that contains the following:
|
||||
|
||||
. `secret`: The secret of the webhook.
|
||||
. `base_url`: The base_url of the webhook client.
|
||||
. `hooks`: List of webhooks.
|
||||
.. `repo_url`: Repository url.
|
||||
.. `current_dir`: The directory to run the command in.
|
||||
.. `command`: The command without any arguments.
|
||||
.. `args`: List of arguments separated by a comma.
|
||||
|
||||
==== Example configuration file:
|
||||
|
||||
[source, json]
|
||||
----
|
||||
{
|
||||
"secret": "CHANGE_ME!",
|
||||
"base_url": "https://webhook.mo8it.xyz",
|
||||
"hooks": [
|
||||
{
|
||||
"repo_url": "https://codeberg.org/Mo8it/git-webhook-client",
|
||||
"current_dir": ".",
|
||||
"command": "ls",
|
||||
"args": ["-l", "-a", "test_directory"]
|
||||
}
|
||||
]
|
||||
}
|
||||
----
|
||||
|
||||
==== First setup
|
||||
|
||||
* Clone the repository.
|
||||
* Create the configuration file.
|
||||
* Run the following to initialize the database:
|
||||
.
|
||||
[source, bash]
|
||||
----
|
||||
cargo install diesel_cli --no-default-features --features sqlite
|
||||
DATABASE_URL=PATH/TO/DATABASE/DIRECTORY/db.sqlite diesel_cli migration run
|
||||
cargo build --release
|
||||
----
|
||||
|
||||
==== Run
|
||||
|
||||
After running `cargo build --release`, the binary can be found in the directory `target/release/git-webhook-client`. To run it, you have to specify the environment variable `DATABASE_URL`:
|
||||
|
||||
[source, bash]
|
||||
----
|
||||
DATABASE_URL=PATH/TO/DATABASE/DIRECTORY/db.sqlite target/release/git-webhook-client
|
||||
----
|
||||
|
||||
==== Setup on the git server
|
||||
|
||||
Setup the webhook for the configured repositories on the git server. Don't forget to enter the same secret that you did specify in the configuration file.
|
||||
|
||||
==== Show output
|
||||
|
||||
After an event, the client responds with a URL that shows the log. The id in that URL is important and specific to this event.
|
||||
|
||||
If you want to see the last log, just visit the `base_url` from the configuration.
|
||||
|
||||
To see a specific log with an id, visit the URL: `base_url/?id=THE_ID_OF_AN_EVENT`.
|
||||
|
||||
You can specify a negative ID to see the last events. `id=-1` corresponds to the last log, `id=-2` corresponds to the log before it and so on.
|
||||
|
||||
== Note
|
||||
|
||||
This is my first Rust project and I am still learning. If you have any suggestions, just open an issue!
|
89
README.md
Normal file
89
README.md
Normal file
|
@ -0,0 +1,89 @@
|
|||
# Git Webhook Client
|
||||
|
||||
Git webhook client that runs commands after a webhook event and shows their output.
|
||||
|
||||
Currently, only Gitea is supported. If you want support for Gitlab or Github, then please open an issue.
|
||||
|
||||
## Features
|
||||
|
||||
- Verify the webhook event with a secret.
|
||||
- Run a configured command to a specific repository on a webhook event.
|
||||
- Save the output of the command.
|
||||
- Show an output by visiting the url of the client.
|
||||
- Supported configuration for multiple repositories.
|
||||
- Written in Rust :D
|
||||
|
||||
## Getting started
|
||||
|
||||
### Requirements
|
||||
|
||||
- `cargo` to compile the source code.
|
||||
- Development package for SQLite (`sqlite-devel` on Fedora)
|
||||
|
||||
### Configuration
|
||||
|
||||
The program looks for the configuration file configured with the environment variable `GWC_CONFIG_FILE` that contains the following:
|
||||
|
||||
<!-- TODO: Adjust to new config -->
|
||||
|
||||
1. `secret`: The secret of the webhook.
|
||||
1. `base_url`: The base_url of the webhook client.
|
||||
1. `hooks`: List of webhooks.
|
||||
1. `repo_url`: Repository url.
|
||||
1. `current_dir`: The directory to run the command in.
|
||||
1. `command`: The command without any arguments.
|
||||
1. `args`: List of arguments separated by a comma.
|
||||
|
||||
#### Example configuration file:
|
||||
|
||||
<!-- TODO: Adjust to new config -->
|
||||
|
||||
```yaml
|
||||
secret: CHANGE_ME!
|
||||
base_url: https://webhook.mo8it.com
|
||||
|
||||
hooks:
|
||||
repo_url: https://codeberg.org/Mo8it/git-webhook-client
|
||||
current_dir: .
|
||||
command: ls
|
||||
args: ["-l", "-a", "test_directory"]
|
||||
```
|
||||
|
||||
#### First setup
|
||||
|
||||
<!-- TODO: Auto migration -->
|
||||
|
||||
- Clone the repository.
|
||||
- Create the configuration file.
|
||||
- Run the following to initialize the database:
|
||||
```bash
|
||||
cargo install diesel_cli --no-default-features --features sqlite
|
||||
DATABASE_URL=PATH/TO/DATABASE/DIRECTORY/db.sqlite diesel_cli migration run
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
#### Run
|
||||
|
||||
After running `cargo build --release`, the binary can be found in the directory `target/release/git-webhook-client`. To run it, you have to specify the environment variable `DATABASE_URL`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=PATH/TO/DATABASE/DIRECTORY/db.sqlite target/release/git-webhook-client
|
||||
```
|
||||
|
||||
#### Setup on the git server
|
||||
|
||||
Setup the webhook for the configured repositories on the git server. Don't forget to enter the same secret that you did specify in the configuration file.
|
||||
|
||||
#### Show output
|
||||
|
||||
After an event, the client responds with a URL that shows the log. The id in that URL is important and specific to this event.
|
||||
|
||||
If you want to see the last log, just visit the `base_url` from the configuration.
|
||||
|
||||
To see a specific log with an id, visit the URL: `base_url/?id=THE_ID_OF_AN_EVENT`.
|
||||
|
||||
You can specify a negative ID to see the last events. `id=-1` corresponds to the last log, `id=-2` corresponds to the log before it and so on.
|
||||
|
||||
## Note
|
||||
|
||||
This is my first Rust project and I am still learning. If you have any suggestions, just open an issue!
|
|
@ -4,8 +4,35 @@ use std::env;
|
|||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SocketAddress {
|
||||
pub address: [u8; 4],
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EmailServer {
|
||||
pub server_name: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Address {
|
||||
pub name: String,
|
||||
pub user: String,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Logging {
|
||||
pub directory: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Hook {
|
||||
pub name: String,
|
||||
pub repo_url: String,
|
||||
pub current_dir: String,
|
||||
pub command: String,
|
||||
|
@ -16,7 +43,11 @@ pub struct Hook {
|
|||
pub struct Config {
|
||||
pub secret: String,
|
||||
pub base_url: String,
|
||||
pub log_file: String,
|
||||
pub socket_address: SocketAddress,
|
||||
pub email_server: EmailServer,
|
||||
pub email_from: Address,
|
||||
pub email_to: Address,
|
||||
pub logging: Logging,
|
||||
pub hooks: Vec<Hook>,
|
||||
}
|
||||
|
||||
|
@ -29,8 +60,8 @@ impl Config {
|
|||
let config_file = File::open(&config_path)
|
||||
.with_context(|| format!("Can not open the config file at the path {config_path}"))?;
|
||||
let config_reader = BufReader::new(config_file);
|
||||
let config: Self = serde_json::from_reader(config_reader)
|
||||
.context("Can not parse the config file as JSON!")?;
|
||||
let config: Self = serde_yaml::from_reader(config_reader)
|
||||
.context("Can not parse the YAML config file!")?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use chrono::offset::Local;
|
||||
use diesel::prelude::*;
|
||||
use diesel::r2d2::{ConnectionManager, Pool, PooledConnection};
|
||||
use log::error;
|
||||
use std::env;
|
||||
use tracing::error;
|
||||
|
||||
use crate::config::Hook;
|
||||
use crate::models::{HookLog, NewHookLog};
|
||||
|
|
25
src/errors.rs
Normal file
25
src/errors.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use tracing::error;
|
||||
|
||||
pub struct AppError(anyhow::Error);
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
error!("{:?}", self.0);
|
||||
|
||||
(StatusCode::BAD_REQUEST, format!("{:?}", self.0)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AppError {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(anyhow::Error::msg(s.to_string()))
|
||||
}
|
||||
}
|
115
src/guards.rs
115
src/guards.rs
|
@ -1,115 +0,0 @@
|
|||
use hmac::{Hmac, Mac};
|
||||
use rocket::data::{Data, FromData, Limits, Outcome};
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, Request};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::states;
|
||||
|
||||
pub struct Repo<'r> {
|
||||
pub clone_url: &'r str,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromData<'r> for Repo<'r> {
|
||||
type Error = String;
|
||||
|
||||
async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> {
|
||||
let payload = match data.open(Limits::JSON).into_bytes().await {
|
||||
Ok(payload) if payload.is_complete() => payload.into_inner(),
|
||||
Ok(_) => {
|
||||
return Outcome::Failure((Status::PayloadTooLarge, "Payload too large".to_string()))
|
||||
}
|
||||
Err(e) => return Outcome::Failure((Status::InternalServerError, e.to_string())),
|
||||
};
|
||||
|
||||
let mut received_signatures = req.headers().get("X-GITEA-SIGNATURE");
|
||||
let received_signature = match received_signatures.next() {
|
||||
Some(signature) => match hex::decode(signature) {
|
||||
Ok(signature) => signature,
|
||||
Err(_) => {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
"Can not hex decode the received signature!".to_string(),
|
||||
))
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Outcome::Failure((Status::BadRequest, "Missing signature!".to_string()))
|
||||
}
|
||||
};
|
||||
|
||||
if received_signatures.next().is_some() {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
"Received more than one signature!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let config_state = match req.rocket().state::<states::Config>() {
|
||||
Some(state) => state,
|
||||
None => {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
"Can not get the config state!".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !is_valid_signature(&config_state.secret, &received_signature, &payload) {
|
||||
return Outcome::Failure((Status::BadRequest, "Invalid signature!".to_string()));
|
||||
}
|
||||
|
||||
let json: Value = match serde_json::from_slice(&payload) {
|
||||
Ok(json) => json,
|
||||
Err(_) => {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
"Can not parse payload into JSON!".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let repo = match json.get("repository") {
|
||||
Some(repo) => repo,
|
||||
None => {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
"Can not get the repository value from the payload!".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let clone_url = match repo.get("clone_url") {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
"Can not get value clone_url from repository in the payload!".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let clone_url = match clone_url.as_str() {
|
||||
Some(url) => url.to_string(),
|
||||
None => {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
"The value of clone_url from repository in the payload is not a string!"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let clone_url = request::local_cache!(req, clone_url);
|
||||
|
||||
Outcome::Success(Repo { clone_url })
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_signature(secret: &[u8], received_signature: &[u8], payload: &[u8]) -> bool {
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(secret).expect("Can not generate a mac from the secret!");
|
||||
mac.update(payload);
|
||||
let expected_signature = mac.finalize().into_bytes();
|
||||
|
||||
received_signature[..] == expected_signature[..]
|
||||
}
|
|
@ -1,30 +1,17 @@
|
|||
use anyhow::{Context, Result};
|
||||
use simplelog::{ColorChoice, LevelFilter, TermLogger, TerminalMode, WriteLogger};
|
||||
use std::fs::OpenOptions;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::filter::LevelFilter;
|
||||
|
||||
use crate::config;
|
||||
|
||||
pub fn init_logger(config: &config::Config) -> Result<()> {
|
||||
let logger = if cfg!(debug_assertions) {
|
||||
TermLogger::init(
|
||||
LevelFilter::Debug,
|
||||
simplelog::Config::default(),
|
||||
TerminalMode::Mixed,
|
||||
ColorChoice::Auto,
|
||||
)
|
||||
} else {
|
||||
WriteLogger::init(
|
||||
LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&config.log_file)
|
||||
.with_context(|| format!("Could not open the log file {}", &config.log_file))?,
|
||||
)
|
||||
};
|
||||
pub fn init_logger(logging_config: &config::Logging) -> WorkerGuard {
|
||||
let file_appender =
|
||||
tracing_appender::rolling::never(&logging_config.directory, &logging_config.filename);
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
logger.context("Could not initialize the logger!")?;
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(LevelFilter::INFO)
|
||||
.with_writer(non_blocking)
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
guard
|
||||
}
|
||||
|
|
58
src/mailer.rs
Normal file
58
src/mailer.rs
Normal file
|
@ -0,0 +1,58 @@
|
|||
use anyhow::{Context, Result};
|
||||
use lettre::address::Address;
|
||||
use lettre::message::{Mailbox, MessageBuilder};
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::Transport;
|
||||
use lettre::{Message, SmtpTransport};
|
||||
use std::mem;
|
||||
|
||||
use crate::config;
|
||||
|
||||
pub struct Mailer {
|
||||
mailer: SmtpTransport,
|
||||
message_builder: MessageBuilder,
|
||||
}
|
||||
|
||||
impl Mailer {
|
||||
pub fn new(config: &mut config::Config) -> Result<Self> {
|
||||
let creds = Credentials::new(
|
||||
mem::take(&mut config.email_server.email),
|
||||
mem::take(&mut config.email_server.password),
|
||||
);
|
||||
|
||||
let mailer = SmtpTransport::relay(&config.email_server.server_name)
|
||||
.context("Failed to connect to the email server!")?
|
||||
.credentials(creds)
|
||||
.build();
|
||||
|
||||
let message_builder = Message::builder()
|
||||
.from(Mailbox::new(
|
||||
Some(mem::take(&mut config.email_from.name)),
|
||||
Address::new(&config.email_from.user, &config.email_from.domain)
|
||||
.context("Failed to create the From email address!")?,
|
||||
))
|
||||
.to(Mailbox::new(
|
||||
Some(mem::take(&mut config.email_to.name)),
|
||||
Address::new(&config.email_to.user, &config.email_to.domain)
|
||||
.context("Failed to create the To email address!")?,
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
mailer,
|
||||
message_builder,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send(&self, hook_name: &str, hook_log_link: &str, status: &str) -> Result<()> {
|
||||
let email = self
|
||||
.message_builder
|
||||
.clone()
|
||||
.subject(format!("GWC {}: {}", hook_name, status))
|
||||
.body(hook_log_link.to_string())
|
||||
.context("Failed to build email!")?;
|
||||
|
||||
self.mailer.send(&email).context("Failed to send email!")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
60
src/main.rs
60
src/main.rs
|
@ -1,39 +1,61 @@
|
|||
mod config;
|
||||
mod db;
|
||||
mod guards;
|
||||
mod errors;
|
||||
mod logging;
|
||||
mod mailer;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod schema;
|
||||
mod states;
|
||||
mod templates;
|
||||
|
||||
use anyhow::Result;
|
||||
use log::info;
|
||||
use rocket::{Build, Rocket};
|
||||
use rocket_dyn_templates::Template;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Router, Server};
|
||||
use axum_extra::routing::SpaRouter;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::process;
|
||||
use tracing::info;
|
||||
|
||||
fn init() -> Result<Rocket<Build>> {
|
||||
let config = config::Config::new()?;
|
||||
async fn init() -> Result<()> {
|
||||
let mut config = config::Config::new()?;
|
||||
let mailer = mailer::Mailer::new(&mut config)?;
|
||||
|
||||
logging::init_logger(&config)?;
|
||||
let address = config.socket_address.address;
|
||||
let socket_address = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(
|
||||
address[0], address[1], address[2], address[3],
|
||||
)),
|
||||
config.socket_address.port,
|
||||
);
|
||||
|
||||
info!("Starting client");
|
||||
let _tracing_gurad = logging::init_logger(&config.logging);
|
||||
|
||||
let rocket = rocket::build()
|
||||
.mount("/", rocket::routes![routes::index])
|
||||
.mount("/api", rocket::routes![routes::trigger])
|
||||
.manage(states::DB::new()?)
|
||||
.manage(states::Config::new(config))
|
||||
.attach(Template::fairing());
|
||||
let app_state = states::AppState::new(config, mailer)?;
|
||||
|
||||
Ok(rocket)
|
||||
let api_routes = Router::new().route("/trigger", post(routes::trigger));
|
||||
let routes = Router::new()
|
||||
.route("/", get(routes::index))
|
||||
.nest("/api", api_routes)
|
||||
.with_state(app_state);
|
||||
|
||||
let spa = SpaRouter::new("/static", "static");
|
||||
|
||||
let app = Router::new().merge(routes).merge(spa);
|
||||
|
||||
info!("Starting server");
|
||||
Server::bind(&socket_address)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rocket::launch]
|
||||
fn rocket() -> _ {
|
||||
init().unwrap_or_else(|e| {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init().await.unwrap_or_else(|e| {
|
||||
eprintln!("{e:?}");
|
||||
process::exit(1);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
142
src/routes.rs
142
src/routes.rs
|
@ -1,59 +1,99 @@
|
|||
use rocket::response::status::BadRequest;
|
||||
use rocket::{get, post, State};
|
||||
use rocket_dyn_templates::Template;
|
||||
use anyhow::Context;
|
||||
use askama_axum::IntoResponse;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::header::HeaderMap;
|
||||
use axum::response::Response;
|
||||
use bytes::Bytes;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::db;
|
||||
use crate::guards;
|
||||
use crate::states;
|
||||
use crate::{db, errors, mailer, states, templates};
|
||||
|
||||
fn bad_req<E>(err: E) -> BadRequest<String>
|
||||
where
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
BadRequest(Some(err.to_string()))
|
||||
#[derive(Deserialize)]
|
||||
pub struct IndexQuery {
|
||||
id: Option<i32>,
|
||||
}
|
||||
|
||||
#[get("/?<id>")]
|
||||
pub fn index(
|
||||
db_state: &State<states::DB>,
|
||||
id: Option<i32>,
|
||||
) -> Result<Template, BadRequest<String>> {
|
||||
let id = id.unwrap_or(-1);
|
||||
pub async fn index(
|
||||
State(db_state): State<Arc<states::DB>>,
|
||||
query: Query<IndexQuery>,
|
||||
) -> Result<Response, errors::AppError> {
|
||||
let id = match query.id {
|
||||
Some(id) if id != 0 => id,
|
||||
Some(_) => return Err("id=0 not allowed!".into()),
|
||||
None => -1,
|
||||
};
|
||||
|
||||
if id == 0 {
|
||||
return Err(bad_req("id=0 not allowed!"));
|
||||
let hook_log = db::get_hook_log(&db_state.pool, id)?;
|
||||
|
||||
info!("Viewed hook log with id: {}", hook_log.id);
|
||||
|
||||
let template = templates::HookLog::from(hook_log);
|
||||
|
||||
Ok(template.into_response())
|
||||
}
|
||||
|
||||
async fn is_valid_signature(secret: &[u8], received_signature: &[u8], body: &[u8]) -> bool {
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(secret).expect("Can not generate a mac from the secret!");
|
||||
mac.update(body);
|
||||
let expected_signature = mac.finalize().into_bytes();
|
||||
|
||||
received_signature[..] == expected_signature[..]
|
||||
}
|
||||
|
||||
pub async fn trigger(
|
||||
State(db_state): State<Arc<states::DB>>,
|
||||
State(config_state): State<Arc<states::Config>>,
|
||||
State(mailer): State<Arc<mailer::Mailer>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response, errors::AppError> {
|
||||
info!("Trigger called");
|
||||
|
||||
let mut received_signatures = headers.get_all("X-GITEA-SIGNATURE").iter();
|
||||
|
||||
let received_signature = received_signatures.next().context("Missing signature!")?;
|
||||
|
||||
let received_signature =
|
||||
hex::decode(received_signature).context("Can not hex decode the received signature!")?;
|
||||
|
||||
if received_signatures.next().is_some() {
|
||||
return Err("Received more than one signature!".into());
|
||||
}
|
||||
|
||||
let hook_log = match db::get_hook_log(&db_state.pool, id) {
|
||||
Ok(hl) => hl,
|
||||
Err(e) => return Err(bad_req(e)),
|
||||
};
|
||||
if !is_valid_signature(&config_state.secret, &received_signature, &body).await {
|
||||
return Err("Invalid signature!".into());
|
||||
}
|
||||
|
||||
Ok(Template::render("hook_log", hook_log))
|
||||
}
|
||||
let json: Value =
|
||||
serde_json::from_slice(&body).context("Can not parse the request body into JSON!")?;
|
||||
|
||||
#[post("/trigger", format = "json", data = "<repo>")]
|
||||
pub fn trigger(
|
||||
repo: guards::Repo,
|
||||
db_state: &State<states::DB>,
|
||||
config_state: &State<states::Config>,
|
||||
) -> Result<String, BadRequest<String>> {
|
||||
let hook = match config_state.get_hook(repo.clone_url) {
|
||||
Some(hook) => hook,
|
||||
None => {
|
||||
return Err(bad_req(format!(
|
||||
"No matching repository with url {} in the configuration file.",
|
||||
repo.clone_url
|
||||
)))
|
||||
}
|
||||
};
|
||||
let repo = json
|
||||
.get("repository")
|
||||
.context("Can not get the repository value from the request body!")?;
|
||||
|
||||
let hook_log_id = match db::add_hook_log(&db_state.pool, hook) {
|
||||
Ok(hook_log) => hook_log.id,
|
||||
Err(e) => return Err(bad_req(e)),
|
||||
};
|
||||
let clone_url = repo
|
||||
.get("clone_url")
|
||||
.context("Can not get value clone_url from repository in the request body!")?;
|
||||
|
||||
let clone_url = clone_url
|
||||
.as_str()
|
||||
.context("The value of clone_url from repository in the request body is not a string!")?;
|
||||
|
||||
let hook = config_state.get_hook(clone_url).with_context(|| {
|
||||
format!("No matching repository with url {clone_url} in the configuration file.")
|
||||
})?;
|
||||
|
||||
let hook_log_id = db::add_hook_log(&db_state.pool, hook)?.id;
|
||||
|
||||
let hook_log_link = format!("{}/?id={}", config_state.base_url, hook_log_id);
|
||||
|
||||
{
|
||||
// Spawn and detach a thread that runs the command and fills the output in the log.
|
||||
|
@ -64,8 +104,13 @@ pub fn trigger(
|
|||
let args = hook.args.clone();
|
||||
let current_dir = hook.current_dir.clone();
|
||||
let db_pool = db_state.pool.clone();
|
||||
let clone_url = clone_url.to_string();
|
||||
let hook_name = hook.name.clone();
|
||||
let hook_log_link = hook_log_link.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
info!("Running webhook for Repo: {clone_url}");
|
||||
|
||||
let stdout: Vec<u8>;
|
||||
let stderr: Vec<u8>;
|
||||
let status_code: Option<i32>;
|
||||
|
@ -89,9 +134,16 @@ pub fn trigger(
|
|||
}
|
||||
};
|
||||
|
||||
let status = if status_code == Some(0) { "Ok" } else { "Err" };
|
||||
|
||||
db::fill_hook_log(&db_pool, hook_log_id, &stdout, &stderr, status_code);
|
||||
|
||||
match mailer.send(&hook_name, &hook_log_link, status) {
|
||||
Ok(_) => info!("Sent email with hook name {hook_name}"),
|
||||
Err(e) => error!("{e:?}"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Ok(format!("{}/?id={}", config_state.base_url, hook_log_id))
|
||||
Ok(hook_log_link.into_response())
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
use crate::config;
|
||||
use crate::db;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::FromRef;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{config, db, mailer};
|
||||
|
||||
pub struct DB {
|
||||
pub pool: db::DBPool,
|
||||
|
@ -21,16 +22,35 @@ pub struct Config {
|
|||
pub hooks: Vec<config::Hook>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(config: config::Config) -> Self {
|
||||
impl From<config::Config> for Config {
|
||||
fn from(config: config::Config) -> Self {
|
||||
Self {
|
||||
secret: config.secret.as_bytes().to_owned(),
|
||||
base_url: config.base_url,
|
||||
hooks: config.hooks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn get_hook(&self, clone_url: &str) -> Option<&config::Hook> {
|
||||
self.hooks.iter().find(|&hook| hook.repo_url == clone_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, FromRef)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub mailer: Arc<mailer::Mailer>,
|
||||
pub db: Arc<DB>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: config::Config, mailer: mailer::Mailer) -> Result<Self> {
|
||||
Ok(Self {
|
||||
config: Arc::new(Config::from(config)),
|
||||
mailer: Arc::new(mailer),
|
||||
db: Arc::new(DB::new()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
31
src/templates.rs
Normal file
31
src/templates.rs
Normal file
|
@ -0,0 +1,31 @@
|
|||
use askama::Template;
|
||||
|
||||
use crate::models;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "hook_log.txt")]
|
||||
pub struct HookLog {
|
||||
pub id: i32,
|
||||
pub datetime: String,
|
||||
pub repo_url: String,
|
||||
pub command_with_args: String,
|
||||
pub current_dir: String,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub status_code: i32,
|
||||
}
|
||||
|
||||
impl From<models::HookLog> for HookLog {
|
||||
fn from(hook_log: models::HookLog) -> Self {
|
||||
Self {
|
||||
id: hook_log.id,
|
||||
datetime: hook_log.datetime,
|
||||
repo_url: hook_log.repo_url,
|
||||
command_with_args: hook_log.command_with_args,
|
||||
current_dir: hook_log.current_dir,
|
||||
stdout: hook_log.stdout.unwrap_or_default(),
|
||||
stderr: hook_log.stderr.unwrap_or_default(),
|
||||
status_code: hook_log.status_code.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue