From acf8873d8ea8d46d14acc6993957982d945d2b29 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 14:49:33 +0300 Subject: [PATCH] webhooks: per-repo webhook configuration scaffold --- templates/new/webhook.html | 40 +++++++ templates/repo/webhook.html | 47 ++++++++ templates/repo/webhooks.html | 45 ++++++++ webhook.v | 211 +++++++++++++++++++++++++++++++++++ webhook_routes.v | 79 +++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 templates/new/webhook.html create mode 100644 templates/repo/webhook.html create mode 100644 templates/repo/webhooks.html create mode 100644 webhook.v create mode 100644 webhook_routes.v diff --git a/templates/new/webhook.html b/templates/new/webhook.html new file mode 100644 index 0000000..ec334e9 --- /dev/null +++ b/templates/new/webhook.html @@ -0,0 +1,40 @@ + + + + @include '../layout/head.html' + + + @include '../layout/header.html' + +
+ @include '../layout/repo_menu.html' + + ← %webhook_back + +

%webhook_new

+ +
+
+ + +
+
+ + +

%webhook_secret_hint

+
+
+ + + + + + +
+ +
+
+ + @include '../layout/footer.html' + + diff --git a/templates/repo/webhook.html b/templates/repo/webhook.html new file mode 100644 index 0000000..5dba16a --- /dev/null +++ b/templates/repo/webhook.html @@ -0,0 +1,47 @@ + + + + @include '../layout/head.html' + + + @include '../layout/header.html' + +
+ @include '../layout/repo_menu.html' + + ← %webhook_back + +

@webhook.url

+
+ @if webhook.is_active + %webhook_active + @else + %webhook_inactive + @end + @webhook.events +
+ +

%webhook_deliveries

+ @if deliveries.len == 0 +
%webhook_never_delivered
+ @else + + + + + + @for d in deliveries + + + + + + @end + +
EventStatusBody
@d.event@d.status_code@d.response_body
+ @end +
+ + @include '../layout/footer.html' + + diff --git a/templates/repo/webhooks.html b/templates/repo/webhooks.html new file mode 100644 index 0000000..7af4ca8 --- /dev/null +++ b/templates/repo/webhooks.html @@ -0,0 +1,45 @@ + + + + @include '../layout/head.html' + + + @include '../layout/header.html' + +
+ @include '../layout/repo_menu.html' + +
+

%webhooks

