0405ecd9ce
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.
375 lines
12 KiB
Go
375 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// staticSiteCols is the column list for static_sites queries.
|
|
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,
|
|
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.
|
|
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 = generateWebhookSecret()
|
|
} else if len(site.WebhookSecret) < minWebhookSecretLength {
|
|
return StaticSite{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO static_sites (`+staticSiteCols+`)
|
|
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.NotificationURL, site.NotificationSecret,
|
|
site.CreatedAt, site.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
|
|
}
|
|
return site, nil
|
|
}
|
|
|
|
// GetStaticSiteByID returns a single static site by its ID.
|
|
func (s *Store) GetStaticSiteByID(id string) (StaticSite, error) {
|
|
site, err := scanStaticSiteRow(s.db.QueryRow(
|
|
`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return StaticSite{}, fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
}
|
|
if err != nil {
|
|
return StaticSite{}, fmt.Errorf("query static site: %w", err)
|
|
}
|
|
return site, nil
|
|
}
|
|
|
|
// GetAllStaticSites returns every static site ordered by name.
|
|
func (s *Store) GetAllStaticSites() ([]StaticSite, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT ` + staticSiteCols + ` FROM static_sites ORDER BY name`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query static sites: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
sites := []StaticSite{}
|
|
for rows.Next() {
|
|
site, err := scanStaticSiteRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sites = append(sites, site)
|
|
}
|
|
return sites, rows.Err()
|
|
}
|
|
|
|
// GetStaticSitesByRepo returns all static sites for a given repo owner/name.
|
|
func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT `+staticSiteCols+`
|
|
FROM static_sites WHERE gitea_url = ? AND repo_owner = ? AND repo_name = ?
|
|
ORDER BY name`,
|
|
giteaURL, owner, name,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query static sites by repo: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
sites := []StaticSite{}
|
|
for rows.Next() {
|
|
site, err := scanStaticSiteRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sites = append(sites, site)
|
|
}
|
|
return sites, rows.Err()
|
|
}
|
|
|
|
// 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=?,
|
|
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.NotificationURL, site.UpdatedAt, site.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update static site: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("static site %s: %w", site.ID, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateStaticSiteStatus updates the deployment status fields.
|
|
func (s *Store) UpdateStaticSiteStatus(id, status, commitSHA, errMsg string) error {
|
|
now := Now()
|
|
result, err := s.db.Exec(
|
|
`UPDATE static_sites SET status=?, last_commit_sha=?, last_sync_at=?, error=?, updated_at=?
|
|
WHERE id=?`,
|
|
status, commitSHA, now, errMsg, now, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update static site status: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateStaticSiteContainer updates the container and proxy route IDs after deployment.
|
|
func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string) error {
|
|
now := Now()
|
|
result, err := s.db.Exec(
|
|
`UPDATE static_sites SET container_id=?, proxy_route_id=?, updated_at=? WHERE id=?`,
|
|
containerID, proxyRouteID, now, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update static site container: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListStaticSiteProxyRoutes returns proxy routes backed by static sites,
|
|
// shaped to match the unified ProxyRoute model used by the Proxies page.
|
|
// Sites without an active proxy route are skipped.
|
|
func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, name, mode, provider, domain, container_id, proxy_route_id, status, created_at
|
|
FROM static_sites
|
|
WHERE proxy_route_id != ''
|
|
ORDER BY name`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query static site proxy routes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
suffix := ""
|
|
if domain != "" {
|
|
suffix = "." + strings.ToLower(domain)
|
|
}
|
|
|
|
routes := []ProxyRoute{}
|
|
for rows.Next() {
|
|
var r ProxyRoute
|
|
var mode, provider, fullDomain string
|
|
if err := rows.Scan(
|
|
&r.InstanceID, &r.ProjectName, &mode, &provider, &fullDomain,
|
|
&r.ContainerID, &r.ProxyRouteID, &r.Status, &r.CreatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan static site proxy route: %w", err)
|
|
}
|
|
r.Source = "static_site"
|
|
r.StageName = mode
|
|
r.ImageTag = provider
|
|
r.Domain = fullDomain
|
|
if suffix != "" && strings.HasSuffix(strings.ToLower(fullDomain), suffix) {
|
|
r.Subdomain = fullDomain[:len(fullDomain)-len(suffix)]
|
|
} else {
|
|
r.Subdomain = fullDomain
|
|
}
|
|
routes = append(routes, r)
|
|
}
|
|
return routes, rows.Err()
|
|
}
|
|
|
|
// DeleteStaticSite removes a static site by ID. Cascading deletes handle secrets.
|
|
func (s *Store) DeleteStaticSite(id string) error {
|
|
result, err := s.db.Exec(`DELETE FROM static_sites WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete static site: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scanStaticSiteRow scans a static site from a *sql.Row.
|
|
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
|
|
var site StaticSite
|
|
var renderMarkdown, storageEnabled int
|
|
err := row.Scan(
|
|
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
|
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
|
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
|
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
|
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
|
&site.WebhookSecret,
|
|
&site.NotificationURL, &site.NotificationSecret,
|
|
&site.CreatedAt, &site.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return StaticSite{}, err
|
|
}
|
|
site.RenderMarkdown = renderMarkdown != 0
|
|
site.StorageEnabled = storageEnabled != 0
|
|
return site, nil
|
|
}
|
|
|
|
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
|
|
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
|
var site StaticSite
|
|
var renderMarkdown, storageEnabled int
|
|
err := rows.Scan(
|
|
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
|
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
|
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
|
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
|
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
|
&site.WebhookSecret,
|
|
&site.NotificationURL, &site.NotificationSecret,
|
|
&site.CreatedAt, &site.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
|
|
}
|
|
site.RenderMarkdown = renderMarkdown != 0
|
|
site.StorageEnabled = storageEnabled != 0
|
|
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) {
|
|
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 := generateWebhookSecret()
|
|
if err := s.SetStaticSiteWebhookSecret(id, secret); err != nil {
|
|
return "", err
|
|
}
|
|
return secret, nil
|
|
}
|