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, webhook_signing_secret, webhook_require_signature, notification_url, notification_secret, created_at, updated_at` // rowScanner is the subset of *sql.Row / *sql.Rows used by scanProject. type rowScanner interface { Scan(dest ...any) error } // scanProject reads one row in projectCols order. webhook_require_signature // is stored as INTEGER and converted to bool here. func scanProject(r rowScanner) (Project, error) { var p Project var requireSig int if err := r.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.WebhookSecret, &p.WebhookSigningSecret, &requireSig, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { return Project{}, err } p.WebhookRequireSignature = requireSig != 0 return p, nil } // 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) } requireSig := 0 if p.WebhookRequireSignature { requireSig = 1 } _, 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.WebhookSigningSecret, requireSig, p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt, ) if err != nil { return Project{}, fmt.Errorf("insert project: %w", err) } if err := s.SyncProjectWorkload(p); err != nil { return Project{}, fmt.Errorf("sync project workload: %w", err) } return p, nil } // GetProjectByID returns a single project by its ID. func (s *Store) GetProjectByID(id string) (Project, error) { row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id) p, err := scanProject(row) 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 } row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret) p, err := scanProject(row) 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() { p, err := scanProject(rows) if 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() { p, err := scanProject(rows) if 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) } // Re-read so the workload sync sees the canonical row (e.g. webhook // secrets that UpdateProject does not write but other call sites do). current, err := s.GetProjectByID(p.ID) if err != nil { return fmt.Errorf("reread project for workload sync: %w", err) } if err := s.SyncProjectWorkload(current); err != nil { return fmt.Errorf("sync project workload: %w", err) } 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) } current, err := s.GetProjectByID(id) if err != nil { return fmt.Errorf("reread project for workload sync: %w", err) } return s.SyncProjectWorkload(current) } // SetProjectWebhookSigningSecret assigns the HMAC signing secret used to // verify inbound webhook payloads. Pass an empty string to clear it (which // also implicitly disables signature enforcement on the next request). func (s *Store) SetProjectWebhookSigningSecret(id, secret string) error { result, err := s.db.Exec( `UPDATE projects SET webhook_signing_secret=?, updated_at=? WHERE id=?`, secret, Now(), id, ) if err != nil { return fmt.Errorf("set project webhook signing secret: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("project %s: %w", id, ErrNotFound) } current, err := s.GetProjectByID(id) if err != nil { return fmt.Errorf("reread project for workload sync: %w", err) } return s.SyncProjectWorkload(current) } // SetProjectWebhookRequireSignature toggles whether unsigned (or // invalidly-signed) webhook requests are rejected with 401. func (s *Store) SetProjectWebhookRequireSignature(id string, require bool) error { v := 0 if require { v = 1 } result, err := s.db.Exec( `UPDATE projects SET webhook_require_signature=?, updated_at=? WHERE id=?`, v, Now(), id, ) if err != nil { return fmt.Errorf("set project webhook require_signature: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("project %s: %w", id, ErrNotFound) } current, err := s.GetProjectByID(id) if err != nil { return fmt.Errorf("reread project for workload sync: %w", err) } return s.SyncProjectWorkload(current) } // 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) } current, err := s.GetProjectByID(id) if err != nil { return fmt.Errorf("reread project for workload sync: %w", err) } return s.SyncProjectWorkload(current) } // 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. // Workload row + container index entries are removed too so the global views // don't show ghost rows after a project is gone. 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) } if w, err := s.GetWorkloadByRef(WorkloadKindProject, id); err == nil { if err := s.DeleteContainersByWorkload(w.ID); err != nil { return fmt.Errorf("delete project containers: %w", err) } if err := s.DeleteWorkload(w.ID); err != nil { return fmt.Errorf("delete project workload: %w", err) } } return nil }