+ %webhook_new +
+ + @if webhooks.len == 0 +
%webhook_none
+ @else + + @end +
+ + @include '../layout/footer.html' + + diff --git a/webhook.v b/webhook.v new file mode 100644 index 0000000..8aadcb1 --- /dev/null +++ b/webhook.v @@ -0,0 +1,211 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import time +import net.http +import crypto.hmac +import crypto.sha256 +import encoding.hex +import json + +pub struct WebhookIssuePayload { + action string + repo string + title string + author string +} + +pub struct WebhookPrPayload { + action string + repo string + number int + title string + author string + head string + base string +} + +pub struct WebhookCommentPayload { + action string + repo string + target string // 'issue' or 'pr' + number int + author string + text string +} + +pub struct WebhookReleasePayload { + action string + repo string + tag string + author string +} + +pub struct WebhookPushPayload { + repo string + ref string + author string +} + +struct Webhook { + id int @[primary; sql: serial] +mut: + repo_id int + url string + secret string + events string // comma-separated: push,issue,pr,comment,release + is_active bool + created_at int + last_status int + last_delivery int +} + +struct WebhookDelivery { + id int @[primary; sql: serial] +mut: + webhook_id int + event string + status_code int + response_body string + created_at int +} + +fn (w &Webhook) has_event(name string) bool { + if w.events == '' { + return true + } + for ev in w.events.split(',') { + if ev.trim_space() == name { + return true + } + } + return false +} + +fn (w &Webhook) event_list() []string { + mut out := []string{} + for ev in w.events.split(',') { + t := ev.trim_space() + if t != '' { + out << t + } + } + return out +} + +fn (mut app App) add_webhook(repo_id int, url string, secret string, events string) ! { + wh := Webhook{ + repo_id: repo_id + url: url + secret: secret + events: events + is_active: true + created_at: int(time.now().unix()) + } + sql app.db { + insert wh into Webhook + }! +} + +fn (mut app App) list_repo_webhooks(repo_id int) []Webhook { + return sql app.db { + select from Webhook where repo_id == repo_id order by id desc + } or { []Webhook{} } +} + +fn (mut app App) find_webhook_by_id(id int) ?Webhook { + rows := sql app.db { + select from Webhook where id == id limit 1 + } or { []Webhook{} } + if rows.len == 0 { + return none + } + return rows.first() +} + +fn (mut app App) delete_webhook(id int) ! { + sql app.db { + delete from Webhook where id == id + }! + sql app.db { + delete from WebhookDelivery where webhook_id == id + }! +} + +fn (mut app App) delete_repo_webhooks(repo_id int) ! { + whs := app.list_repo_webhooks(repo_id) + for wh in whs { + app.delete_webhook(wh.id) or {} + } +} + +fn (mut app App) toggle_webhook(id int, active bool) ! { + sql app.db { + update Webhook set is_active = active where id == id + }! +} + +fn (mut app App) record_webhook_delivery(webhook_id int, event string, status int, body string) { + d := WebhookDelivery{ + webhook_id: webhook_id + event: event + status_code: status + response_body: body + created_at: int(time.now().unix()) + } + sql app.db { + insert d into WebhookDelivery + } or { return } + sql app.db { + update Webhook set last_status = status, last_delivery = d.created_at where id == webhook_id + } or { return } +} + +fn (mut app App) recent_webhook_deliveries(webhook_id int, limit int) []WebhookDelivery { + return sql app.db { + select from WebhookDelivery where webhook_id == webhook_id order by id desc limit limit + } or { []WebhookDelivery{} } +} + +// dispatch_webhook fires a webhook delivery in a background spawn. +// payload is any serializable value; it's JSON-encoded with `json.encode`. +fn (mut app App) dispatch_webhook[T](repo_id int, event string, payload T) { + body := json.encode(payload) + app.fan_out_webhook(repo_id, event, body) +} + +fn (mut app App) fan_out_webhook(repo_id int, event string, body string) { + hooks := app.list_repo_webhooks(repo_id) + for wh in hooks { + if !wh.is_active { + continue + } + if !wh.has_event(event) { + continue + } + spawn app.deliver_webhook(wh, event, body) + } +} + +fn (mut app App) deliver_webhook(wh Webhook, event string, body string) { + mut signature := '' + if wh.secret != '' { + sig_bytes := hmac.new(wh.secret.bytes(), body.bytes(), sha256.sum, sha256.block_size) + signature = 'sha256=' + hex.encode(sig_bytes) + } + mut req := http.new_request(.post, wh.url, body) + req.header.add(.content_type, 'application/json') + req.header.add_custom('X-Gitly-Event', event) or {} + if signature != '' { + req.header.add_custom('X-Gitly-Signature', signature) or {} + } + req.read_timeout = 10 * time.second + req.write_timeout = 10 * time.second + resp := req.do() or { + app.record_webhook_delivery(wh.id, event, 0, err.str()) + return + } + preview := if resp.body.len > 500 { resp.body[..500] } else { resp.body } + app.record_webhook_delivery(wh.id, event, resp.status_code, preview) +} diff --git a/webhook_routes.v b/webhook_routes.v new file mode 100644 index 0000000..ad3f2de --- /dev/null +++ b/webhook_routes.v @@ -0,0 +1,79 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import veb +import validation + +@['/:username/:repo_name/settings/webhooks'] +pub fn (mut app App) repo_webhooks(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.check_repo_owner(ctx.user.username, repo_name) { + return ctx.redirect_to_repository(username, repo_name) + } + webhooks := app.list_repo_webhooks(repo.id) + return $veb.html('templates/repo/webhooks.html') +} + +@['/:username/:repo_name/settings/webhooks/new'] +pub fn (mut app App) new_webhook(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.check_repo_owner(ctx.user.username, repo_name) { + return ctx.redirect_to_repository(username, repo_name) + } + return $veb.html('templates/new/webhook.html') +} + +@['/:username/:repo_name/settings/webhooks'; post] +pub fn (mut app App) handle_create_webhook(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.check_repo_owner(ctx.user.username, repo_name) { + return ctx.redirect_to_repository(username, repo_name) + } + url := ctx.form['url'].trim_space() + secret := ctx.form['secret'] + if validation.is_string_empty(url) || !(url.starts_with('http://') + || url.starts_with('https://')) { + return ctx.redirect('/${username}/${repo_name}/settings/webhooks/new') + } + mut events := []string{} + for ev in ['push', 'issue', 'pr', 'comment', 'release'] { + if ctx.form['event_${ev}'] == 'on' { + events << ev + } + } + events_str := if events.len == 0 { 'push,issue,pr,comment,release' } else { events.join(',') } + app.add_webhook(repo.id, url, secret, events_str) or { + ctx.error('Could not create webhook') + return ctx.redirect('/${username}/${repo_name}/settings/webhooks/new') + } + return ctx.redirect('/${username}/${repo_name}/settings/webhooks') +} + +@['/:username/:repo_name/settings/webhooks/:id/delete'; post] +pub fn (mut app App) handle_delete_webhook(mut ctx Context, username string, repo_name string, id string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.check_repo_owner(ctx.user.username, repo_name) { + return ctx.redirect_to_repository(username, repo_name) + } + wh := app.find_webhook_by_id(id.int()) or { return ctx.not_found() } + if wh.repo_id != repo.id { + return ctx.not_found() + } + app.delete_webhook(wh.id) or {} + return ctx.redirect('/${username}/${repo_name}/settings/webhooks') +} + +@['/:username/:repo_name/settings/webhooks/:id'] +pub fn (mut app App) view_webhook(mut ctx Context, username string, repo_name string, id string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.check_repo_owner(ctx.user.username, repo_name) { + return ctx.redirect_to_repository(username, repo_name) + } + webhook := app.find_webhook_by_id(id.int()) or { return ctx.not_found() } + if webhook.repo_id != repo.id { + return ctx.not_found() + } + deliveries := app.recent_webhook_deliveries(webhook.id, 30) + return $veb.html('templates/repo/webhook.html') +} -- 2.39.5