feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
This commit is contained in:
@@ -10,8 +10,10 @@ type Project struct {
|
||||
Healthcheck string `json:"healthcheck"`
|
||||
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
|
||||
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
|
||||
WebhookSecret string `json:"-"` // per-project webhook secret; never serialized directly
|
||||
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -27,8 +29,9 @@ type Stage struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
EnableProxy bool `json:"enable_proxy"`
|
||||
PromoteFrom string `json:"promote_from"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited
|
||||
MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited
|
||||
CreatedAt string `json:"created_at"`
|
||||
@@ -54,7 +57,8 @@ type Settings struct {
|
||||
PublicIP string `json:"public_ip"` // Public-facing IP for DNS A records (e.g., NPM/proxy host)
|
||||
Network string `json:"network"`
|
||||
SubdomainPattern string `json:"subdomain_pattern"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
NpmURL string `json:"npm_url"`
|
||||
NpmEmail string `json:"npm_email"`
|
||||
NpmPassword string `json:"npm_password"`
|
||||
@@ -250,7 +254,9 @@ 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
|
||||
WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly
|
||||
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
+47
-10
@@ -31,7 +31,7 @@ func generateWebhookSecret() string {
|
||||
|
||||
// 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`
|
||||
npm_access_list_id, webhook_secret, notification_url, notification_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.
|
||||
@@ -47,9 +47,9 @@ func (s *Store) CreateProject(p Project) (Project, error) {
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO projects (`+projectCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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,
|
||||
p.NpmAccessListID, p.WebhookSecret, p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Project{}, fmt.Errorf("insert project: %w", err)
|
||||
@@ -63,7 +63,7 @@ func (s *Store) GetProjectByID(id string) (Project, error) {
|
||||
err := s.db.QueryRow(
|
||||
`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)
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) {
|
||||
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)
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Project{}, ErrNotFound
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
||||
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.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan project: %w", err)
|
||||
}
|
||||
projects = append(projects, p)
|
||||
@@ -129,7 +129,7 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
||||
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.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan project: %w", err)
|
||||
}
|
||||
projects = append(projects, p)
|
||||
@@ -138,15 +138,16 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
||||
}
|
||||
|
||||
// UpdateProject updates an existing project's mutable fields. Webhook secret
|
||||
// is intentionally not updated here — use SetProjectWebhookSecret instead.
|
||||
// and notification_secret are intentionally not updated here — use the
|
||||
// dedicated SetProjectWebhookSecret / SetProjectNotificationSecret helpers.
|
||||
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=?
|
||||
npm_access_list_id=?, notification_url=?, 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.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update project: %w", err)
|
||||
@@ -193,6 +194,42 @@ func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) {
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// SetProjectNotificationSecret rotates the project's outgoing-webhook signing
|
||||
// secret. Empty string disables HMAC signing for this project (notifications
|
||||
// still send unsigned, falling through to the parent tier's secret if any).
|
||||
func (s *Store) SetProjectNotificationSecret(id, secret string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE projects SET notification_secret=?, updated_at=? WHERE id=?`,
|
||||
secret, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set project notification secret: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("project %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureProjectNotificationSecret returns the current outgoing-webhook signing
|
||||
// secret, generating one lazily if missing. Used when an operator first opens
|
||||
// the outgoing-webhook panel for a project that predates this feature.
|
||||
func (s *Store) EnsureProjectNotificationSecret(id string) (string, error) {
|
||||
project, err := s.GetProjectByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if project.NotificationSecret != "" {
|
||||
return project.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetProjectNotificationSecret(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)
|
||||
|
||||
@@ -10,6 +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,
|
||||
notification_secret,
|
||||
npm_url, npm_email, npm_password, polling_interval,
|
||||
base_volume_path, ssl_certificate_id, stale_threshold_days,
|
||||
allowed_volume_paths, wildcard_dns, dns_provider,
|
||||
@@ -22,6 +23,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.NotificationSecret,
|
||||
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.PollingInterval,
|
||||
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
||||
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
||||
@@ -59,6 +61,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=?,
|
||||
notification_secret=?,
|
||||
npm_url=?, npm_email=?, npm_password=?, polling_interval=?,
|
||||
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
|
||||
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
||||
@@ -71,6 +74,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.NotificationSecret,
|
||||
st.NpmURL, st.NpmEmail, st.NpmPassword, st.PollingInterval,
|
||||
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
||||
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
||||
@@ -87,3 +91,17 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSettingsNotificationSecret rewrites only the global outgoing-webhook
|
||||
// signing secret on the singleton settings row. Pass an empty string to
|
||||
// disable signing globally (notifications still send, just without HMAC).
|
||||
func (s *Store) SetSettingsNotificationSecret(secret string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE settings SET notification_secret=?, updated_at=? WHERE id = 1`,
|
||||
secret, Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings notification secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, cpu_limit, memory_limit, created_at, updated_at`
|
||||
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, notification_secret, cpu_limit, memory_limit, created_at, updated_at`
|
||||
|
||||
// CreateStage inserts a new stage for a project.
|
||||
func (s *Store) CreateStage(st Stage) (Stage, error) {
|
||||
@@ -17,9 +17,10 @@ func (s *Store) CreateStage(st Stage) (Stage, error) {
|
||||
st.UpdatedAt = st.CreatedAt
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
|
||||
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
|
||||
st.NotificationSecret,
|
||||
st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -57,6 +58,7 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
|
||||
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
|
||||
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
|
||||
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
|
||||
&st.NotificationSecret,
|
||||
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
|
||||
@@ -80,6 +82,8 @@ func (s *Store) UpdateStage(st Stage) error {
|
||||
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
|
||||
st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID,
|
||||
)
|
||||
// notification_secret is intentionally not updated here — use the
|
||||
// dedicated SetStageNotificationSecret rotation helper.
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stage: %w", err)
|
||||
}
|
||||
@@ -103,6 +107,41 @@ func (s *Store) DeleteStage(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStageNotificationSecret rotates the stage's outgoing-webhook signing
|
||||
// secret. Empty string disables HMAC signing for this stage (notifications
|
||||
// still send unsigned, falling through to project/global resolution).
|
||||
func (s *Store) SetStageNotificationSecret(id, secret string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stages SET notification_secret=?, updated_at=? WHERE id=?`,
|
||||
secret, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set stage notification secret: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureStageNotificationSecret returns the stage's outgoing-webhook signing
|
||||
// secret, generating one lazily if missing.
|
||||
func (s *Store) EnsureStageNotificationSecret(id string) (string, error) {
|
||||
stage, err := s.GetStageByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if stage.NotificationSecret != "" {
|
||||
return stage.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetStageNotificationSecret(id, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// BoolToInt converts a bool to an integer for SQLite storage.
|
||||
func BoolToInt(b bool) int {
|
||||
if b {
|
||||
@@ -117,6 +156,7 @@ func scanStage(rows *sql.Rows) (Stage, error) {
|
||||
var autoDeploy, confirm, enableProxy int
|
||||
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
|
||||
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
|
||||
&st.NotificationSecret,
|
||||
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
|
||||
if err != nil {
|
||||
return Stage{}, fmt.Errorf("scan stage: %w", err)
|
||||
|
||||
@@ -13,7 +13,9 @@ 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, webhook_secret, created_at, updated_at`
|
||||
storage_enabled, storage_limit_mb, webhook_secret,
|
||||
notification_url, notification_secret,
|
||||
created_at, updated_at`
|
||||
|
||||
// CreateStaticSite inserts a new static site and returns it. A webhook secret
|
||||
// is generated automatically if one is not already set on the input.
|
||||
@@ -29,13 +31,15 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
|
||||
|
||||
_, 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.WebhookSecret, site.CreatedAt, site.UpdatedAt,
|
||||
site.WebhookSecret,
|
||||
site.NotificationURL, site.NotificationSecret,
|
||||
site.CreatedAt, site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
|
||||
@@ -103,18 +107,21 @@ func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite
|
||||
}
|
||||
|
||||
// UpdateStaticSite updates an existing static site's configuration fields.
|
||||
// notification_secret is intentionally not updated here — use the dedicated
|
||||
// SetStaticSiteNotificationSecret rotation helper.
|
||||
func (s *Store) UpdateStaticSite(site StaticSite) error {
|
||||
site.UpdatedAt = Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
|
||||
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
|
||||
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?, updated_at=?
|
||||
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?,
|
||||
notification_url=?, updated_at=?
|
||||
WHERE 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,
|
||||
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
||||
site.UpdatedAt, site.ID,
|
||||
site.NotificationURL, site.UpdatedAt, site.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update static site: %w", err)
|
||||
@@ -228,7 +235,9 @@ 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.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
|
||||
&site.WebhookSecret,
|
||||
&site.NotificationURL, &site.NotificationSecret,
|
||||
&site.CreatedAt, &site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, err
|
||||
@@ -248,7 +257,9 @@ 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.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
|
||||
&site.WebhookSecret,
|
||||
&site.NotificationURL, &site.NotificationSecret,
|
||||
&site.CreatedAt, &site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
|
||||
@@ -258,6 +269,58 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
||||
return site, nil
|
||||
}
|
||||
|
||||
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
|
||||
// signing secret. Empty string disables HMAC signing for this site
|
||||
// (notifications still send unsigned, falling through to global resolution).
|
||||
func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`,
|
||||
secret, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set static site notification secret: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook
|
||||
// signing secret, generating one lazily if missing.
|
||||
func (s *Store) EnsureStaticSiteNotificationSecret(id string) (string, error) {
|
||||
site, err := s.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if site.NotificationSecret != "" {
|
||||
return site.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetStaticSiteNotificationSecret(id, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// EnsureSettingsNotificationSecret returns the global outgoing-webhook signing
|
||||
// secret, generating one lazily if missing.
|
||||
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
|
||||
st, err := s.GetSettings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if st.NotificationSecret != "" {
|
||||
return st.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetSettingsNotificationSecret(secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, 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) {
|
||||
|
||||
+23
-8
@@ -138,6 +138,15 @@ func (s *Store) runMigrations() error {
|
||||
// retention in hours. 0 in either disables collection.
|
||||
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
|
||||
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
|
||||
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
|
||||
// tokens (matches the inbound webhook_secret pattern). Empty = no
|
||||
// signing; existing rows stay unsigned on upgrade for back-compat.
|
||||
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
|
||||
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||
@@ -284,7 +293,9 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
healthcheck TEXT NOT NULL DEFAULT '',
|
||||
env TEXT NOT NULL DEFAULT '{}',
|
||||
volumes TEXT NOT NULL DEFAULT '{}',
|
||||
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
||||
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -299,8 +310,9 @@ CREATE TABLE IF NOT EXISTS stages (
|
||||
confirm INTEGER NOT NULL DEFAULT 0,
|
||||
enable_proxy INTEGER NOT NULL DEFAULT 1,
|
||||
promote_from TEXT NOT NULL DEFAULT '',
|
||||
subdomain TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
subdomain TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
cpu_limit REAL NOT NULL DEFAULT 0,
|
||||
memory_limit INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
@@ -326,6 +338,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
network TEXT NOT NULL DEFAULT 'tinyforge',
|
||||
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
npm_url TEXT NOT NULL DEFAULT '',
|
||||
npm_email TEXT NOT NULL DEFAULT '',
|
||||
npm_password TEXT NOT NULL DEFAULT '',
|
||||
@@ -487,11 +500,13 @@ CREATE TABLE IF NOT EXISTS static_sites (
|
||||
container_id TEXT NOT NULL DEFAULT '',
|
||||
proxy_route_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_sync_at TEXT NOT NULL DEFAULT '',
|
||||
last_commit_sha TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
last_sync_at TEXT NOT NULL DEFAULT '',
|
||||
last_commit_sha TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS static_site_secrets (
|
||||
|
||||
Reference in New Issue
Block a user