Files
tiny-forge/internal/webhook/matcher.go
T
alexei.dolgolyov 0632f512e6
Build / build (push) Successful in 10m25s
feat(webhook): per-project and per-site webhook URLs
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)
2026-04-23 15:18:19 +03:00

81 lines
2.0 KiB
Go

package webhook
import (
"fmt"
"path"
"strings"
"github.com/alexei/tinyforge/internal/store"
)
// 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) {
stages, err := st.GetStagesByProjectID(projectID)
if err != nil {
return store.Stage{}, false, fmt.Errorf("get stages: %w", err)
}
for _, stage := range stages {
pattern := stage.TagPattern
if pattern == "" {
pattern = "*"
}
matched, err := path.Match(pattern, tag)
if err != nil {
// Invalid pattern — skip this stage.
continue
}
if matched {
return stage, true, nil
}
}
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
}
}