Setting up webhooks

This commit is contained in:
Matthew Knight 2026-02-13 23:05:42 -08:00
parent 50b0b29e10
commit c14cff4f51
No known key found for this signature in database
9 changed files with 129 additions and 60 deletions

View File

@ -2,6 +2,8 @@ package forgejo
import ( import (
"bytes" "bytes"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -45,6 +47,14 @@ type Comment struct {
Body string `json:"body"` 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) { 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) url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo)

View File

@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/models" "github.com/mattnite/forgejo-tickets/internal/models"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -37,17 +38,28 @@ func (h *RepoHandler) Create(c *gin.Context) {
slug := strings.TrimSpace(c.PostForm("slug")) slug := strings.TrimSpace(c.PostForm("slug"))
forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner")) forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner"))
forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo")) forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo"))
webhookSecret := strings.TrimSpace(c.PostForm("webhook_secret"))
active := c.PostForm("active") == "on" 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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{
"Error": "All fields are required", "Error": "All fields are required",
"Name": name, "Name": name,
"Slug": slug, "Slug": slug,
"ForgejoOwner": forgejoOwner, "ForgejoOwner": forgejoOwner,
"ForgejoRepo": forgejoRepo, "ForgejoRepo": forgejoRepo,
"WebhookSecret": webhookSecret, })
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 return
} }
@ -64,12 +76,11 @@ func (h *RepoHandler) Create(c *gin.Context) {
if err := h.deps.DB.Create(&repo).Error; err != nil { if err := h.deps.DB.Create(&repo).Error; err != nil {
log.Error().Err(err).Msg("create repo error") log.Error().Err(err).Msg("create repo error")
h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{
"Error": "Failed to create repo: " + err.Error(), "Error": "Failed to create repo: " + err.Error(),
"Name": name, "Name": name,
"Slug": slug, "Slug": slug,
"ForgejoOwner": forgejoOwner, "ForgejoOwner": forgejoOwner,
"ForgejoRepo": forgejoRepo, "ForgejoRepo": forgejoRepo,
"WebhookSecret": webhookSecret,
}) })
return 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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/edit", map[string]interface{}{
"Repo": repo, "Repo": repo,
"BaseURL": h.deps.Config.BaseURL, "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")) slug := strings.TrimSpace(c.PostForm("slug"))
forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner")) forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner"))
forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo")) forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo"))
webhookSecret := strings.TrimSpace(c.PostForm("webhook_secret"))
active := c.PostForm("active") == "on" active := c.PostForm("active") == "on"
if err := h.deps.DB.Model(&models.Repo{}).Where("id = ?", repoID).Updates(map[string]interface{}{ if err := h.deps.DB.Model(&models.Repo{}).Where("id = ?", repoID).Updates(map[string]interface{}{
"name": name, "name": name,
"slug": slug, "slug": slug,
"forgejo_owner": forgejoOwner, "forgejo_owner": forgejoOwner,
"forgejo_repo": forgejoRepo, "forgejo_repo": forgejoRepo,
"webhook_secret": webhookSecret, "active": active,
"active": active,
}).Error; err != nil { }).Error; err != nil {
log.Error().Err(err).Msg("update repo error") log.Error().Err(err).Msg("update repo error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update repo") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update repo")

View File

@ -15,12 +15,24 @@ type AuthHandler struct {
deps Dependencies 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) { func (h *AuthHandler) LoginForm(c *gin.Context) {
if auth.CurrentUser(c) != nil { if auth.CurrentUser(c) != nil {
c.Redirect(http.StatusSeeOther, "/tickets") c.Redirect(http.StatusSeeOther, "/tickets")
return 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) { 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) user, err := h.deps.Auth.Login(c.Request.Context(), email, password)
if err != nil { 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(), "Error": err.Error(),
"Email": email, "Email": email,
}) }))
return return
} }
if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil { if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil {
log.Error().Err(err).Msg("create session error") 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", "Error": "An unexpected error occurred",
"Email": email, "Email": email,
}) }))
return return
} }

View File

