Replace the single global webhook secret with entity-scoped secrets stored
on each project and static site. Webhook-driven project autocreate is
removed — projects must exist before their URL can trigger deploys.
Also wires static-site webhooks (sync_trigger=push|tag), turning the
previously inert "push" trigger into a functional one: POST the site's
webhook URL from a Git provider and Tinyforge re-syncs on matching refs.
- Adds webhook_secret columns + unique indexes to projects and static_sites
- Per-entity GET/regenerate endpoints under /api/projects/{id}/webhook
and /api/sites/{id}/webhook (admin-only)
- Removes /api/settings/webhook-url and the global webhook panel
- Reusable WebhookPanel Svelte component on both detail pages, i18n in en/ru
- Tests for matcher (siteRefMatches, ParseImageRef) and handler (project
match/mismatch/404 and site push/manual/branch-skip)
This commit is contained in:
+5
-10
@@ -148,16 +148,10 @@ func main() {
|
|||||||
|
|
||||||
dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey)
|
dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey)
|
||||||
|
|
||||||
// Initialize webhook handler.
|
// Initialize webhook handler. Per-project and per-site secrets are stored
|
||||||
webhookHandler := webhook.NewHandler(db, dep, dockerClient)
|
// on their respective rows; the static-site triggerer is wired in below
|
||||||
|
// once the site manager has been constructed.
|
||||||
// Ensure webhook secret exists.
|
webhookHandler := webhook.NewHandler(db, dep, nil)
|
||||||
_, 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 registry poller.
|
// Initialize registry poller.
|
||||||
poller := registry.NewPoller(db, dep, encKey)
|
poller := registry.NewPoller(db, dep, encKey)
|
||||||
@@ -284,6 +278,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize static site manager and health checker.
|
// Initialize static site manager and health checker.
|
||||||
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey)
|
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey)
|
||||||
|
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
|
||||||
staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr)
|
staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr)
|
||||||
if err := staticSiteHealth.Start("2m"); err != nil {
|
if err := staticSiteHealth.Start("2m"); err != nil {
|
||||||
slog.Warn("failed to start static site health checker", "error", err)
|
slog.Warn("failed to start static site health checker", "error", err)
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Put("/", s.updateProject)
|
r.Put("/", s.updateProject)
|
||||||
r.Delete("/", s.deleteProject)
|
r.Delete("/", s.deleteProject)
|
||||||
|
|
||||||
|
// Per-project webhook URL management.
|
||||||
|
r.Get("/webhook", s.getProjectWebhook)
|
||||||
|
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
|
||||||
|
|
||||||
// Stage endpoints.
|
// Stage endpoints.
|
||||||
r.Post("/stages", s.createStage)
|
r.Post("/stages", s.createStage)
|
||||||
r.Put("/stages/{stage}", s.updateStage)
|
r.Put("/stages/{stage}", s.updateStage)
|
||||||
@@ -293,6 +297,8 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Post("/deploy", s.deployStaticSite)
|
r.Post("/deploy", s.deployStaticSite)
|
||||||
r.Post("/stop", s.stopStaticSite)
|
r.Post("/stop", s.stopStaticSite)
|
||||||
r.Post("/start", s.startStaticSite)
|
r.Post("/start", s.startStaticSite)
|
||||||
|
r.Get("/webhook", s.getStaticSiteWebhook)
|
||||||
|
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
|
||||||
r.Post("/secrets", s.createStaticSiteSecret)
|
r.Post("/secrets", s.createStaticSiteSecret)
|
||||||
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
|
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
|
||||||
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
|
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
|
||||||
@@ -372,8 +378,6 @@ func (s *Server) Router() chi.Router {
|
|||||||
|
|
||||||
// Settings endpoints.
|
// Settings endpoints.
|
||||||
r.Put("/settings", s.updateSettings)
|
r.Put("/settings", s.updateSettings)
|
||||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
|
||||||
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
|
|
||||||
|
|
||||||
// Docker management.
|
// Docker management.
|
||||||
r.Post("/docker/prune-images", s.pruneImages)
|
r.Post("/docker/prune-images", s.pruneImages)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/proxy"
|
"github.com/alexei/tinyforge/internal/proxy"
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
"github.com/alexei/tinyforge/internal/volume"
|
"github.com/alexei/tinyforge/internal/volume"
|
||||||
"github.com/alexei/tinyforge/internal/webhook"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// settingsRequest is the expected JSON body for updating settings.
|
// 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"})
|
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.
|
// listNpmCertificates handles GET /api/settings/npm-certificates.
|
||||||
// It authenticates to NPM using the stored credentials and returns only wildcard certificates.
|
// It authenticates to NPM using the stored credentials and returns only wildcard certificates.
|
||||||
func (s *Server) listNpmCertificates(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listNpmCertificates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type Project struct {
|
|||||||
Env string `json:"env"` // JSON-encoded map
|
Env string `json:"env"` // JSON-encoded map
|
||||||
Volumes string `json:"volumes"` // JSON-encoded map
|
Volumes string `json:"volumes"` // JSON-encoded map
|
||||||
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
|
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"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,6 @@ type Settings struct {
|
|||||||
NpmURL string `json:"npm_url"`
|
NpmURL string `json:"npm_url"`
|
||||||
NpmEmail string `json:"npm_email"`
|
NpmEmail string `json:"npm_email"`
|
||||||
NpmPassword string `json:"npm_password"`
|
NpmPassword string `json:"npm_password"`
|
||||||
WebhookSecret string `json:"webhook_secret"`
|
|
||||||
PollingInterval string `json:"polling_interval"`
|
PollingInterval string `json:"polling_interval"`
|
||||||
BaseVolumePath string `json:"base_volume_path"`
|
BaseVolumePath string `json:"base_volume_path"`
|
||||||
SSLCertificateID int `json:"ssl_certificate_id"`
|
SSLCertificateID int `json:"ssl_certificate_id"`
|
||||||
@@ -219,6 +219,7 @@ type StaticSite struct {
|
|||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
StorageEnabled bool `json:"storage_enabled"`
|
StorageEnabled bool `json:"storage_enabled"`
|
||||||
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
|
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
|
||||||
|
WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-16
@@ -8,16 +8,25 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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) {
|
func (s *Store) CreateProject(p Project) (Project, error) {
|
||||||
p.ID = uuid.New().String()
|
p.ID = uuid.New().String()
|
||||||
p.CreatedAt = Now()
|
p.CreatedAt = Now()
|
||||||
p.UpdatedAt = p.CreatedAt
|
p.UpdatedAt = p.CreatedAt
|
||||||
|
if p.WebhookSecret == "" {
|
||||||
|
p.WebhookSecret = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at)
|
`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.CreatedAt, p.UpdatedAt,
|
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 {
|
if err != nil {
|
||||||
return Project{}, fmt.Errorf("insert project: %w", err)
|
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) {
|
func (s *Store) GetProjectByID(id string) (Project, error) {
|
||||||
var p Project
|
var p Project
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at
|
`SELECT `+projectCols+` FROM projects WHERE id = ?`, id,
|
||||||
FROM projects WHERE id = ?`, id,
|
).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
||||||
).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.CreatedAt, &p.UpdatedAt)
|
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
|
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
|
||||||
}
|
}
|
||||||
@@ -41,11 +50,30 @@ func (s *Store) GetProjectByID(id string) (Project, error) {
|
|||||||
return p, nil
|
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.
|
// GetAllProjects returns every project ordered by name.
|
||||||
func (s *Store) GetAllProjects() ([]Project, error) {
|
func (s *Store) GetAllProjects() ([]Project, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at
|
`SELECT ` + projectCols + ` FROM projects ORDER BY name`,
|
||||||
FROM projects ORDER BY name`,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query projects: %w", err)
|
return nil, fmt.Errorf("query projects: %w", err)
|
||||||
@@ -55,7 +83,8 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
|||||||
projects := []Project{}
|
projects := []Project{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Project
|
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)
|
return nil, fmt.Errorf("scan project: %w", err)
|
||||||
}
|
}
|
||||||
projects = append(projects, p)
|
projects = append(projects, p)
|
||||||
@@ -66,8 +95,7 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
|||||||
// GetProjectsByImage returns all projects using the given image, newest first.
|
// GetProjectsByImage returns all projects using the given image, newest first.
|
||||||
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at
|
`SELECT `+projectCols+` FROM projects WHERE image = ? ORDER BY created_at DESC`, image,
|
||||||
FROM projects WHERE image = ? ORDER BY created_at DESC`, image,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query projects by image: %w", err)
|
return nil, fmt.Errorf("query projects by image: %w", err)
|
||||||
@@ -77,7 +105,8 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
|||||||
projects := []Project{}
|
projects := []Project{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Project
|
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)
|
return nil, fmt.Errorf("scan project: %w", err)
|
||||||
}
|
}
|
||||||
projects = append(projects, p)
|
projects = append(projects, p)
|
||||||
@@ -85,13 +114,16 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
|||||||
return projects, rows.Err()
|
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 {
|
func (s *Store) UpdateProject(p Project) error {
|
||||||
p.UpdatedAt = Now()
|
p.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
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=?`,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("update project: %w", err)
|
return fmt.Errorf("update project: %w", err)
|
||||||
@@ -103,6 +135,41 @@ func (s *Store) UpdateProject(p Project) error {
|
|||||||
return nil
|
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.
|
// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys.
|
||||||
func (s *Store) DeleteProject(id string) error {
|
func (s *Store) DeleteProject(id string) error {
|
||||||
result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
var wildcardDNS, npmRemote, backupEnabled int
|
var wildcardDNS, npmRemote, backupEnabled int
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT domain, server_ip, public_ip, network, subdomain_pattern, notification_url,
|
`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,
|
base_volume_path, ssl_certificate_id, stale_threshold_days,
|
||||||
allowed_volume_paths, wildcard_dns, dns_provider,
|
allowed_volume_paths, wildcard_dns, dns_provider,
|
||||||
cloudflare_api_token, cloudflare_zone_id,
|
cloudflare_api_token, cloudflare_zone_id,
|
||||||
@@ -21,7 +21,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
updated_at
|
updated_at
|
||||||
FROM settings WHERE id = 1`,
|
FROM settings WHERE id = 1`,
|
||||||
).Scan(&st.Domain, &st.ServerIP, &st.PublicIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
|
).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.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
||||||
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
||||||
&st.CloudflareAPIToken, &st.CloudflareZoneID,
|
&st.CloudflareAPIToken, &st.CloudflareZoneID,
|
||||||
@@ -57,7 +57,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`UPDATE settings SET
|
`UPDATE settings SET
|
||||||
domain=?, server_ip=?, public_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
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=?,
|
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
|
||||||
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
||||||
cloudflare_api_token=?, cloudflare_zone_id=?,
|
cloudflare_api_token=?, cloudflare_zone_id=?,
|
||||||
@@ -68,7 +68,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id = 1`,
|
WHERE id = 1`,
|
||||||
st.Domain, st.ServerIP, st.PublicIP, st.Network, st.SubdomainPattern, st.NotificationURL,
|
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.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
||||||
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
||||||
st.CloudflareAPIToken, st.CloudflareZoneID,
|
st.CloudflareAPIToken, st.CloudflareZoneID,
|
||||||
|
|||||||
@@ -13,23 +13,27 @@ import (
|
|||||||
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
|
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
|
||||||
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
|
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
|
||||||
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
|
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) {
|
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
|
||||||
site.ID = uuid.New().String()
|
site.ID = uuid.New().String()
|
||||||
site.CreatedAt = Now()
|
site.CreatedAt = Now()
|
||||||
site.UpdatedAt = site.CreatedAt
|
site.UpdatedAt = site.CreatedAt
|
||||||
|
if site.WebhookSecret == "" {
|
||||||
|
site.WebhookSecret = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO static_sites (`+staticSiteCols+`)
|
`INSERT INTO static_sites (`+staticSiteCols+`)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
||||||
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
||||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
||||||
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
||||||
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
||||||
site.CreatedAt, site.UpdatedAt,
|
site.WebhookSecret, site.CreatedAt, site.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
|
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,
|
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||||
&site.CreatedAt, &site.UpdatedAt,
|
&site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StaticSite{}, err
|
return StaticSite{}, err
|
||||||
@@ -242,7 +246,7 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
|||||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||||
&site.CreatedAt, &site.UpdatedAt,
|
&site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
|
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
|
||||||
@@ -251,3 +255,55 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
|||||||
site.StorageEnabled = storageEnabled != 0
|
site.StorageEnabled = storageEnabled != 0
|
||||||
return site, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ func (s *Store) runMigrations() error {
|
|||||||
// Add persistent storage columns to static_sites (2026-04-12).
|
// 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_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb 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
|
// 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_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_revisions_stack_id ON stack_revisions(stack_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(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 {
|
for _, idx := range indexes {
|
||||||
if _, err := s.db.Exec(idx); err != nil {
|
if _, err := s.db.Exec(idx); err != nil {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
+153
-81
@@ -2,17 +2,15 @@ package webhook
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/docker"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,18 +20,27 @@ type DeployTriggerer interface {
|
|||||||
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageInspector abstracts Docker image inspection for testability.
|
// SiteSyncTriggerer is called when a static-site webhook determines a sync
|
||||||
type ImageInspector interface {
|
// should happen. The manager handles the actual git-pull + redeploy.
|
||||||
InspectImage(ctx context.Context, imageRef string) (docker.ImageInfo, error)
|
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 {
|
type Payload struct {
|
||||||
// Image is the full image reference including tag, e.g.
|
// Image is the full image reference including tag, e.g.
|
||||||
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
|
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
|
||||||
Image string `json:"image"`
|
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.
|
// ParsedImage holds the components extracted from a full image reference string.
|
||||||
type ParsedImage struct {
|
type ParsedImage struct {
|
||||||
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
|
// 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.
|
// Handler is the HTTP handler for webhook requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
deployer DeployTriggerer
|
deployer DeployTriggerer
|
||||||
inspector ImageInspector
|
sites SiteSyncTriggerer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new webhook Handler.
|
// NewHandler creates a new webhook Handler. The sites triggerer is optional
|
||||||
func NewHandler(st *store.Store, deployer DeployTriggerer, inspector ImageInspector) *Handler {
|
// and may be nil (site webhooks will return 404).
|
||||||
return &Handler{
|
func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler {
|
||||||
store: st,
|
return &Handler{store: st, deployer: deployer, sites: sites}
|
||||||
deployer: deployer,
|
|
||||||
inspector: inspector,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (h *Handler) Route() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
r.Post("/sites/{secret}", h.handleSiteWebhook)
|
||||||
r.Post("/{secret}", h.handleWebhook)
|
r.Post("/{secret}", h.handleWebhook)
|
||||||
return r
|
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})
|
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWebhook processes an incoming webhook request.
|
// handleWebhook processes an incoming project webhook request.
|
||||||
// URL format: POST /api/webhook/{secret-uuid}
|
//
|
||||||
// Returns 404 for invalid secrets (no information leak).
|
// 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) {
|
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
@@ -149,20 +171,17 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the webhook secret against stored settings.
|
project, err := h.store.GetProjectByWebhookSecret(secret)
|
||||||
settings, err := h.store.GetSettings()
|
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.WebhookSecret == "" || subtle.ConstantTimeCompare([]byte(settings.WebhookSecret), []byte(secret)) != 1 {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the request body.
|
|
||||||
var payload Payload
|
var payload Payload
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
||||||
@@ -180,37 +199,48 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default tag to "latest" if omitted.
|
|
||||||
if parsed.Tag == "" {
|
if parsed.Tag == "" {
|
||||||
parsed.Tag = "latest"
|
parsed.Tag = "latest"
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("webhook: received push", "image", parsed.FullName(), "tag", parsed.Tag)
|
// Guardrail: refuse payloads whose image doesn't match the project's
|
||||||
|
// configured image. Not a security control (the secret already scopes
|
||||||
// Look up a matching project by image name.
|
// access) — just a misconfiguration check that prevents accidental
|
||||||
project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed)
|
// cross-project deploys from a misaimed CI pipeline.
|
||||||
if err != nil {
|
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
|
||||||
slog.Error("webhook: lookup error", "error", err)
|
slog.Warn("webhook: image mismatch",
|
||||||
respondWebhookError(w, http.StatusInternalServerError, "internal error")
|
"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
|
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 {
|
if !found {
|
||||||
// Unknown project — auto-create with defaults from image inspection.
|
slog.Info("webhook: no stage matches tag",
|
||||||
slog.Info("webhook: unknown image, auto-creating project", "image", parsed.FullName())
|
"project", project.Name, "tag", parsed.Tag)
|
||||||
project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed)
|
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||||
if err != nil {
|
"success": true, "deploy": false, "project": project.Name,
|
||||||
slog.Error("webhook: auto-create failed", "error", err)
|
"reason": "no stage pattern matched tag",
|
||||||
respondWebhookError(w, http.StatusInternalServerError, "failed to auto-create project")
|
})
|
||||||
return
|
return
|
||||||
}
|
|
||||||
slog.Info("webhook: auto-created project", "project", project.Name, "id", project.ID, "stage", stage.Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only deploy if auto_deploy is enabled for the matched stage.
|
|
||||||
if !stage.AutoDeploy {
|
if !stage.AutoDeploy {
|
||||||
slog.Info("webhook: auto_deploy disabled, skipping", "project", project.Name, "stage", stage.Name)
|
slog.Info("webhook: auto_deploy disabled, skipping",
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": false, "project": project.Name, "stage": stage.Name})
|
"project", project.Name, "stage", stage.Name)
|
||||||
|
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true, "deploy": false,
|
||||||
|
"project": project.Name, "stage": stage.Name,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,44 +250,86 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
|
slog.Info("webhook: triggered deploy",
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag})
|
"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.
|
// handleSiteWebhook processes an incoming static-site webhook request.
|
||||||
// If not, it generates a new UUID and stores it. Returns the current secret.
|
//
|
||||||
func EnsureWebhookSecret(st *store.Store) (string, error) {
|
// URL: POST /api/webhook/sites/{secret}
|
||||||
settings, err := st.GetSettings()
|
//
|
||||||
|
// 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 {
|
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 != "" {
|
// Manual sites do not auto-sync via webhook. Return success but skip.
|
||||||
return settings.WebhookSecret, nil
|
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()
|
// Body is optional — decode best-effort.
|
||||||
if err := st.UpdateSettings(settings); err != nil {
|
var payload SitePayload
|
||||||
return "", fmt.Errorf("store webhook secret: %w", err)
|
if r.ContentLength > 0 {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("webhook: generated new secret")
|
if payload.Ref != "" && !siteRefMatches(site, payload.Ref) {
|
||||||
return settings.WebhookSecret, nil
|
slog.Info("webhook: site ref does not match configured branch/tag",
|
||||||
}
|
"site", site.Name, "ref", payload.Ref,
|
||||||
|
"branch", site.Branch, "tag_pattern", site.TagPattern,
|
||||||
// RegenerateWebhookSecret generates a new webhook secret UUID, replacing and
|
"trigger", site.SyncTrigger)
|
||||||
// invalidating the old one. Returns the new secret.
|
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||||
func RegenerateWebhookSecret(st *store.Store) (string, error) {
|
"success": true, "sync": false, "site": site.Name,
|
||||||
settings, err := st.GetSettings()
|
"reason": "ref does not match configured branch or tag pattern",
|
||||||
if err != nil {
|
})
|
||||||
return "", fmt.Errorf("get settings: %w", err)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.WebhookSecret = uuid.New().String()
|
// Fire and forget — sync may take a while (git fetch + container rebuild).
|
||||||
if err := st.UpdateSettings(settings); err != nil {
|
go func(siteID, siteName string) {
|
||||||
return "", fmt.Errorf("store webhook secret: %w", err)
|
if err := h.sites.Deploy(context.Background(), siteID, false); err != nil {
|
||||||
}
|
slog.Error("webhook: site sync failed", "site", siteName, "error", err)
|
||||||
|
}
|
||||||
slog.Info("webhook: regenerated secret")
|
}(site.ID, site.Name)
|
||||||
return settings.WebhookSecret, nil
|
|
||||||
|
_ = 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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-55
@@ -1,67 +1,13 @@
|
|||||||
package webhook
|
package webhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"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
|
// 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).
|
// 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) {
|
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
|
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/<branch> ref whose
|
||||||
|
// branch name equals site.Branch.
|
||||||
|
// For sync_trigger = "tag": the ref must be a tags/<tag> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-4
@@ -319,12 +319,27 @@ export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
|||||||
return put<Settings>('/api/settings', data);
|
return put<Settings>('/api/settings', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWebhookUrl(): Promise<{ webhook_url: string }> {
|
// ── Webhooks ───────────────────────────────────────────────────────
|
||||||
return get<{ webhook_url: string }>('/api/settings/webhook-url');
|
|
||||||
|
export interface WebhookUrlResponse {
|
||||||
|
webhook_url: string;
|
||||||
|
webhook_secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function regenerateWebhookUrl(): Promise<{ webhook_url: string }> {
|
export function getProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
|
||||||
return post<{ webhook_url: string }>('/api/settings/webhook-url/regenerate');
|
return get<WebhookUrlResponse>(`/api/projects/${projectId}/webhook`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
|
||||||
|
return post<WebhookUrlResponse>(`/api/projects/${projectId}/webhook/regenerate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
|
||||||
|
return get<WebhookUrlResponse>(`/api/sites/${siteId}/webhook`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
|
||||||
|
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Proxy Routes ───────────────────────────────────────────────────
|
// ── Proxy Routes ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<!--
|
||||||
|
WebhookPanel
|
||||||
|
|
||||||
|
Generic panel that displays an entity-scoped webhook URL and exposes a
|
||||||
|
"regenerate" action. Used by both project and static-site detail pages.
|
||||||
|
|
||||||
|
Parent supplies a fetch + regenerate pair returning { webhook_url, webhook_secret }.
|
||||||
|
Panel absolutises the URL with window.location.origin for easy copy/paste.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toasts } from '$lib/stores/toast';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import { IconCopy, IconRefresh, IconLoader } from '$lib/components/icons';
|
||||||
|
|
||||||
|
interface WebhookUrlResponse {
|
||||||
|
webhook_url: string;
|
||||||
|
webhook_secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fetchWebhook: () => Promise<WebhookUrlResponse>;
|
||||||
|
regenerateWebhook: () => Promise<WebhookUrlResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, description, fetchWebhook, regenerateWebhook }: Props = $props();
|
||||||
|
|
||||||
|
let relativeUrl = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
let regenerating = $state(false);
|
||||||
|
let confirmOpen = $state(false);
|
||||||
|
|
||||||
|
const absoluteUrl = $derived(
|
||||||
|
relativeUrl && typeof window !== 'undefined' ? window.location.origin + relativeUrl : relativeUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetchWebhook();
|
||||||
|
relativeUrl = res.webhook_url;
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
confirmOpen = false;
|
||||||
|
regenerating = true;
|
||||||
|
try {
|
||||||
|
const res = await regenerateWebhook();
|
||||||
|
relativeUrl = res.webhook_url;
|
||||||
|
toasts.success($t('webhookPanel.regenerated'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.regenerateFailed'));
|
||||||
|
} finally {
|
||||||
|
regenerating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
if (!absoluteUrl) return;
|
||||||
|
navigator.clipboard.writeText(absoluteUrl).then(
|
||||||
|
() => toasts.info($t('webhookPanel.copied')),
|
||||||
|
() => toasts.error($t('webhookPanel.copyFailed'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||||
|
<p class="mb-4 text-sm text-[var(--text-secondary)]">{description}</p>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="h-11 rounded-lg bg-[var(--surface-card-hover)]"></div>
|
||||||
|
{:else if relativeUrl}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<code class="flex-1 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)] break-all">
|
||||||
|
{absoluteUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleCopy}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<IconCopy size={16} />
|
||||||
|
{$t('webhookPanel.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-[var(--text-tertiary)] italic">{$t('webhookPanel.noUrl')}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
{#if confirmOpen}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">{$t('webhookPanel.confirmRegenerate')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleRegenerate}
|
||||||
|
disabled={regenerating}
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-danger)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 active:animate-press"
|
||||||
|
>
|
||||||
|
{#if regenerating}<IconLoader size={14} />{/if}
|
||||||
|
{$t('webhookPanel.confirmYes')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmOpen = false)}
|
||||||
|
class="inline-flex items-center rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]"
|
||||||
|
>
|
||||||
|
{$t('webhookPanel.confirmNo')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmOpen = true)}
|
||||||
|
disabled={regenerating || loading}
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors disabled:opacity-50 active:animate-press"
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} />
|
||||||
|
{$t('webhookPanel.regenerate')}
|
||||||
|
</button>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('webhookPanel.regenerateWarning')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -74,6 +74,8 @@
|
|||||||
"noMatchingProjects": "No projects match your search."
|
"noMatchingProjects": "No projects match your search."
|
||||||
},
|
},
|
||||||
"projectDetail": {
|
"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",
|
"deleteProject": "Delete Project",
|
||||||
"envVars": "Environment Variables",
|
"envVars": "Environment Variables",
|
||||||
"volumes": "Volume Mounts",
|
"volumes": "Volume Mounts",
|
||||||
@@ -570,6 +572,8 @@
|
|||||||
"lastChecked": "Last checked"
|
"lastChecked": "Last checked"
|
||||||
},
|
},
|
||||||
"sites": {
|
"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",
|
"title": "Static Sites",
|
||||||
"addSite": "New Site",
|
"addSite": "New Site",
|
||||||
"newSite": "New Static Site",
|
"newSite": "New Static Site",
|
||||||
@@ -1099,7 +1103,22 @@
|
|||||||
"title": "Integrations",
|
"title": "Integrations",
|
||||||
"outgoing": "Outgoing notifications",
|
"outgoing": "Outgoing notifications",
|
||||||
"outgoingDesc": "Where Tinyforge posts deploy and alert events. Paste a webhook URL (Apprise, Discord, Slack, your own handler).",
|
"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": {
|
"settingsMaintenance": {
|
||||||
"title": "Maintenance",
|
"title": "Maintenance",
|
||||||
|
|||||||
@@ -74,6 +74,8 @@
|
|||||||
"noMatchingProjects": "Проекты не найдены."
|
"noMatchingProjects": "Проекты не найдены."
|
||||||
},
|
},
|
||||||
"projectDetail": {
|
"projectDetail": {
|
||||||
|
"webhookTitle": "Webhook проекта",
|
||||||
|
"webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.",
|
||||||
"deleteProject": "Удалить проект",
|
"deleteProject": "Удалить проект",
|
||||||
"envVars": "Переменные окружения",
|
"envVars": "Переменные окружения",
|
||||||
"volumes": "Тома",
|
"volumes": "Тома",
|
||||||
@@ -570,6 +572,8 @@
|
|||||||
"lastChecked": "Последняя проверка"
|
"lastChecked": "Последняя проверка"
|
||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
|
"webhookTitle": "Webhook сайта",
|
||||||
|
"webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.",
|
||||||
"title": "Статические сайты",
|
"title": "Статические сайты",
|
||||||
"addSite": "Новый сайт",
|
"addSite": "Новый сайт",
|
||||||
"newSite": "Новый статический сайт",
|
"newSite": "Новый статический сайт",
|
||||||
@@ -1099,7 +1103,22 @@
|
|||||||
"title": "Интеграции",
|
"title": "Интеграции",
|
||||||
"outgoing": "Исходящие уведомления",
|
"outgoing": "Исходящие уведомления",
|
||||||
"outgoingDesc": "Куда Tinyforge отправляет события деплоев и алертов. Укажите webhook-URL (Apprise, Discord, Slack, свой обработчик).",
|
"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": {
|
"settingsMaintenance": {
|
||||||
"title": "Обслуживание",
|
"title": "Обслуживание",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
|
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
import type { EntityPickerItem } from '$lib/types';
|
import type { EntityPickerItem } from '$lib/types';
|
||||||
import { IconShield } from '$lib/components/icons';
|
import { IconShield } from '$lib/components/icons';
|
||||||
@@ -767,6 +768,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Webhook -->
|
||||||
|
<WebhookPanel
|
||||||
|
title={$t('projectDetail.webhookTitle')}
|
||||||
|
description={$t('projectDetail.webhookDesc')}
|
||||||
|
fetchWebhook={() => api.getProjectWebhook(projectId)}
|
||||||
|
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Deploy History Timeline -->
|
<!-- Deploy History Timeline -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
<!--
|
<!--
|
||||||
Settings › Integrations
|
Settings › Integrations
|
||||||
|
|
||||||
Outward-facing hooks: where Tinyforge *sends* events (notification URL)
|
Outward-facing hooks: where Tinyforge *sends* events (notification URL).
|
||||||
and where other systems send events *to* Tinyforge (webhook URL).
|
Inbound webhooks are per-project / per-site and live on their respective
|
||||||
Keeps discovery in one place instead of burying webhook regen at the
|
detail pages — this page no longer exposes a global "master" webhook.
|
||||||
bottom of the General page.
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl } from '$lib/api';
|
import { getSettings, updateSettings } from '$lib/api';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconLoader, IconCopy, IconRefresh } from '$lib/components/icons';
|
import { IconLoader } from '$lib/components/icons';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let regenerating = $state(false);
|
|
||||||
|
|
||||||
let notificationUrl = $state('');
|
let notificationUrl = $state('');
|
||||||
let webhookUrl = $state('');
|
|
||||||
let errors = $state<Record<string, string>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
function validateUrl(value: string): string {
|
function validateUrl(value: string): string {
|
||||||
@@ -30,9 +27,8 @@
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const [settings, hook] = await Promise.all([getSettings(), getWebhookUrl().catch(() => ({ webhook_url: '' }))]);
|
const settings = await getSettings();
|
||||||
notificationUrl = settings.notification_url ?? '';
|
notificationUrl = settings.notification_url ?? '';
|
||||||
webhookUrl = hook.webhook_url ?? '';
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,19 +51,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRegenerateWebhook() {
|
|
||||||
regenerating = true;
|
|
||||||
try {
|
|
||||||
const result = await regenerateWebhookUrl();
|
|
||||||
webhookUrl = result.webhook_url;
|
|
||||||
toasts.success($t('settingsGeneral.regenerated'));
|
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.regenerateFailed'));
|
|
||||||
} finally {
|
|
||||||
regenerating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { load(); });
|
$effect(() => { load(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -80,7 +63,6 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Skeleton height="2rem" width="12rem" />
|
<Skeleton height="2rem" width="12rem" />
|
||||||
<Skeleton height="6rem" />
|
<Skeleton height="6rem" />
|
||||||
<Skeleton height="6rem" />
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Outgoing: notification URL -->
|
<!-- Outgoing: notification URL -->
|
||||||
@@ -105,40 +87,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Incoming: webhook URL + regenerate -->
|
<!-- Inbound hooks now live per-entity. -->
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.incoming')}</h2>
|
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.incoming')}</h2>
|
||||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsGeneral.webhookDesc')}</p>
|
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsIntegrations.incomingMovedDesc')}</p>
|
||||||
|
|
||||||
{#if webhookUrl}
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<code class="flex-1 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)] break-all">
|
|
||||||
{webhookUrl}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
onclick={() => { navigator.clipboard.writeText(webhookUrl); toasts.info($t('settingsGeneral.copied')); }}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconCopy size={16} />
|
|
||||||
{$t('settingsGeneral.copy')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-[var(--text-tertiary)] italic">{$t('settingsGeneral.noWebhookUrl')}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<button
|
|
||||||
onclick={handleRegenerateWebhook}
|
|
||||||
disabled={regenerating}
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors disabled:opacity-50 active:animate-press"
|
|
||||||
>
|
|
||||||
{#if regenerating}<IconLoader size={16} />{/if}
|
|
||||||
<IconRefresh size={16} />
|
|
||||||
{regenerating ? $t('settingsGeneral.regenerating') : $t('settingsGeneral.regenerateUrl')}
|
|
||||||
</button>
|
|
||||||
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.regenerateWarning')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.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';
|
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
|
||||||
|
|
||||||
let site = $state<StaticSite | null>(null);
|
let site = $state<StaticSite | null>(null);
|
||||||
@@ -250,6 +251,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook -->
|
||||||
|
<WebhookPanel
|
||||||
|
title={$t('sites.webhookTitle')}
|
||||||
|
description={$t('sites.webhookDesc')}
|
||||||
|
fetchWebhook={() => api.getStaticSiteWebhook(siteId!)}
|
||||||
|
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Secrets -->
|
<!-- Secrets -->
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user