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 @@
- Webhook URL:
- {{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}}
-
Configure this URL in Forgejo's webhook settings for this repo.
+ {{if .Repo.WebhookVerified}} +Webhook verified
+Verified at {{.Repo.WebhookVerifiedAt.Format "Jan 02, 2006 3:04 PM"}}
+ {{end}}Webhook setup required
+Follow these steps to configure the Forgejo webhook:
+{{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}}
+ application/json{{.Repo.WebhookSecret}}
+