From c14cff4f518edf56a2ef059a7cbf8555a127d204 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Fri, 13 Feb 2026 23:05:42 -0800 Subject: [PATCH] Setting up webhooks --- internal/forgejo/client.go | 10 ++++ internal/handlers/admin/repos.go | 56 +++++++++++++---------- internal/handlers/public/auth.go | 22 +++++++-- internal/handlers/public/webhook.go | 10 ++++ internal/models/models.go | 18 ++++---- web/templates/pages/admin/repos/edit.html | 43 ++++++++++++----- web/templates/pages/admin/repos/list.html | 10 +++- web/templates/pages/admin/repos/new.html | 8 +--- web/templates/pages/login.html | 12 +++-- 9 files changed, 129 insertions(+), 60 deletions(-) diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index beb7281..53c6929 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -2,6 +2,8 @@ package forgejo import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -45,6 +47,14 @@ type Comment struct { Body string `json:"body"` } +func GenerateWebhookSecret() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + func (c *Client) CreateIssue(owner, repo string, req CreateIssueRequest) (*Issue, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) diff --git a/internal/handlers/admin/repos.go b/internal/handlers/admin/repos.go index 6d3b614..ee9d56e 100644 --- a/internal/handlers/admin/repos.go +++ b/internal/handlers/admin/repos.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) @@ -37,17 +38,28 @@ func (h *RepoHandler) Create(c *gin.Context) { slug := strings.TrimSpace(c.PostForm("slug")) forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner")) forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo")) - webhookSecret := strings.TrimSpace(c.PostForm("webhook_secret")) active := c.PostForm("active") == "on" - if name == "" || slug == "" || forgejoOwner == "" || forgejoRepo == "" || webhookSecret == "" { + if name == "" || slug == "" || forgejoOwner == "" || forgejoRepo == "" { h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ - "Error": "All fields are required", - "Name": name, - "Slug": slug, - "ForgejoOwner": forgejoOwner, - "ForgejoRepo": forgejoRepo, - "WebhookSecret": webhookSecret, + "Error": "All fields are required", + "Name": name, + "Slug": slug, + "ForgejoOwner": forgejoOwner, + "ForgejoRepo": forgejoRepo, + }) + return + } + + webhookSecret, err := forgejo.GenerateWebhookSecret() + if err != nil { + log.Error().Err(err).Msg("generate webhook secret error") + h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ + "Error": "Failed to generate webhook secret", + "Name": name, + "Slug": slug, + "ForgejoOwner": forgejoOwner, + "ForgejoRepo": forgejoRepo, }) return } @@ -64,12 +76,11 @@ func (h *RepoHandler) Create(c *gin.Context) { if err := h.deps.DB.Create(&repo).Error; err != nil { log.Error().Err(err).Msg("create repo error") h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ - "Error": "Failed to create repo: " + err.Error(), - "Name": name, - "Slug": slug, - "ForgejoOwner": forgejoOwner, - "ForgejoRepo": forgejoRepo, - "WebhookSecret": webhookSecret, + "Error": "Failed to create repo: " + err.Error(), + "Name": name, + "Slug": slug, + "ForgejoOwner": forgejoOwner, + "ForgejoRepo": forgejoRepo, }) return } @@ -91,8 +102,9 @@ func (h *RepoHandler) EditForm(c *gin.Context) { } h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/edit", map[string]interface{}{ - "Repo": repo, - "BaseURL": h.deps.Config.BaseURL, + "Repo": repo, + "BaseURL": h.deps.Config.BaseURL, + "ForgejoURL": h.deps.Config.ForgejoURL, }) } @@ -107,16 +119,14 @@ func (h *RepoHandler) Update(c *gin.Context) { slug := strings.TrimSpace(c.PostForm("slug")) forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner")) forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo")) - webhookSecret := strings.TrimSpace(c.PostForm("webhook_secret")) active := c.PostForm("active") == "on" if err := h.deps.DB.Model(&models.Repo{}).Where("id = ?", repoID).Updates(map[string]interface{}{ - "name": name, - "slug": slug, - "forgejo_owner": forgejoOwner, - "forgejo_repo": forgejoRepo, - "webhook_secret": webhookSecret, - "active": active, + "name": name, + "slug": slug, + "forgejo_owner": forgejoOwner, + "forgejo_repo": forgejoRepo, + "active": active, }).Error; err != nil { log.Error().Err(err).Msg("update repo error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update repo") diff --git a/internal/handlers/public/auth.go b/internal/handlers/public/auth.go index 052f375..ffd12c1 100644 --- a/internal/handlers/public/auth.go +++ b/internal/handlers/public/auth.go @@ -15,12 +15,24 @@ type AuthHandler struct { deps Dependencies } +func (h *AuthHandler) loginData(extra map[string]interface{}) map[string]interface{} { + data := map[string]interface{}{ + "GoogleEnabled": h.deps.Config.GoogleClientID != "", + "MicrosoftEnabled": h.deps.Config.MicrosoftClientID != "", + "AppleEnabled": h.deps.Config.AppleClientID != "", + } + for k, v := range extra { + data[k] = v + } + return data +} + func (h *AuthHandler) LoginForm(c *gin.Context) { if auth.CurrentUser(c) != nil { c.Redirect(http.StatusSeeOther, "/tickets") return } - h.deps.Renderer.Render(c.Writer, c.Request, "login", nil) + h.deps.Renderer.Render(c.Writer, c.Request, "login", h.loginData(nil)) } func (h *AuthHandler) Login(c *gin.Context) { @@ -29,19 +41,19 @@ func (h *AuthHandler) Login(c *gin.Context) { user, err := h.deps.Auth.Login(c.Request.Context(), email, password) if err != nil { - h.deps.Renderer.Render(c.Writer, c.Request, "login", map[string]interface{}{ + h.deps.Renderer.Render(c.Writer, c.Request, "login", h.loginData(map[string]interface{}{ "Error": err.Error(), "Email": email, - }) + })) return } if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil { log.Error().Err(err).Msg("create session error") - h.deps.Renderer.Render(c.Writer, c.Request, "login", map[string]interface{}{ + h.deps.Renderer.Render(c.Writer, c.Request, "login", h.loginData(map[string]interface{}{ "Error": "An unexpected error occurred", "Email": email, - }) + })) return } diff --git a/internal/handlers/public/webhook.go b/internal/handlers/public/webhook.go index d04148e..b92cf8d 100644 --- a/internal/handlers/public/webhook.go +++ b/internal/handlers/public/webhook.go @@ -2,6 +2,7 @@ package public import ( "net/http" + "time" "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/forgejo" @@ -30,6 +31,15 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) { return } + if !repo.WebhookVerified { + now := time.Now() + h.deps.DB.Model(&repo).Updates(map[string]interface{}{ + "webhook_verified": true, + "webhook_verified_at": now, + }) + log.Info().Msgf("webhook: marked repo %q as verified", repoSlug) + } + payload, err := forgejo.ParseWebhookPayload(body) if err != nil { log.Error().Err(err).Msg("webhook: parse error") diff --git a/internal/models/models.go b/internal/models/models.go index 99a5cc6..53735e4 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -53,14 +53,16 @@ type Session struct { } type Repo struct { - ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` - Name string `gorm:"not null" json:"name"` - Slug string `gorm:"uniqueIndex;not null" json:"slug"` - ForgejoOwner string `gorm:"not null" json:"forgejo_owner"` - ForgejoRepo string `gorm:"not null" json:"forgejo_repo"` - WebhookSecret string `gorm:"not null" json:"webhook_secret"` - Active bool `gorm:"not null;default:true" json:"active"` - CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Slug string `gorm:"uniqueIndex;not null" json:"slug"` + ForgejoOwner string `gorm:"not null" json:"forgejo_owner"` + ForgejoRepo string `gorm:"not null" json:"forgejo_repo"` + WebhookSecret string `gorm:"not null" json:"webhook_secret"` + WebhookVerified bool `gorm:"not null;default:false" json:"webhook_verified"` + WebhookVerifiedAt *time.Time `json:"webhook_verified_at"` + Active bool `gorm:"not null;default:true" json:"active"` + CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` } type Ticket struct { diff --git a/web/templates/pages/admin/repos/edit.html b/web/templates/pages/admin/repos/edit.html index 5061e22..95709a1 100644 --- a/web/templates/pages/admin/repos/edit.html +++ b/web/templates/pages/admin/repos/edit.html @@ -9,13 +9,38 @@

