diff --git a/cmd/server/main.go b/cmd/server/main.go index 102025b..5216003 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -148,16 +148,10 @@ func main() { dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey) - // Initialize webhook handler. - webhookHandler := webhook.NewHandler(db, dep, dockerClient) - - // Ensure webhook secret exists. - _, err = webhook.EnsureWebhookSecret(db) - if err != nil { - slog.Error("ensure webhook secret", "error", err) - os.Exit(1) - } - slog.Info("webhook secret configured (use /api/settings/webhook-url to retrieve)") + // Initialize webhook handler. Per-project and per-site secrets are stored + // on their respective rows; the static-site triggerer is wired in below + // once the site manager has been constructed. + webhookHandler := webhook.NewHandler(db, dep, nil) // Initialize registry poller. poller := registry.NewPoller(db, dep, encKey) @@ -284,6 +278,7 @@ func main() { // Initialize static site manager and health checker. staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey) + webhookHandler.SetSiteSyncTriggerer(staticSiteMgr) staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr) if err := staticSiteHealth.Start("2m"); err != nil { slog.Warn("failed to start static site health checker", "error", err) diff --git a/internal/api/router.go b/internal/api/router.go index 3a8ac34..0bd838d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -231,6 +231,10 @@ func (s *Server) Router() chi.Router { r.Put("/", s.updateProject) r.Delete("/", s.deleteProject) + // Per-project webhook URL management. + r.Get("/webhook", s.getProjectWebhook) + r.Post("/webhook/regenerate", s.regenerateProjectWebhook) + // Stage endpoints. r.Post("/stages", s.createStage) r.Put("/stages/{stage}", s.updateStage) @@ -293,6 +297,8 @@ func (s *Server) Router() chi.Router { r.Post("/deploy", s.deployStaticSite) r.Post("/stop", s.stopStaticSite) r.Post("/start", s.startStaticSite) + r.Get("/webhook", s.getStaticSiteWebhook) + r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook) r.Post("/secrets", s.createStaticSiteSecret) r.Put("/secrets/{sid}", s.updateStaticSiteSecret) r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret) @@ -372,8 +378,6 @@ func (s *Server) Router() chi.Router { // Settings endpoints. r.Put("/settings", s.updateSettings) - r.Get("/settings/webhook-url", s.getWebhookURL) - r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) // Docker management. r.Post("/docker/prune-images", s.pruneImages) diff --git a/internal/api/settings.go b/internal/api/settings.go index bba9a38..e940ca4 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -14,7 +14,6 @@ import ( "github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/volume" - "github.com/alexei/tinyforge/internal/webhook" ) // settingsRequest is the expected JSON body for updating settings. @@ -275,40 +274,6 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } -// getWebhookURL handles GET /api/settings/webhook-url. -func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) { - settings, err := s.store.GetSettings() - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) - return - } - - webhookPath := "" - if settings.WebhookSecret != "" { - webhookPath = "/api/webhook/" + settings.WebhookSecret - } - - respondJSON(w, http.StatusOK, map[string]string{ - "webhook_url": webhookPath, - }) -} - -// regenerateWebhookSecret handles POST /api/settings/regenerate. -func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) { - secret, err := webhook.RegenerateWebhookSecret(s.store) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error()) - return - } - - webhookURL := "/api/webhook/" + secret - - respondJSON(w, http.StatusOK, map[string]string{ - "webhook_url": webhookURL, - "webhook_secret": secret, - }) -} - // listNpmCertificates handles GET /api/settings/npm-certificates. // It authenticates to NPM using the stored credentials and returns only wildcard certificates. func (s *Server) listNpmCertificates(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/webhooks.go b/internal/api/webhooks.go new file mode 100644 index 0000000..02560f8 --- /dev/null +++ b/internal/api/webhooks.go @@ -0,0 +1,122 @@ +package api + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/alexei/tinyforge/internal/store" +) + +// webhookURLResponse is the common payload returned by every webhook endpoint. +// Clients never see raw secrets except at issue/rotate time via these fields; +// the URL shape is "/api/webhook/..." so callers can prepend their own origin. +type webhookURLResponse struct { + WebhookURL string `json:"webhook_url"` + WebhookSecret string `json:"webhook_secret"` +} + +// getProjectWebhook handles GET /api/projects/{id}/webhook. +// Returns the project's webhook URL + secret, generating one lazily if the +// project predates the per-project webhook migration. +func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + secret, err := s.store.EnsureProjectWebhookSecret(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + slog.Error("get project webhook: ensure secret", "project", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to get webhook secret") + return + } + + respondJSON(w, http.StatusOK, webhookURLResponse{ + WebhookURL: "/api/webhook/" + secret, + WebhookSecret: secret, + }) +} + +// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate. +// Rotates the project's webhook secret, invalidating the old URL. +func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // Verify project exists before rotating. + if _, err := s.store.GetProjectByID(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + slog.Error("regenerate project webhook: lookup", "project", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to get project") + return + } + + secret := uuid.New().String() + if err := s.store.SetProjectWebhookSecret(id, secret); err != nil { + slog.Error("regenerate project webhook: set secret", "project", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret") + return + } + + slog.Info("project webhook secret rotated", "project", id) + respondJSON(w, http.StatusOK, webhookURLResponse{ + WebhookURL: "/api/webhook/" + secret, + WebhookSecret: secret, + }) +} + +// getStaticSiteWebhook handles GET /api/sites/{id}/webhook. +func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + secret, err := s.store.EnsureStaticSiteWebhookSecret(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "static site") + return + } + slog.Error("get site webhook: ensure secret", "site", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to get webhook secret") + return + } + + respondJSON(w, http.StatusOK, webhookURLResponse{ + WebhookURL: "/api/webhook/sites/" + secret, + WebhookSecret: secret, + }) +} + +// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate. +func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if _, err := s.store.GetStaticSiteByID(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "static site") + return + } + slog.Error("regenerate site webhook: lookup", "site", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to get static site") + return + } + + secret := uuid.New().String() + if err := s.store.SetStaticSiteWebhookSecret(id, secret); err != nil { + slog.Error("regenerate site webhook: set secret", "site", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret") + return + } + + slog.Info("static site webhook secret rotated", "site", id) + respondJSON(w, http.StatusOK, webhookURLResponse{ + WebhookURL: "/api/webhook/sites/" + secret, + WebhookSecret: secret, + }) +} diff --git a/internal/store/models.go b/internal/store/models.go index eeec2bc..5ccf0f7 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -11,6 +11,7 @@ type Project struct { Env string `json:"env"` // JSON-encoded map Volumes string `json:"volumes"` // JSON-encoded map NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global + WebhookSecret string `json:"-"` // per-project webhook secret; never serialized directly CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -57,7 +58,6 @@ type Settings struct { NpmURL string `json:"npm_url"` NpmEmail string `json:"npm_email"` NpmPassword string `json:"npm_password"` - WebhookSecret string `json:"webhook_secret"` PollingInterval string `json:"polling_interval"` BaseVolumePath string `json:"base_volume_path"` SSLCertificateID int `json:"ssl_certificate_id"` @@ -219,6 +219,7 @@ type StaticSite struct { Error string `json:"error"` StorageEnabled bool `json:"storage_enabled"` StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited + WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } diff --git a/internal/store/projects.go b/internal/store/projects.go index aca4959..16788f3 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -8,16 +8,25 @@ import ( "github.com/google/uuid" ) -// CreateProject inserts a new project and returns it. +// projectCols is the canonical column list for projects queries. +const projectCols = `id, name, registry, image, port, healthcheck, env, volumes, + npm_access_list_id, webhook_secret, created_at, updated_at` + +// CreateProject inserts a new project and returns it. A webhook secret is +// generated automatically if one is not already set on the input. func (s *Store) CreateProject(p Project) (Project, error) { p.ID = uuid.New().String() p.CreatedAt = Now() p.UpdatedAt = p.CreatedAt + if p.WebhookSecret == "" { + p.WebhookSecret = uuid.New().String() + } _, err := s.db.Exec( - `INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.NpmAccessListID, p.CreatedAt, p.UpdatedAt, + `INSERT INTO projects (`+projectCols+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, + p.NpmAccessListID, p.WebhookSecret, p.CreatedAt, p.UpdatedAt, ) if err != nil { return Project{}, fmt.Errorf("insert project: %w", err) @@ -29,9 +38,9 @@ func (s *Store) CreateProject(p Project) (Project, error) { func (s *Store) GetProjectByID(id string) (Project, error) { var p Project err := s.db.QueryRow( - `SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at - FROM projects WHERE id = ?`, id, - ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.CreatedAt, &p.UpdatedAt) + `SELECT `+projectCols+` FROM projects WHERE id = ?`, id, + ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, + &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound) } @@ -41,11 +50,30 @@ func (s *Store) GetProjectByID(id string) (Project, error) { return p, nil } +// GetProjectByWebhookSecret looks up a project by its webhook secret. +// Returns ErrNotFound if no project has this secret (including empty). +func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) { + if secret == "" { + return Project{}, ErrNotFound + } + var p Project + err := s.db.QueryRow( + `SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret, + ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, + &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Project{}, ErrNotFound + } + if err != nil { + return Project{}, fmt.Errorf("query project by webhook secret: %w", err) + } + return p, nil +} + // GetAllProjects returns every project ordered by name. func (s *Store) GetAllProjects() ([]Project, error) { rows, err := s.db.Query( - `SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at - FROM projects ORDER BY name`, + `SELECT ` + projectCols + ` FROM projects ORDER BY name`, ) if err != nil { return nil, fmt.Errorf("query projects: %w", err) @@ -55,7 +83,8 @@ func (s *Store) GetAllProjects() ([]Project, error) { projects := []Project{} for rows.Next() { var p Project - if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, + &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) @@ -66,8 +95,7 @@ func (s *Store) GetAllProjects() ([]Project, error) { // GetProjectsByImage returns all projects using the given image, newest first. func (s *Store) GetProjectsByImage(image string) ([]Project, error) { rows, err := s.db.Query( - `SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at - FROM projects WHERE image = ? ORDER BY created_at DESC`, image, + `SELECT `+projectCols+` FROM projects WHERE image = ? ORDER BY created_at DESC`, image, ) if err != nil { return nil, fmt.Errorf("query projects by image: %w", err) @@ -77,7 +105,8 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) { projects := []Project{} for rows.Next() { var p Project - if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, + &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) @@ -85,13 +114,16 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) { return projects, rows.Err() } -// UpdateProject updates an existing project's mutable fields. +// UpdateProject updates an existing project's mutable fields. Webhook secret +// is intentionally not updated here — use SetProjectWebhookSecret instead. func (s *Store) UpdateProject(p Project) error { p.UpdatedAt = Now() result, err := s.db.Exec( - `UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, npm_access_list_id=?, updated_at=? + `UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, + npm_access_list_id=?, updated_at=? WHERE id=?`, - p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.NpmAccessListID, p.UpdatedAt, p.ID, + p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, + p.NpmAccessListID, p.UpdatedAt, p.ID, ) if err != nil { return fmt.Errorf("update project: %w", err) @@ -103,6 +135,41 @@ func (s *Store) UpdateProject(p Project) error { return nil } +// SetProjectWebhookSecret assigns a webhook secret to a project. +// Pass an empty string to disable webhook access for the project. +func (s *Store) SetProjectWebhookSecret(id, secret string) error { + result, err := s.db.Exec( + `UPDATE projects SET webhook_secret=?, updated_at=? WHERE id=?`, + secret, Now(), id, + ) + if err != nil { + return fmt.Errorf("set project webhook secret: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("project %s: %w", id, ErrNotFound) + } + return nil +} + +// EnsureProjectWebhookSecret returns the current webhook secret for a project, +// generating one on the fly if the stored value is empty (lazy backfill for +// projects created before the per-project webhook migration). +func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) { + project, err := s.GetProjectByID(id) + if err != nil { + return "", err + } + if project.WebhookSecret != "" { + return project.WebhookSecret, nil + } + secret := uuid.New().String() + if err := s.SetProjectWebhookSecret(id, secret); err != nil { + return "", err + } + return secret, nil +} + // DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys. func (s *Store) DeleteProject(id string) error { result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id) diff --git a/internal/store/settings.go b/internal/store/settings.go index d244755..25caa24 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -10,7 +10,7 @@ func (s *Store) GetSettings() (Settings, error) { var wildcardDNS, npmRemote, backupEnabled int err := s.db.QueryRow( `SELECT domain, server_ip, public_ip, network, subdomain_pattern, notification_url, - npm_url, npm_email, npm_password, webhook_secret, polling_interval, + npm_url, npm_email, npm_password, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, allowed_volume_paths, wildcard_dns, dns_provider, cloudflare_api_token, cloudflare_zone_id, @@ -21,7 +21,7 @@ func (s *Store) GetSettings() (Settings, error) { updated_at FROM settings WHERE id = 1`, ).Scan(&st.Domain, &st.ServerIP, &st.PublicIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, - &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, + &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider, &st.CloudflareAPIToken, &st.CloudflareZoneID, @@ -57,7 +57,7 @@ func (s *Store) UpdateSettings(st Settings) error { _, err := s.db.Exec( `UPDATE settings SET domain=?, server_ip=?, public_ip=?, network=?, subdomain_pattern=?, notification_url=?, - npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, + npm_url=?, npm_email=?, npm_password=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, allowed_volume_paths=?, wildcard_dns=?, dns_provider=?, cloudflare_api_token=?, cloudflare_zone_id=?, @@ -68,7 +68,7 @@ func (s *Store) UpdateSettings(st Settings) error { updated_at=? WHERE id = 1`, st.Domain, st.ServerIP, st.PublicIP, st.Network, st.SubdomainPattern, st.NotificationURL, - st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, + st.NpmURL, st.NpmEmail, st.NpmPassword, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.AllowedVolumePaths, wildcardDNS, st.DNSProvider, st.CloudflareAPIToken, st.CloudflareZoneID, diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go index 346ed8c..8187942 100644 --- a/internal/store/static_sites.go +++ b/internal/store/static_sites.go @@ -13,23 +13,27 @@ import ( const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path, access_token, domain, mode, render_markdown, sync_trigger, tag_pattern, container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error, - storage_enabled, storage_limit_mb, created_at, updated_at` + storage_enabled, storage_limit_mb, webhook_secret, created_at, updated_at` -// CreateStaticSite inserts a new static site and returns it. +// CreateStaticSite inserts a new static site and returns it. A webhook secret +// is generated automatically if one is not already set on the input. func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) { site.ID = uuid.New().String() site.CreatedAt = Now() site.UpdatedAt = site.CreatedAt + if site.WebhookSecret == "" { + site.WebhookSecret = uuid.New().String() + } _, err := s.db.Exec( `INSERT INTO static_sites (`+staticSiteCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode, BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt, site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB, - site.CreatedAt, site.UpdatedAt, + site.WebhookSecret, site.CreatedAt, site.UpdatedAt, ) if err != nil { return StaticSite{}, fmt.Errorf("insert static site: %w", err) @@ -222,7 +226,7 @@ func scanStaticSiteRow(row *sql.Row) (StaticSite, error) { &renderMarkdown, &site.SyncTrigger, &site.TagPattern, &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, &site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB, - &site.CreatedAt, &site.UpdatedAt, + &site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt, ) if err != nil { return StaticSite{}, err @@ -242,7 +246,7 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) { &renderMarkdown, &site.SyncTrigger, &site.TagPattern, &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, &site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB, - &site.CreatedAt, &site.UpdatedAt, + &site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt, ) if err != nil { return StaticSite{}, fmt.Errorf("scan static site: %w", err) @@ -251,3 +255,55 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) { site.StorageEnabled = storageEnabled != 0 return site, nil } + +// GetStaticSiteByWebhookSecret looks up a static site by its webhook secret. +// Returns ErrNotFound if no site has this secret (including empty). +func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error) { + if secret == "" { + return StaticSite{}, ErrNotFound + } + site, err := scanStaticSiteRow(s.db.QueryRow( + `SELECT `+staticSiteCols+` FROM static_sites WHERE webhook_secret = ?`, secret, + )) + if errors.Is(err, sql.ErrNoRows) { + return StaticSite{}, ErrNotFound + } + if err != nil { + return StaticSite{}, fmt.Errorf("query static site by webhook secret: %w", err) + } + return site, nil +} + +// SetStaticSiteWebhookSecret assigns a webhook secret to a static site. +// Pass an empty string to disable webhook access for the site. +func (s *Store) SetStaticSiteWebhookSecret(id, secret string) error { + result, err := s.db.Exec( + `UPDATE static_sites SET webhook_secret=?, updated_at=? WHERE id=?`, + secret, Now(), id, + ) + if err != nil { + return fmt.Errorf("set static site webhook secret: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("static site %s: %w", id, ErrNotFound) + } + return nil +} + +// EnsureStaticSiteWebhookSecret returns the current webhook secret for a site, +// generating one on the fly if the stored value is empty (lazy backfill). +func (s *Store) EnsureStaticSiteWebhookSecret(id string) (string, error) { + site, err := s.GetStaticSiteByID(id) + if err != nil { + return "", err + } + if site.WebhookSecret != "" { + return site.WebhookSecret, nil + } + secret := uuid.New().String() + if err := s.SetStaticSiteWebhookSecret(id, secret); err != nil { + return "", err + } + return secret, nil +} diff --git a/internal/store/store.go b/internal/store/store.go index 8b9f151..96bc456 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -128,6 +128,11 @@ func (s *Store) runMigrations() error { // Add persistent storage columns to static_sites (2026-04-12). `ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`, + // Per-project + per-site webhook secrets (2026-04-23). Global + // settings.webhook_secret is deprecated; its column is retained to + // avoid a destructive migration on SQLite. + `ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE static_sites ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`, } // Additive stack tables (2026-04-16). Created here rather than in the @@ -194,6 +199,8 @@ func (s *Store) runMigrations() error { `CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`, `CREATE INDEX IF NOT EXISTS idx_stack_revisions_stack_id ON stack_revisions(stack_id)`, `CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_webhook_secret ON projects(webhook_secret) WHERE webhook_secret != ''`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_static_sites_webhook_secret ON static_sites(webhook_secret) WHERE webhook_secret != ''`, } for _, idx := range indexes { if _, err := s.db.Exec(idx); err != nil { diff --git a/internal/webhook/autocreate.go b/internal/webhook/autocreate.go deleted file mode 100644 index e306397..0000000 --- a/internal/webhook/autocreate.go +++ /dev/null @@ -1,91 +0,0 @@ -package webhook - -import ( - "context" - "fmt" - "log/slog" - "strings" - - "github.com/alexei/tinyforge/internal/docker" - "github.com/alexei/tinyforge/internal/store" -) - -// AutoCreateProject creates a new project and a default "dev" stage from an -// unknown image. It inspects the Docker image to extract defaults (EXPOSE port, -// healthcheck, labels). -// -// The auto-created project uses: -// - Name: derived from image name (e.g. "web-app-launcher") -// - Image: full owner/name path -// - Port: first EXPOSE port from the image, or 0 if none -// - Healthcheck: from image HEALTHCHECK instruction, if present -// - A single "dev" stage with auto_deploy=true and tag_pattern="*" -func AutoCreateProject( - ctx context.Context, - st *store.Store, - inspector ImageInspector, - parsed ParsedImage, -) (store.Project, store.Stage, error) { - // Build the full image ref for inspection (registry/owner/name:tag). - imageRef := buildImageRef(parsed) - - var port int - var healthcheck string - - // Attempt to inspect the image for metadata. If inspection fails (image - // not pulled locally), proceed with zero defaults. - if inspector != nil { - info, err := inspector.InspectImage(ctx, imageRef) - if err != nil { - slog.Warn("webhook: image inspection failed, using defaults", "image", imageRef, "error", err) - } else { - port = docker.ExtractPort(info.ExposedPorts) - healthcheck = info.Healthcheck - } - } - - project, err := st.CreateProject(store.Project{ - Name: parsed.Name, - Registry: parsed.Registry, - Image: parsed.FullName(), - Port: port, - Healthcheck: healthcheck, - Env: "{}", - Volumes: "{}", - }) - if err != nil { - return store.Project{}, store.Stage{}, fmt.Errorf("create project: %w", err) - } - - stage, err := st.CreateStage(store.Stage{ - ProjectID: project.ID, - Name: "dev", - TagPattern: "*", - AutoDeploy: true, - MaxInstances: 1, - }) - if err != nil { - return store.Project{}, store.Stage{}, fmt.Errorf("create default stage: %w", err) - } - - return project, stage, nil -} - -// buildImageRef reconstructs a pullable image reference from parsed components. -func buildImageRef(parsed ParsedImage) string { - var parts []string - if parsed.Registry != "" { - parts = append(parts, parsed.Registry) - } - if parsed.Owner != "" { - parts = append(parts, parsed.Owner) - } - parts = append(parts, parsed.Name) - - ref := strings.Join(parts, "/") - if parsed.Tag != "" { - ref += ":" + parsed.Tag - } - return ref -} - diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 7dbb58c..9182ce6 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -2,17 +2,15 @@ package webhook import ( "context" - "crypto/subtle" "encoding/json" + "errors" "fmt" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/store" ) @@ -22,18 +20,27 @@ type DeployTriggerer interface { TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error } -// ImageInspector abstracts Docker image inspection for testability. -type ImageInspector interface { - InspectImage(ctx context.Context, imageRef string) (docker.ImageInfo, error) +// SiteSyncTriggerer is called when a static-site webhook determines a sync +// should happen. The manager handles the actual git-pull + redeploy. +type SiteSyncTriggerer interface { + Deploy(ctx context.Context, siteID string, force bool) error } -// Payload is the expected JSON body for a webhook request. +// Payload is the expected JSON body for a project webhook request. type Payload struct { // Image is the full image reference including tag, e.g. // "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123". Image string `json:"image"` } +// SitePayload is the expected JSON body for a static-site webhook request. +// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref +// matters for branch filtering. Body is optional — an empty body triggers +// a sync using the site's configured branch. +type SitePayload struct { + Ref string `json:"ref"` // e.g. "refs/heads/main"; optional +} + // ParsedImage holds the components extracted from a full image reference string. type ParsedImage struct { // Registry is the hostname, e.g. "git.dolgolyov-family.by". @@ -104,23 +111,34 @@ func ParseImageRef(ref string) (ParsedImage, error) { // Handler is the HTTP handler for webhook requests. type Handler struct { - store *store.Store - deployer DeployTriggerer - inspector ImageInspector + store *store.Store + deployer DeployTriggerer + sites SiteSyncTriggerer } -// NewHandler creates a new webhook Handler. -func NewHandler(st *store.Store, deployer DeployTriggerer, inspector ImageInspector) *Handler { - return &Handler{ - store: st, - deployer: deployer, - inspector: inspector, - } +// NewHandler creates a new webhook Handler. The sites triggerer is optional +// and may be nil (site webhooks will return 404). +func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler { + return &Handler{store: st, deployer: deployer, sites: sites} } -// Route returns a chi router with the webhook endpoint mounted. +// SetSiteSyncTriggerer injects the static-site manager after construction. +// The site manager depends on the store + docker client, which are wired up +// in the same startup path as the handler; this setter lets callers defer the +// dependency if needed. +func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) { + h.sites = s +} + +// Route returns a chi router with the webhook endpoints mounted. +// +// Routes: +// +// POST /{secret} — per-project deploy trigger +// POST /sites/{secret} — per-site sync trigger func (h *Handler) Route() chi.Router { r := chi.NewRouter() + r.Post("/sites/{secret}", h.handleSiteWebhook) r.Post("/{secret}", h.handleWebhook) return r } @@ -137,9 +155,13 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) { respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg}) } -// handleWebhook processes an incoming webhook request. -// URL format: POST /api/webhook/{secret-uuid} -// Returns 404 for invalid secrets (no information leak). +// handleWebhook processes an incoming project webhook request. +// +// URL: POST /api/webhook/{secret} +// +// The secret identifies exactly one project. Stage routing is delegated to +// the project's configured stages (tag_pattern match). Returns 404 for +// unknown secrets (no information leak). func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -149,20 +171,17 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { return } - // Validate the webhook secret against stored settings. - settings, err := h.store.GetSettings() + project, err := h.store.GetProjectByWebhookSecret(secret) if err != nil { - slog.Error("webhook: failed to read settings", "error", err) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + slog.Error("webhook: project lookup failed", "error", err) http.NotFound(w, r) return } - if settings.WebhookSecret == "" || subtle.ConstantTimeCompare([]byte(settings.WebhookSecret), []byte(secret)) != 1 { - http.NotFound(w, r) - return - } - - // Parse the request body. var payload Payload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload") @@ -180,37 +199,48 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { return } - // Default tag to "latest" if omitted. if parsed.Tag == "" { parsed.Tag = "latest" } - slog.Info("webhook: received push", "image", parsed.FullName(), "tag", parsed.Tag) - - // Look up a matching project by image name. - project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed) - if err != nil { - slog.Error("webhook: lookup error", "error", err) - respondWebhookError(w, http.StatusInternalServerError, "internal error") + // Guardrail: refuse payloads whose image doesn't match the project's + // configured image. Not a security control (the secret already scopes + // access) — just a misconfiguration check that prevents accidental + // cross-project deploys from a misaimed CI pipeline. + if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) { + slog.Warn("webhook: image mismatch", + "project", project.Name, "expected", project.Image, "received", parsed.FullName()) + respondWebhookError(w, http.StatusBadRequest, + fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image)) return } + slog.Info("webhook: received push", + "project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag) + + stage, found, err := matchStage(h.store, project.ID, parsed.Tag) + if err != nil { + slog.Error("webhook: stage match failed", "project", project.Name, "error", err) + respondWebhookError(w, http.StatusInternalServerError, "internal error") + return + } if !found { - // Unknown project — auto-create with defaults from image inspection. - slog.Info("webhook: unknown image, auto-creating project", "image", parsed.FullName()) - project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed) - if err != nil { - slog.Error("webhook: auto-create failed", "error", err) - respondWebhookError(w, http.StatusInternalServerError, "failed to auto-create project") - return - } - slog.Info("webhook: auto-created project", "project", project.Name, "id", project.ID, "stage", stage.Name) + slog.Info("webhook: no stage matches tag", + "project", project.Name, "tag", parsed.Tag) + respondWebhookJSON(w, http.StatusOK, map[string]any{ + "success": true, "deploy": false, "project": project.Name, + "reason": "no stage pattern matched tag", + }) + return } - // Only deploy if auto_deploy is enabled for the matched stage. if !stage.AutoDeploy { - slog.Info("webhook: auto_deploy disabled, skipping", "project", project.Name, "stage", stage.Name) - respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": false, "project": project.Name, "stage": stage.Name}) + slog.Info("webhook: auto_deploy disabled, skipping", + "project", project.Name, "stage", stage.Name) + respondWebhookJSON(w, http.StatusOK, map[string]any{ + "success": true, "deploy": false, + "project": project.Name, "stage": stage.Name, + }) return } @@ -220,44 +250,86 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { return } - slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", parsed.Tag) - respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag}) + slog.Info("webhook: triggered deploy", + "project", project.Name, "stage", stage.Name, "tag", parsed.Tag) + respondWebhookJSON(w, http.StatusOK, map[string]any{ + "success": true, "deploy": true, + "project": project.Name, "stage": stage.Name, "tag": parsed.Tag, + }) } -// EnsureWebhookSecret checks whether a webhook secret exists in settings. -// If not, it generates a new UUID and stores it. Returns the current secret. -func EnsureWebhookSecret(st *store.Store) (string, error) { - settings, err := st.GetSettings() +// handleSiteWebhook processes an incoming static-site webhook request. +// +// URL: POST /api/webhook/sites/{secret} +// +// The secret identifies exactly one static site. If the payload includes a +// ref (Git push event), it must match the site's configured branch (when the +// site's sync_trigger is "push"). For tag-based sync, the ref must match the +// stored tag pattern. Manual-trigger sites ignore webhooks entirely. +func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if h.sites == nil { + http.NotFound(w, r) + return + } + + secret := chi.URLParam(r, "secret") + if secret == "" { + http.NotFound(w, r) + return + } + + site, err := h.store.GetStaticSiteByWebhookSecret(secret) if err != nil { - return "", fmt.Errorf("get settings: %w", err) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + slog.Error("webhook: site lookup failed", "error", err) + http.NotFound(w, r) + return } - if settings.WebhookSecret != "" { - return settings.WebhookSecret, nil + // Manual sites do not auto-sync via webhook. Return success but skip. + if site.SyncTrigger == "manual" { + slog.Info("webhook: site sync_trigger=manual, skipping", + "site", site.Name) + respondWebhookJSON(w, http.StatusOK, map[string]any{ + "success": true, "sync": false, "site": site.Name, + "reason": "sync_trigger is manual", + }) + return } - settings.WebhookSecret = uuid.New().String() - if err := st.UpdateSettings(settings); err != nil { - return "", fmt.Errorf("store webhook secret: %w", err) + // Body is optional — decode best-effort. + var payload SitePayload + if r.ContentLength > 0 { + _ = json.NewDecoder(r.Body).Decode(&payload) } - slog.Info("webhook: generated new secret") - return settings.WebhookSecret, nil -} - -// RegenerateWebhookSecret generates a new webhook secret UUID, replacing and -// invalidating the old one. Returns the new secret. -func RegenerateWebhookSecret(st *store.Store) (string, error) { - settings, err := st.GetSettings() - if err != nil { - return "", fmt.Errorf("get settings: %w", err) - } - - settings.WebhookSecret = uuid.New().String() - if err := st.UpdateSettings(settings); err != nil { - return "", fmt.Errorf("store webhook secret: %w", err) - } - - slog.Info("webhook: regenerated secret") - return settings.WebhookSecret, nil + if payload.Ref != "" && !siteRefMatches(site, payload.Ref) { + slog.Info("webhook: site ref does not match configured branch/tag", + "site", site.Name, "ref", payload.Ref, + "branch", site.Branch, "tag_pattern", site.TagPattern, + "trigger", site.SyncTrigger) + respondWebhookJSON(w, http.StatusOK, map[string]any{ + "success": true, "sync": false, "site": site.Name, + "reason": "ref does not match configured branch or tag pattern", + }) + return + } + + // Fire and forget — sync may take a while (git fetch + container rebuild). + go func(siteID, siteName string) { + if err := h.sites.Deploy(context.Background(), siteID, false); err != nil { + slog.Error("webhook: site sync failed", "site", siteName, "error", err) + } + }(site.ID, site.Name) + + _ = ctx + slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref) + respondWebhookJSON(w, http.StatusOK, map[string]any{ + "success": true, "sync": true, "site": site.Name, + }) } diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go new file mode 100644 index 0000000..54b8f2b --- /dev/null +++ b/internal/webhook/handler_test.go @@ -0,0 +1,311 @@ +package webhook_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/webhook" +) + +// fakeDeployer records the last trigger for assertion. +type fakeDeployer struct { + mu sync.Mutex + calls int + lastProj string + lastStg string + lastTag string + err error +} + +func (f *fakeDeployer) TriggerDeploy(_ context.Context, projectID, stageID, tag string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + f.lastProj = projectID + f.lastStg = stageID + f.lastTag = tag + return f.err +} + +// fakeSiteTriggerer records Deploy calls. +type fakeSiteTriggerer struct { + mu sync.Mutex + calls int + done chan struct{} +} + +func (f *fakeSiteTriggerer) Deploy(_ context.Context, _ string, _ bool) error { + f.mu.Lock() + f.calls++ + ch := f.done + f.mu.Unlock() + if ch != nil { + select { + case ch <- struct{}{}: + default: + } + } + return nil +} + +func newRouter(t *testing.T, h *webhook.Handler) chi.Router { + t.Helper() + r := chi.NewRouter() + r.Mount("/api/webhook", h.Route()) + return r +} + +func newStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("create store: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func doJSON(t *testing.T, r chi.Router, method, path, body string) (*http.Response, string) { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + resp := w.Result() + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp, string(b) +} + +func TestProjectWebhook_UnknownSecretReturns404(t *testing.T) { + t.Parallel() + st := newStore(t) + h := webhook.NewHandler(st, &fakeDeployer{}, nil) + r := newRouter(t, h) + + resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/bogus-secret", `{"image":"x"}`) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +func TestProjectWebhook_DeploysOnMatchingStage(t *testing.T) { + t.Parallel() + st := newStore(t) + + p, err := st.CreateProject(store.Project{ + Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + stage, err := st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "dev-*", AutoDeploy: true, MaxInstances: 1, + }) + if err != nil { + t.Fatalf("create stage: %v", err) + } + + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + + path := "/api/webhook/" + p.WebhookSecret + resp, body := doJSON(t, r, http.MethodPost, path, `{"image":"alexei/app:dev-abc"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + if dep.calls != 1 { + t.Fatalf("expected 1 deploy call, got %d", dep.calls) + } + if dep.lastProj != p.ID || dep.lastStg != stage.ID || dep.lastTag != "dev-abc" { + t.Errorf("deploy called with wrong args: proj=%s stage=%s tag=%s", + dep.lastProj, dep.lastStg, dep.lastTag) + } +} + +func TestProjectWebhook_ImageMismatchRejected(t *testing.T) { + t.Parallel() + st := newStore(t) + p, err := st.CreateProject(store.Project{ + Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + if _, err := st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, + }); err != nil { + t.Fatalf("create stage: %v", err) + } + + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + + resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, + `{"image":"otheruser/other:dev"}`) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400 on image mismatch, got %d", resp.StatusCode) + } + if dep.calls != 0 { + t.Errorf("deploy should not have been triggered on image mismatch") + } +} + +func TestProjectWebhook_NoMatchingStageReturns200NoDeploy(t *testing.T) { + t.Parallel() + st := newStore(t) + p, err := st.CreateProject(store.Project{ + Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + if _, err := st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "prod", TagPattern: "v*", AutoDeploy: true, MaxInstances: 1, + }); err != nil { + t.Fatalf("create stage: %v", err) + } + + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + + resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, + `{"image":"alexei/app:dev-abc"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + if dep.calls != 0 { + t.Errorf("expected no deploy call, got %d", dep.calls) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + if parsed["deploy"] != false { + t.Errorf("expected deploy=false, got %v", parsed["deploy"]) + } +} + +func TestProjectWebhook_AutoDeployDisabled(t *testing.T) { + t.Parallel() + st := newStore(t) + p, _ := st.CreateProject(store.Project{Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}"}) + _, _ = st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: false, MaxInstances: 1, + }) + + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + + resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, + `{"image":"alexei/app:dev-1"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if dep.calls != 0 { + t.Errorf("auto_deploy=false should suppress deploy call; got %d", dep.calls) + } +} + +func TestSiteWebhook_UnknownSecretReturns404(t *testing.T) { + t.Parallel() + st := newStore(t) + h := webhook.NewHandler(st, &fakeDeployer{}, &fakeSiteTriggerer{}) + r := newRouter(t, h) + + resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/bogus", "{}") + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +func TestSiteWebhook_ManualTriggerShortCircuits(t *testing.T) { + t.Parallel() + st := newStore(t) + site, err := st.CreateStaticSite(store.StaticSite{ + Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", + Branch: "main", SyncTrigger: "manual", Status: "idle", + }) + if err != nil { + t.Fatalf("create site: %v", err) + } + + ft := &fakeSiteTriggerer{} + h := webhook.NewHandler(st, &fakeDeployer{}, ft) + r := newRouter(t, h) + + resp, _ := doJSON(t, r, http.MethodPost, + "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if ft.calls != 0 { + t.Errorf("manual-trigger site must not invoke sync; got %d calls", ft.calls) + } +} + +func TestSiteWebhook_PushTriggersSyncOnBranchMatch(t *testing.T) { + t.Parallel() + st := newStore(t) + site, err := st.CreateStaticSite(store.StaticSite{ + Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", + Branch: "main", SyncTrigger: "push", Status: "idle", + }) + if err != nil { + t.Fatalf("create site: %v", err) + } + + ft := &fakeSiteTriggerer{done: make(chan struct{}, 1)} + h := webhook.NewHandler(st, &fakeDeployer{}, ft) + r := newRouter(t, h) + + resp, body := doJSON(t, r, http.MethodPost, + "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + // Sync runs in a goroutine — wait for the signal. + <-ft.done + ft.mu.Lock() + calls := ft.calls + ft.mu.Unlock() + if calls != 1 { + t.Errorf("expected 1 sync call, got %d", calls) + } +} + +func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) { + t.Parallel() + st := newStore(t) + site, _ := st.CreateStaticSite(store.StaticSite{ + Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", + Branch: "main", SyncTrigger: "push", Status: "idle", + }) + + ft := &fakeSiteTriggerer{} + h := webhook.NewHandler(st, &fakeDeployer{}, ft) + r := newRouter(t, h) + + resp, _ := doJSON(t, r, http.MethodPost, + "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/feature-x"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if ft.calls != 0 { + t.Errorf("non-matching branch must not trigger sync; got %d calls", ft.calls) + } +} diff --git a/internal/webhook/matcher.go b/internal/webhook/matcher.go index 4bd3f63..2fffe4d 100644 --- a/internal/webhook/matcher.go +++ b/internal/webhook/matcher.go @@ -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/ ref whose +// branch name equals site.Branch. +// For sync_trigger = "tag": the ref must be a tags/ 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 + } +} diff --git a/internal/webhook/matcher_test.go b/internal/webhook/matcher_test.go new file mode 100644 index 0000000..db04ceb --- /dev/null +++ b/internal/webhook/matcher_test.go @@ -0,0 +1,98 @@ +package webhook + +import ( + "testing" + + "github.com/alexei/tinyforge/internal/store" +) + +func TestSiteRefMatches_Push(t *testing.T) { + t.Parallel() + site := store.StaticSite{SyncTrigger: "push", Branch: "main"} + cases := []struct { + ref string + want bool + }{ + {"refs/heads/main", true}, + {"refs/heads/develop", false}, + {"refs/tags/v1.0.0", false}, + {"", false}, + {"main", false}, + } + for _, tc := range cases { + if got := siteRefMatches(site, tc.ref); got != tc.want { + t.Errorf("siteRefMatches(push, %q) = %v; want %v", tc.ref, got, tc.want) + } + } +} + +func TestSiteRefMatches_PushEmptyBranchAcceptsAny(t *testing.T) { + t.Parallel() + // When Branch is unset, any heads ref should match — tolerates the sites + // table having blank Branch values from legacy rows. + site := store.StaticSite{SyncTrigger: "push"} + if !siteRefMatches(site, "refs/heads/whatever") { + t.Error("expected empty Branch to accept any heads ref") + } + if siteRefMatches(site, "refs/tags/v1") { + t.Error("empty Branch must still reject tag refs") + } +} + +func TestSiteRefMatches_Tag(t *testing.T) { + t.Parallel() + site := store.StaticSite{SyncTrigger: "tag", TagPattern: "v*"} + cases := []struct { + ref string + want bool + }{ + {"refs/tags/v1.0.0", true}, + {"refs/tags/v2", true}, + {"refs/tags/hotfix", false}, + {"refs/heads/main", false}, + } + for _, tc := range cases { + if got := siteRefMatches(site, tc.ref); got != tc.want { + t.Errorf("siteRefMatches(tag, %q) = %v; want %v", tc.ref, got, tc.want) + } + } +} + +func TestSiteRefMatches_ManualIsIgnored(t *testing.T) { + t.Parallel() + site := store.StaticSite{SyncTrigger: "manual", Branch: "main"} + if siteRefMatches(site, "refs/heads/main") { + t.Error("manual trigger must never match any ref — caller short-circuits") + } +} + +func TestParseImageRef(t *testing.T) { + t.Parallel() + cases := []struct { + in string + wantFull string + wantTag string + }{ + {"registry.example.com/alexei/app:v1", "alexei/app", "v1"}, + {"alexei/app:dev", "alexei/app", "dev"}, + {"app", "app", ""}, + } + for _, tc := range cases { + got, err := ParseImageRef(tc.in) + if err != nil { + t.Errorf("ParseImageRef(%q) unexpected error: %v", tc.in, err) + continue + } + if got.FullName() != tc.wantFull || got.Tag != tc.wantTag { + t.Errorf("ParseImageRef(%q) = %q:%q; want %q:%q", + tc.in, got.FullName(), got.Tag, tc.wantFull, tc.wantTag) + } + } +} + +func TestParseImageRef_Empty(t *testing.T) { + t.Parallel() + if _, err := ParseImageRef(""); err == nil { + t.Error("expected error for empty image ref") + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9ce7834..b5e739c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -319,12 +319,27 @@ export function updateSettings(data: Partial): Promise { return put('/api/settings', data); } -export function getWebhookUrl(): Promise<{ webhook_url: string }> { - return get<{ webhook_url: string }>('/api/settings/webhook-url'); +// ── Webhooks ─────────────────────────────────────────────────────── + +export interface WebhookUrlResponse { + webhook_url: string; + webhook_secret: string; } -export function regenerateWebhookUrl(): Promise<{ webhook_url: string }> { - return post<{ webhook_url: string }>('/api/settings/webhook-url/regenerate'); +export function getProjectWebhook(projectId: string): Promise { + return get(`/api/projects/${projectId}/webhook`); +} + +export function regenerateProjectWebhook(projectId: string): Promise { + return post(`/api/projects/${projectId}/webhook/regenerate`); +} + +export function getStaticSiteWebhook(siteId: string): Promise { + return get(`/api/sites/${siteId}/webhook`); +} + +export function regenerateStaticSiteWebhook(siteId: string): Promise { + return post(`/api/sites/${siteId}/webhook/regenerate`); } // ── Proxy Routes ─────────────────────────────────────────────────── diff --git a/web/src/lib/components/WebhookPanel.svelte b/web/src/lib/components/WebhookPanel.svelte new file mode 100644 index 0000000..2d71826 --- /dev/null +++ b/web/src/lib/components/WebhookPanel.svelte @@ -0,0 +1,136 @@ + + + +
+