@ -2,6 +2,7 @@ package public
import ( import (
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/forgejo"
@ -30,6 +31,15 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) {
return 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) payload, err := forgejo.ParseWebhookPayload(body)
if err != nil { if err != nil {
log.Error().Err(err).Msg("webhook: parse error") log.Error().Err(err).Msg("webhook: parse error")

View File

@ -53,14 +53,16 @@ type Session struct {
} }
type Repo struct { type Repo struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"` Slug string `gorm:"uniqueIndex;not null" json:"slug"`
ForgejoOwner string `gorm:"not null" json:"forgejo_owner"` ForgejoOwner string `gorm:"not null" json:"forgejo_owner"`
ForgejoRepo string `gorm:"not null" json:"forgejo_repo"` ForgejoRepo string `gorm:"not null" json:"forgejo_repo"`
WebhookSecret string `gorm:"not null" json:"webhook_secret"` WebhookSecret string `gorm:"not null" json:"webhook_secret"`
Active bool `gorm:"not null;default:true" json:"active"` WebhookVerified bool `gorm:"not null;default:false" json:"webhook_verified"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` 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 { type Ticket struct {

View File

@ -9,13 +9,38 @@
<h1 class="text-2xl font-bold text-gray-900 mb-6">Edit Repo</h1> <h1 class="text-2xl font-bold text-gray-900 mb-6">Edit Repo</h1>
<div class="mb-6 rounded-md bg-blue-50 p-4"> {{if .Repo.WebhookVerified}}
<p class="text-sm text-blue-800"> <div class="mb-6 rounded-md bg-green-50 p-4 ring-1 ring-green-200">
<strong>Webhook URL:</strong> <div class="flex items-center gap-2">
<code class="ml-1 font-mono text-xs">{{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}}</code> <svg class="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
</p> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<p class="mt-1 text-xs text-blue-600">Configure this URL in Forgejo's webhook settings for this repo.</p> </svg>
<p class="text-sm font-semibold text-green-800">Webhook verified</p>
</div>
{{if .Repo.WebhookVerifiedAt}}
<p class="mt-1 text-xs text-green-600">Verified at {{.Repo.WebhookVerifiedAt.Format "Jan 02, 2006 3:04 PM"}}</p>
{{end}}
</div> </div>
{{else}}
<div class="mb-6 rounded-md bg-yellow-50 p-4 ring-1 ring-yellow-200">
<p class="text-sm font-semibold text-yellow-800 mb-3">Webhook setup required</p>
<p class="text-sm text-yellow-700 mb-3">Follow these steps to configure the Forgejo webhook:</p>
<ol class="list-decimal list-inside space-y-2 text-sm text-yellow-700">
<li>Go to <a href="{{.ForgejoURL}}/{{.Repo.ForgejoOwner}}/{{.Repo.ForgejoRepo}}/settings/hooks" target="_blank" class="text-blue-600 underline hover:text-blue-500">repo webhook settings in Forgejo</a></li>
<li>Click <strong>Add Webhook</strong> &rarr; <strong>Forgejo</strong></li>
<li>Set <strong>Target URL</strong> to:
<code class="block mt-1 bg-yellow-100 px-2 py-1 rounded text-xs font-mono select-all">{{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}}</code>
</li>
<li>Set <strong>Content Type</strong> to <code class="font-mono text-xs">application/json</code></li>
<li>Set <strong>Secret</strong> to:
<code class="block mt-1 bg-yellow-100 px-2 py-1 rounded text-xs font-mono select-all">{{.Repo.WebhookSecret}}</code>
</li>
<li>Under <strong>Trigger On</strong>, select <strong>Custom Events</strong> &rarr; check only <strong>Issues</strong></li>
<li>Click <strong>Add Webhook</strong>, then click into the webhook and hit <strong>Test Delivery</strong></li>
<li>Come back and <strong>reload this page</strong> to confirm verification</li>
</ol>
</div>
{{end}}
<form method="POST" action="/repos/{{.Repo.ID}}" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <form method="POST" action="/repos/{{.Repo.ID}}" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div> <div>
@ -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"> 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">
</div> </div>
<div>
<label for="webhook_secret" class="block text-sm font-medium text-gray-700">Webhook Secret</label>
<input type="text" name="webhook_secret" id="webhook_secret" required value="{{.Repo.WebhookSecret}}"
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">
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" name="active" id="active" {{if .Repo.Active}}checked{{end}} <input type="checkbox" name="active" id="active" {{if .Repo.Active}}checked{{end}}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"> class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">

View File

@ -14,7 +14,7 @@
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Forgejo</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Forgejo</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Webhook URL</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Webhook</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th>
<th class="px-4 py-3"></th> <th class="px-4 py-3"></th>
</tr> </tr>
@ -24,7 +24,13 @@
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{.Name}}</td> <td class="px-4 py-3 text-sm font-medium text-gray-900">{{.Name}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.ForgejoOwner}}/{{.ForgejoRepo}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{.ForgejoOwner}}/{{.ForgejoRepo}}</td>
<td class="px-4 py-3 text-sm text-gray-500 font-mono text-xs">/webhooks/forgejo/{{.Slug}}</td> <td class="px-4 py-3 text-sm">
{{if .WebhookVerified}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Verified</span>
{{else}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Unverified</span>
{{end}}
</td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
{{if .Active}} {{if .Active}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span> <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>

View File

@ -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"> 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">
</div> </div>
<div> <div class="rounded-md bg-blue-50 p-4">
<label for="webhook_secret" class="block text-sm font-medium text-gray-700">Webhook Secret</label> <p class="text-sm text-blue-800">After adding, you'll be shown instructions for setting up the Forgejo webhook.</p>
<input type="text" name="webhook_secret" id="webhook_secret" required
value="{{with .Data}}{{.WebhookSecret}}{{end}}"
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">
<p class="mt-1 text-xs text-gray-500">Must match the secret configured in Forgejo's webhook settings</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@ -31,17 +31,21 @@
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Sign in</button> <button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Sign in</button>
</form> </form>
{{with .Data}}
{{if or .GoogleEnabled .MicrosoftEnabled .AppleEnabled}}
<div class="mt-6"> <div class="mt-6">
<div class="relative"> <div class="relative">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-300"></div></div> <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-300"></div></div>
<div class="relative flex justify-center text-sm"><span class="bg-gray-50 px-2 text-gray-500">Or continue with</span></div> <div class="relative flex justify-center text-sm"><span class="bg-gray-50 px-2 text-gray-500">Or continue with</span></div>
</div> </div>
<div class="mt-6 grid grid-cols-3 gap-3"> <div class="mt-6 flex flex-wrap gap-3 justify-center">
<a href="/auth/google/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Google</a> {{if .GoogleEnabled}}<a href="/auth/google/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Google</a>{{end}}
<a href="/auth/microsoft/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Microsoft</a> {{if .MicrosoftEnabled}}<a href="/auth/microsoft/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Microsoft</a>{{end}}
<a href="/auth/apple/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Apple</a> {{if .AppleEnabled}}<a href="/auth/apple/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Apple</a>{{end}}
</div> </div>
</div> </div>
{{end}}
{{end}}
<p class="mt-6 text-center text-sm text-gray-500"> <p class="mt-6 text-center text-sm text-gray-500">
Don't have an account? <a href="/register" class="font-medium text-blue-600 hover:text-blue-500">Register</a> Don't have an account? <a href="/register" class="font-medium text-blue-600 hover:text-blue-500">Register</a>