/* * Copyright (C) 2022 Aravinth Manivannan * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ use actix_identity::Identity; use actix_web::{web, HttpRequest, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; use tracing::info; use url::Url; use super::get_auth_middleware; use crate::{errors::*, AppCtx}; pub mod routes { use crate::ctx::Ctx; pub struct Forgejo { pub add_webhook: &'static str, pub view_webhook: &'static str, pub list_webhooks: &'static str, pub webhook: &'static str, } impl Forgejo { pub const fn new() -> Self { Self { add_webhook: "/api/v1/forgejo/webhook/add", list_webhooks: "/api/v1/forgejo/webhook/add", view_webhook: "/api/v1/forgejo/webhook/view/{auth_token}", webhook: "/api/v1/forgejo/webhook/event/new", } } pub fn get_view(&self, auth_token: &str) -> String { self.view_webhook.replace("{auth_token}", auth_token) } pub fn get_webhook_url(&self, ctx: &Ctx, auth_token: &str) -> String { format!( "https://{}{}?auth={auth_token}", &ctx.settings.server.domain, self.webhook ) } } } #[derive(Serialize, Deserialize)] pub struct AddWebhook { pub forgejo_url: Url, } #[actix_web_codegen_const_routes::post( path = "crate::V1_API_ROUTES.forgejo.add_webhook", wrap = "get_auth_middleware()" )] #[tracing::instrument(name = "Add webhook" skip(id, ctx, payload))] async fn add_webhook( ctx: AppCtx, id: Identity, payload: web::Json, ) -> ServiceResult { info!( "Adding webhook for Forgejo instance: {}", payload.forgejo_url.as_str() ); let owner = id.identity().unwrap(); let payload = payload.into_inner(); let hook = ctx.db.new_webhook(payload.forgejo_url, &owner).await?; Ok(HttpResponse::Ok().json(hook)) } #[actix_web_codegen_const_routes::get( path = "crate::V1_API_ROUTES.forgejo.list_webhooks", wrap = "get_auth_middleware()" )] #[tracing::instrument(name = "Delete webhook" skip(id, ctx))] async fn list_webhooks(ctx: AppCtx, id: Identity) -> ServiceResult { let owner = id.identity().unwrap(); info!("Getting all webhooks created by {}", owner); let hooks = ctx.db.list_all_webhooks_with_owner(&owner).await?; Ok(HttpResponse::Ok().json(hooks)) } #[actix_web_codegen_const_routes::get( path = "crate::V1_API_ROUTES.forgejo.view_webhook", wrap = "get_auth_middleware()" )] #[tracing::instrument(name = "Delete webhook" skip(id, ctx, path))] async fn view_webhook( ctx: AppCtx, id: Identity, path: web::Path, ) -> ServiceResult { let path = path.into_inner(); let owner = id.identity().unwrap(); info!("Gitting webhook webhook for Forgejo instance: {}", path,); let hook = ctx.db.get_webhook_with_owner(&path, &owner).await?; Ok(HttpResponse::Ok().json(hook)) } #[derive(Serialize, Deserialize)] struct Auth { auth: String, } #[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.forgejo.webhook")] #[tracing::instrument(name = "Update ", skip(body, ctx, req, q))] async fn webhook( ctx: AppCtx, body: web::Bytes, req: HttpRequest, q: web::Query, ) -> ServiceResult { ctx.process_webhook(&body, &req, &q.auth).await?; Ok(HttpResponse::Ok()) } pub fn services(cfg: &mut web::ServiceConfig) { cfg.service(add_webhook); cfg.service(view_webhook); cfg.service(list_webhooks); cfg.service(webhook); } #[cfg(test)] mod tests { use actix_web::{error::ResponseError, http::StatusCode, test}; use hmac::Mac; use crate::ctx::api::v1::forgejo::{HmacSha256, WebhookPayload}; use crate::db::ForgejoWebhook; use crate::tests; use crate::*; use super::*; #[actix_rt::test] async fn test_api_forgejo_webhook() { const NAME: &str = "apiforgejowebhookuser"; const PASSWORD: &str = "longpasswordasdfa2"; const EMAIL: &str = "apiforgejowebhookuser@a.com"; let (_dir, ctx) = tests::get_ctx().await; let _ = ctx.delete_user(NAME, PASSWORD).await; let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; let page = ctx.add_test_site(NAME.into()).await; let cookies = get_cookie!(signin_resp); let app = get_app!(ctx).await; let payload = AddWebhook { forgejo_url: Url::parse("https://git.batnsense.net").unwrap(), }; let add_webhook_resp = test::call_service( &app, post_request!(&payload, V1_API_ROUTES.forgejo.add_webhook) .cookie(cookies.clone()) .to_request(), ) .await; check_status!(add_webhook_resp, StatusCode::OK); let response: ForgejoWebhook = actix_web::test::read_body_json(add_webhook_resp).await; assert_eq!(response.forgejo_url, payload.forgejo_url); let view_webhook_resp = get_request!( &app, &V1_API_ROUTES.forgejo.get_view(&response.auth_token), cookies.clone() ); check_status!(view_webhook_resp, StatusCode::OK); let hook: ForgejoWebhook = actix_web::test::read_body_json(view_webhook_resp).await; assert_eq!(hook, response); let list_all_webhooks_resp = get_request!(&app, &V1_API_ROUTES.forgejo.list_webhooks, cookies.clone()); check_status!(list_all_webhooks_resp, StatusCode::OK); let hooks: Vec = actix_web::test::read_body_json(list_all_webhooks_resp).await; assert_eq!(vec![hook.clone()], hooks); let webhook_url = format!("{}?auth={}", V1_API_ROUTES.forgejo.webhook, hook.auth_token); // test webhook let mut webhook_payload = WebhookPayload::default(); webhook_payload.reference = format!("refs/origin/{}", page.branch); webhook_payload.repository.html_url = page.repo; let body = serde_json::to_string(&webhook_payload).unwrap(); let body = body.as_bytes(); let mut mac = HmacSha256::new_from_slice(hook.forgejo_webhook_secret.as_bytes()) .expect("HMAC can take key of any size"); mac.update(&body); let res = mac.finalize(); let sig = res.into_bytes(); let sig = hex::encode(&sig[..]); let post_to_webhook_resp = test::call_service( &app, post_request!(&webhook_payload, &webhook_url) .insert_header(("X-Gitea-Delivery", "foobar213randomuuid")) .insert_header(("X-Gitea-Signature", sig.clone())) .insert_header(("X-Gitea-Event", "push")) .cookie(cookies.clone()) .to_request(), ) .await; check_status!(post_to_webhook_resp, StatusCode::OK); // no webhook let fake_webhook_url = format!( "{}?auth={}", V1_API_ROUTES.forgejo.webhook, hook.forgejo_webhook_secret ); let body = serde_json::to_string(&webhook_payload).unwrap(); let body = body.as_bytes(); let mut mac = HmacSha256::new_from_slice(b"nosecret").expect("HMAC can take key of any size"); mac.update(&body); let res = mac.finalize(); let fake_sig = res.into_bytes(); let fake_sig = hex::encode(&fake_sig[..]); let post_to_no_exist_webhook_resp = test::call_service( &app, post_request!(&webhook_payload, &fake_webhook_url) .insert_header(("X-Gitea-Delivery", "foobar213randomuuid")) .insert_header(("X-Gitea-Signature", fake_sig)) .insert_header(("X-Gitea-Event", "push")) .cookie(cookies.clone()) .to_request(), ) .await; let err = ServiceError::WebhookNotFound; assert_eq!(post_to_no_exist_webhook_resp.status(), err.status_code()); let resp_err: ErrorToResponse = actix_web::test::read_body_json(post_to_no_exist_webhook_resp).await; assert_eq!(resp_err.error, err.to_string()); // no website let mut webhook_payload = WebhookPayload::default(); webhook_payload.reference = format!("refs/origin/{}", page.branch); webhook_payload.repository.html_url = "https://no-exist-git.example.org".into(); let body = serde_json::to_string(&webhook_payload).unwrap(); let body = body.as_bytes(); let mut mac = HmacSha256::new_from_slice(hook.forgejo_webhook_secret.as_bytes()) .expect("HMAC can take key of any size"); mac.update(&body); let res = mac.finalize(); let sig = res.into_bytes(); let sig = hex::encode(&sig[..]); let post_to_no_website_webhook_resp = test::call_service( &app, post_request!(&webhook_payload, &webhook_url) .insert_header(("X-Gitea-Delivery", "foobar213randomuuid")) .insert_header(("X-Gitea-Signature", sig.clone())) .insert_header(("X-Gitea-Event", "push")) .cookie(cookies.clone()) .to_request(), ) .await; let err = ServiceError::WebsiteNotFound; assert_eq!(post_to_no_website_webhook_resp.status(), err.status_code()); let resp_err: ErrorToResponse = actix_web::test::read_body_json(post_to_no_website_webhook_resp).await; assert_eq!(resp_err.error, err.to_string()); } }