Replace the single global webhook secret with entity-scoped secrets stored
on each project and static site. Webhook-driven project autocreate is
removed — projects must exist before their URL can trigger deploys.
Also wires static-site webhooks (sync_trigger=push|tag), turning the
previously inert "push" trigger into a functional one: POST the site's
webhook URL from a Git provider and Tinyforge re-syncs on matching refs.
- Adds webhook_secret columns + unique indexes to projects and static_sites
- Per-entity GET/regenerate endpoints under /api/projects/{id}/webhook
and /api/sites/{id}/webhook (admin-only)
- Removes /api/settings/webhook-url and the global webhook panel
- Reusable WebhookPanel Svelte component on both detail pages, i18n in en/ru
- Tests for matcher (siteRefMatches, ParseImageRef) and handler (project
match/mismatch/404 and site push/manual/branch-skip)
This commit is contained in:
+45
-55
@@ -1,67 +1,13 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// FindProjectAndStage searches for a project whose image matches the parsed
|
||||
// image reference, then finds the stage whose tag pattern matches the incoming
|
||||
// tag. Returns (project, stage, found, error).
|
||||
//
|
||||
// Matching logic:
|
||||
// 1. Iterate all projects.
|
||||
// 2. Compare the project's Image field against the parsed image's FullName().
|
||||
// 3. For the matched project, iterate its stages and find one whose TagPattern
|
||||
// matches the incoming tag using path.Match (glob semantics).
|
||||
// 4. If multiple stages match, the first match wins (stages are ordered by name).
|
||||
func FindProjectAndStage(ctx context.Context, st *store.Store, parsed ParsedImage) (store.Project, store.Stage, bool, error) {
|
||||
projects, err := st.GetAllProjects()
|
||||
if err != nil {
|
||||
return store.Project{}, store.Stage{}, false, fmt.Errorf("get projects: %w", err)
|
||||
}
|
||||
|
||||
imageName := parsed.FullName()
|
||||
|
||||
for _, project := range projects {
|
||||
if !imageMatches(project.Image, imageName) {
|
||||
continue
|
||||
}
|
||||
|
||||
stage, found, err := matchStage(st, project.ID, parsed.Tag)
|
||||
if err != nil {
|
||||
return store.Project{}, store.Stage{}, false, fmt.Errorf("match stage for project %s: %w", project.Name, err)
|
||||
}
|
||||
if found {
|
||||
return project, stage, true, nil
|
||||
}
|
||||
|
||||
// Project matches but no stage pattern matches this tag.
|
||||
// Return project with empty stage — caller can decide what to do.
|
||||
// For now, we treat it as "not found" so auto-create doesn't fire
|
||||
// for known projects with no matching stage.
|
||||
return store.Project{}, store.Stage{}, false, nil
|
||||
}
|
||||
|
||||
return store.Project{}, store.Stage{}, false, nil
|
||||
}
|
||||
|
||||
// imageMatches checks if a project's stored image name matches the parsed
|
||||
// image name. The comparison is case-sensitive and supports the project image
|
||||
// being stored as either "owner/name" or just "name".
|
||||
func imageMatches(projectImage, incomingImage string) bool {
|
||||
if projectImage == incomingImage {
|
||||
return true
|
||||
}
|
||||
// Also match if the incoming image has an owner prefix but the project
|
||||
// only stores the bare name (or vice versa). This handles registries
|
||||
// that include or omit the owner segment.
|
||||
return false
|
||||
}
|
||||
|
||||
// matchStage finds the first stage of a project whose tag pattern matches the
|
||||
// given tag. Uses path.Match for glob-style matching (same as the registry poller).
|
||||
func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, error) {
|
||||
@@ -88,3 +34,47 @@ func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, erro
|
||||
|
||||
return store.Stage{}, false, nil
|
||||
}
|
||||
|
||||
// imageMatches reports whether an incoming image reference matches the
|
||||
// project's stored image. The comparison is case-sensitive and exact.
|
||||
func imageMatches(projectImage, incomingImage string) bool {
|
||||
return projectImage == incomingImage
|
||||
}
|
||||
|
||||
// siteRefMatches reports whether a Git ref (e.g. "refs/heads/main" or
|
||||
// "refs/tags/v1.2.3") targets the site's configured branch or tag pattern.
|
||||
//
|
||||
// For sync_trigger = "push": the ref must be a heads/<branch> ref whose
|
||||
// branch name equals site.Branch.
|
||||
// For sync_trigger = "tag": the ref must be a tags/<tag> ref whose tag name
|
||||
// matches site.TagPattern via glob semantics.
|
||||
// Unknown triggers return false (caller should have filtered these out).
|
||||
func siteRefMatches(site store.StaticSite, ref string) bool {
|
||||
switch site.SyncTrigger {
|
||||
case "push":
|
||||
branch, ok := strings.CutPrefix(ref, "refs/heads/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if site.Branch == "" {
|
||||
return true
|
||||
}
|
||||
return branch == site.Branch
|
||||
case "tag":
|
||||
tag, ok := strings.CutPrefix(ref, "refs/tags/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
pattern := site.TagPattern
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
matched, err := path.Match(pattern, tag)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return matched
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user