Files
tiny-forge/internal/store/projects.go
T
alexei.dolgolyov 0405ecd9ce
Build / build (push) Successful in 10m36s
feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
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.
2026-05-07 02:03:32 +03:00

245 lines
8.3 KiB
Go

package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"github.com/google/uuid"
)
// minWebhookSecretLength is the smallest user-supplied webhook secret accepted
// at insert time. Auto-generated secrets are 64 hex chars (256 bits); a
// 32-char floor still leaves > 128 bits of brute-force resistance for hex
// alphabets and rejects obvious typos / placeholder strings.
const minWebhookSecretLength = 32
// generateWebhookSecret returns a 256-bit hex-encoded random token. We use
// crypto/rand directly rather than uuid.New() so the intent ("secret token,
// not identifier") is explicit and the entropy is unambiguous.
func generateWebhookSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand is documented to never fail on supported platforms;
// fall back to a UUID rather than panicking.
return uuid.New().String()
}
return hex.EncodeToString(b)
}
// 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, 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.
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 = generateWebhookSecret()
} else if len(p.WebhookSecret) < minWebhookSecretLength {
return Project{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
}
_, err := s.db.Exec(
`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.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt,
)
if err != nil {
return Project{}, fmt.Errorf("insert project: %w", err)
}
return p, nil
}
// GetProjectByID returns a single project by its ID.
func (s *Store) GetProjectByID(id string) (Project, error) {
var p Project
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.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
}
if err != nil {
return Project{}, fmt.Errorf("query project: %w", err)
}
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.NotificationURL, &p.NotificationSecret, &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 ` + projectCols + ` FROM projects ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
defer rows.Close()
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.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetProjectsByImage returns all projects using the given image, newest first.
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
rows, err := s.db.Query(
`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)
}
defer rows.Close()
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.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// UpdateProject updates an existing project's mutable fields. Webhook secret
// 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=?, notification_url=?, updated_at=?
WHERE id=?`,
p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
p.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID,
)
if err != nil {
return fmt.Errorf("update project: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: %w", p.ID, ErrNotFound)
}
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 := generateWebhookSecret()
if err := s.SetProjectWebhookSecret(id, secret); err != nil {
return "", err
}
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)
if err != nil {
return fmt.Errorf("delete project: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: %w", id, ErrNotFound)
}
return nil
}