Edit Repo

-
-

- Webhook URL: - {{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}} -

-

Configure this URL in Forgejo's webhook settings for this repo.

+ {{if .Repo.WebhookVerified}} +
+
+ + + +

Webhook verified

+
+ {{if .Repo.WebhookVerifiedAt}} +

Verified at {{.Repo.WebhookVerifiedAt.Format "Jan 02, 2006 3:04 PM"}}

+ {{end}}
+ {{else}} +
+

Webhook setup required

+

Follow these steps to configure the Forgejo webhook:

+
    +
  1. Go to repo webhook settings in Forgejo
  2. +
  3. Click Add WebhookForgejo
  4. +
  5. Set Target URL to: + {{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}} +
  6. +
  7. Set Content Type to application/json
  8. +
  9. Set Secret to: + {{.Repo.WebhookSecret}} +
  10. +
  11. Under Trigger On, select Custom Events → check only Issues
  12. +
  13. Click Add Webhook, then click into the webhook and hit Test Delivery
  14. +
  15. Come back and reload this page to confirm verification
  16. +
+
+ {{end}}
@@ -42,12 +67,6 @@ class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
-
- - -
-
diff --git a/web/templates/pages/admin/repos/list.html b/web/templates/pages/admin/repos/list.html index 6a8b5e5..c952ddf 100644 --- a/web/templates/pages/admin/repos/list.html +++ b/web/templates/pages/admin/repos/list.html @@ -14,7 +14,7 @@ Name Forgejo - Webhook URL + Webhook Active @@ -24,7 +24,13 @@ {{.Name}} {{.ForgejoOwner}}/{{.ForgejoRepo}} - /webhooks/forgejo/{{.Slug}} + + {{if .WebhookVerified}} + Verified + {{else}} + Unverified + {{end}} + {{if .Active}} Active diff --git a/web/templates/pages/admin/repos/new.html b/web/templates/pages/admin/repos/new.html index 041a3f1..10a98e2 100644 --- a/web/templates/pages/admin/repos/new.html +++ b/web/templates/pages/admin/repos/new.html @@ -46,12 +46,8 @@ class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
-
- - -

Must match the secret configured in Forgejo's webhook settings

+
+

After adding, you'll be shown instructions for setting up the Forgejo webhook.

diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html index 8807e19..06e98ef 100644 --- a/web/templates/pages/login.html +++ b/web/templates/pages/login.html @@ -31,17 +31,21 @@ + {{with .Data}} + {{if or .GoogleEnabled .MicrosoftEnabled .AppleEnabled}}
Or continue with
-
- Google - Microsoft - Apple +
+ {{if .GoogleEnabled}}Google{{end}} + {{if .MicrosoftEnabled}}Microsoft{{end}} + {{if .AppleEnabled}}Apple{{end}}
+ {{end}} + {{end}}

Don't have an account? Register