{title}

+

{description}

+ + {#if loading} +
+ {:else if relativeUrl} +
+ + {absoluteUrl} + + +
+ {:else} +

{$t('webhookPanel.noUrl')}

+ {/if} + +
+ {#if confirmOpen} +
+ {$t('webhookPanel.confirmRegenerate')} + + +
+ {:else} + +

{$t('webhookPanel.regenerateWarning')}

+ {/if} +
+
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index e507ebe..389874b 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -74,6 +74,8 @@ "noMatchingProjects": "No projects match your search." }, "projectDetail": { + "webhookTitle": "Project webhook", + "webhookDesc": "POST an image reference to this URL from your CI pipeline to trigger a deploy. Stage routing uses each stage's tag pattern.", "deleteProject": "Delete Project", "envVars": "Environment Variables", "volumes": "Volume Mounts", @@ -570,6 +572,8 @@ "lastChecked": "Last checked" }, "sites": { + "webhookTitle": "Site webhook", + "webhookDesc": "Point your Git provider's push webhook at this URL. Tinyforge will re-sync the site on matching refs (branch for push trigger, tag pattern for tag trigger). Send an empty body for an unconditional sync.", "title": "Static Sites", "addSite": "New Site", "newSite": "New Static Site", @@ -1099,7 +1103,22 @@ "title": "Integrations", "outgoing": "Outgoing notifications", "outgoingDesc": "Where Tinyforge posts deploy and alert events. Paste a webhook URL (Apprise, Discord, Slack, your own handler).", - "incoming": "Incoming webhook" + "incoming": "Incoming webhooks", + "incomingMovedDesc": "Inbound webhooks are now scoped per entity. Open a project or static site to view and rotate its webhook URL." + }, + "webhookPanel": { + "copy": "Copy", + "copied": "Webhook URL copied to clipboard", + "copyFailed": "Failed to copy to clipboard", + "noUrl": "No webhook URL configured", + "loadFailed": "Failed to load webhook URL", + "regenerate": "Regenerate URL", + "regenerated": "Webhook URL regenerated", + "regenerateFailed": "Failed to regenerate webhook URL", + "regenerateWarning": "Regenerating invalidates the current URL. Update any CI pipeline or Git webhook that uses it.", + "confirmRegenerate": "Replace the current URL?", + "confirmYes": "Regenerate", + "confirmNo": "Cancel" }, "settingsMaintenance": { "title": "Maintenance", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 8ab2903..9929147 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -74,6 +74,8 @@ "noMatchingProjects": "Проекты не найдены." }, "projectDetail": { + "webhookTitle": "Webhook проекта", + "webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.", "deleteProject": "Удалить проект", "envVars": "Переменные окружения", "volumes": "Тома", @@ -570,6 +572,8 @@ "lastChecked": "Последняя проверка" }, "sites": { + "webhookTitle": "Webhook сайта", + "webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.", "title": "Статические сайты", "addSite": "Новый сайт", "newSite": "Новый статический сайт", @@ -1099,7 +1103,22 @@ "title": "Интеграции", "outgoing": "Исходящие уведомления", "outgoingDesc": "Куда Tinyforge отправляет события деплоев и алертов. Укажите webhook-URL (Apprise, Discord, Slack, свой обработчик).", - "incoming": "Входящий вебхук" + "incoming": "Входящие вебхуки", + "incomingMovedDesc": "Входящие вебхуки теперь привязаны к конкретному проекту или сайту. Откройте страницу проекта или статического сайта, чтобы увидеть и перегенерировать URL." + }, + "webhookPanel": { + "copy": "Копировать", + "copied": "Webhook-URL скопирован в буфер обмена", + "copyFailed": "Не удалось скопировать", + "noUrl": "Webhook-URL не настроен", + "loadFailed": "Не удалось загрузить webhook-URL", + "regenerate": "Перегенерировать URL", + "regenerated": "Webhook-URL перегенерирован", + "regenerateFailed": "Не удалось перегенерировать webhook-URL", + "regenerateWarning": "Перегенерация инвалидирует текущий URL. Обновите CI-пайплайны и Git-вебхуки, использующие его.", + "confirmRegenerate": "Заменить текущий URL?", + "confirmYes": "Перегенерировать", + "confirmNo": "Отмена" }, "settingsMaintenance": { "title": "Обслуживание", diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index c147945..6de4e61 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -13,6 +13,7 @@ import ForgeHero from '$lib/components/ForgeHero.svelte'; import FormField from '$lib/components/FormField.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; + import WebhookPanel from '$lib/components/WebhookPanel.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte'; import type { EntityPickerItem } from '$lib/types'; import { IconShield } from '$lib/components/icons'; @@ -767,6 +768,14 @@ {/if} + + api.getProjectWebhook(projectId)} + regenerateWebhook={() => api.regenerateProjectWebhook(projectId)} + /> +

{$t('projectDetail.recentDeploys')}

diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte index eb618c7..758d605 100644 --- a/web/src/routes/settings/integrations/+page.svelte +++ b/web/src/routes/settings/integrations/+page.svelte @@ -1,25 +1,22 @@ @@ -80,7 +63,6 @@
-
{:else} @@ -105,40 +87,10 @@
- +

{$t('settingsIntegrations.incoming')}

-

{$t('settingsGeneral.webhookDesc')}

- - {#if webhookUrl} -
- - {webhookUrl} - - -
- {:else} -

{$t('settingsGeneral.noWebhookUrl')}

- {/if} - -
- -

{$t('settingsGeneral.regenerateWarning')}

-
+

{$t('settingsIntegrations.incomingMovedDesc')}

{/if} diff --git a/web/src/routes/sites/[id]/+page.svelte b/web/src/routes/sites/[id]/+page.svelte index 0745c5d..d563b08 100644 --- a/web/src/routes/sites/[id]/+page.svelte +++ b/web/src/routes/sites/[id]/+page.svelte @@ -8,6 +8,7 @@ import FormField from '$lib/components/FormField.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte'; + import WebhookPanel from '$lib/components/WebhookPanel.svelte'; import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons'; let site = $state(null); @@ -250,6 +251,14 @@ {/if} + + api.getStaticSiteWebhook(siteId!)} + regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)} + /> +