mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
Add total-count pagination to the webhook admin endpoints and the web UI.
Store:
- WebhookStore/WebhookCallStore gain Count; WebhookListFilter gains
IncludeRevoked + Query (PG + SQLite, parameterized, tenant-scoped)
API:
- GET /v1/webhooks and GET /v1/webhooks/{id}/calls return
{items, total, limit, offset} with server-side search + revoked filtering
Web UI:
- server-driven list pager + search/revoked filter; call-history dialog uses
the real total (fixes the full-page "has more" boundary bug)
- i18n pager labels (en/vi/zh)
Tests: store pagination integration test; mock stores implement Count.
No schema migration (read-only COUNT).
281 lines
7.6 KiB
Go
281 lines
7.6 KiB
Go
package pg
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// compile-time interface assertion
|
|
var _ store.WebhookStore = (*PGWebhookStore)(nil)
|
|
|
|
// PGWebhookStore implements store.WebhookStore using PostgreSQL.
|
|
type PGWebhookStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewPGWebhookStore creates a new PostgreSQL-backed webhook store.
|
|
func NewPGWebhookStore(db *sql.DB) *PGWebhookStore {
|
|
return &PGWebhookStore{db: db}
|
|
}
|
|
|
|
// webhookColumns is the canonical SELECT column list for webhooks.
|
|
const webhookColumns = `id, tenant_id, agent_id, name, kind, secret_prefix, secret_hash, encrypted_secret,
|
|
scopes, channel_id, rate_limit_per_min, ip_allowlist,
|
|
require_hmac, localhost_only, revoked, created_by,
|
|
created_at, updated_at, last_used_at`
|
|
|
|
// scanWebhookRow scans a single webhooks row into WebhookData.
|
|
// scopes and ip_allowlist are scanned as raw bytes from PostgreSQL text[] columns.
|
|
func scanWebhookRow(row interface {
|
|
Scan(dest ...any) error
|
|
}) (*store.WebhookData, error) {
|
|
var w store.WebhookData
|
|
var scopesRaw, ipAllowlistRaw []byte
|
|
var agentID, channelID *uuid.UUID
|
|
// secret_prefix and created_by are nullable TEXT columns.
|
|
var secretPrefix, createdBy *string
|
|
|
|
err := row.Scan(
|
|
&w.ID, &w.TenantID, &agentID,
|
|
&w.Name, &w.Kind, &secretPrefix, &w.SecretHash, &w.EncryptedSecret,
|
|
&scopesRaw, &channelID, &w.RateLimitPerMin, &ipAllowlistRaw,
|
|
&w.RequireHMAC, &w.LocalhostOnly, &w.Revoked, &createdBy,
|
|
&w.CreatedAt, &w.UpdatedAt, &w.LastUsedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
w.AgentID = agentID
|
|
w.ChannelID = channelID
|
|
if secretPrefix != nil {
|
|
w.SecretPrefix = *secretPrefix
|
|
}
|
|
if createdBy != nil {
|
|
w.CreatedBy = *createdBy
|
|
}
|
|
scanStringArray(scopesRaw, &w.Scopes)
|
|
scanStringArray(ipAllowlistRaw, &w.IPAllowlist)
|
|
return &w, nil
|
|
}
|
|
|
|
func (s *PGWebhookStore) Create(ctx context.Context, w *store.WebhookData) error {
|
|
// scopes and ip_allowlist are NOT NULL DEFAULT '{}'; coerce nil slices
|
|
// to empty arrays so Create works without requiring callers to set them.
|
|
scopes := w.Scopes
|
|
if scopes == nil {
|
|
scopes = []string{}
|
|
}
|
|
ipAllow := w.IPAllowlist
|
|
if ipAllow == nil {
|
|
ipAllow = []string{}
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO webhooks
|
|
(id, tenant_id, agent_id, name, kind, secret_prefix, secret_hash, encrypted_secret,
|
|
scopes, channel_id, rate_limit_per_min, ip_allowlist,
|
|
require_hmac, localhost_only, revoked, created_by, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
|
|
w.ID, w.TenantID, nilUUID(w.AgentID),
|
|
w.Name, w.Kind, nilStr(w.SecretPrefix), w.SecretHash, w.EncryptedSecret,
|
|
pqStringArray(scopes), nilUUID(w.ChannelID), w.RateLimitPerMin, pqStringArray(ipAllow),
|
|
w.RequireHMAC, w.LocalhostOnly, w.Revoked,
|
|
nilStr(w.CreatedBy), w.CreatedAt, w.UpdatedAt,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *PGWebhookStore) GetByID(ctx context.Context, id uuid.UUID) (*store.WebhookData, error) {
|
|
tid, err := requireTenantID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+webhookColumns+`
|
|
FROM webhooks
|
|
WHERE id = $1 AND tenant_id = $2`,
|
|
id, tid,
|
|
)
|
|
return scanWebhookRow(row)
|
|
}
|
|
|
|
func (s *PGWebhookStore) GetByHash(ctx context.Context, secretHash string) (*store.WebhookData, error) {
|
|
tid, err := requireTenantID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+webhookColumns+`
|
|
FROM webhooks
|
|
WHERE secret_hash = $1 AND tenant_id = $2 AND NOT revoked`,
|
|
secretHash, tid,
|
|
)
|
|
return scanWebhookRow(row)
|
|
}
|
|
|
|
// GetByHashUnscoped looks up a webhook by secret_hash without a tenant filter.
|
|
// Intended only for WebhookAuthMiddleware pre-auth resolution before tenant context
|
|
// has been established. Downstream queries must remain tenant-scoped.
|
|
func (s *PGWebhookStore) GetByHashUnscoped(ctx context.Context, secretHash string) (*store.WebhookData, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+webhookColumns+`
|
|
FROM webhooks
|
|
WHERE secret_hash = $1 AND NOT revoked`,
|
|
secretHash,
|
|
)
|
|
return scanWebhookRow(row)
|
|
}
|
|
|
|
// GetByIDUnscoped looks up a webhook by UUID without a tenant filter.
|
|
// Intended only for WebhookAuthMiddleware HMAC pre-auth resolution.
|
|
func (s *PGWebhookStore) GetByIDUnscoped(ctx context.Context, id uuid.UUID) (*store.WebhookData, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+webhookColumns+`
|
|
FROM webhooks
|
|
WHERE id = $1 AND NOT revoked`,
|
|
id,
|
|
)
|
|
return scanWebhookRow(row)
|
|
}
|
|
|
|
func (s *PGWebhookStore) List(ctx context.Context, f store.WebhookListFilter) ([]store.WebhookData, error) {
|
|
tid, err := requireTenantID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
q := `SELECT ` + webhookColumns + ` FROM webhooks WHERE tenant_id = $1`
|
|
args := []any{tid}
|
|
n := 2
|
|
|
|
if f.AgentID != nil {
|
|
q += fmt.Sprintf(` AND agent_id = $%d`, n)
|
|
args = append(args, *f.AgentID)
|
|
n++
|
|
}
|
|
if !f.IncludeRevoked {
|
|
q += ` AND revoked = false`
|
|
}
|
|
if f.Query != "" {
|
|
q += fmt.Sprintf(` AND (name ILIKE $%d OR secret_prefix LIKE $%d)`, n, n+1)
|
|
args = append(args, "%"+f.Query+"%", f.Query+"%")
|
|
n += 2
|
|
}
|
|
q += ` ORDER BY created_at DESC`
|
|
|
|
limit := f.Limit
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
q += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n, n+1)
|
|
args = append(args, limit, f.Offset)
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []store.WebhookData
|
|
for rows.Next() {
|
|
w, scanErr := scanWebhookRow(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
out = append(out, *w)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *PGWebhookStore) Count(ctx context.Context, f store.WebhookListFilter) (int, error) {
|
|
tid, err := requireTenantID(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
q := `SELECT COUNT(*) FROM webhooks WHERE tenant_id = $1`
|
|
args := []any{tid}
|
|
n := 2
|
|
|
|
if f.AgentID != nil {
|
|
q += fmt.Sprintf(` AND agent_id = $%d`, n)
|
|
args = append(args, *f.AgentID)
|
|
n++
|
|
}
|
|
if !f.IncludeRevoked {
|
|
q += ` AND revoked = false`
|
|
}
|
|
if f.Query != "" {
|
|
q += fmt.Sprintf(` AND (name ILIKE $%d OR secret_prefix LIKE $%d)`, n, n+1)
|
|
args = append(args, "%"+f.Query+"%", f.Query+"%")
|
|
n += 2
|
|
}
|
|
|
|
var total int
|
|
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&total); err != nil {
|
|
return 0, err
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (s *PGWebhookStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
|
|
tid, err := requireTenantID(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return execMapUpdateWhereTenant(ctx, s.db, "webhooks", updates, id, tid)
|
|
}
|
|
|
|
func (s *PGWebhookStore) RotateSecret(ctx context.Context, id uuid.UUID, newSecretHash, newPrefix, newEncryptedSecret string) error {
|
|
tid, err := requireTenantID(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE webhooks SET secret_hash = $1, secret_prefix = $2, encrypted_secret = $3, updated_at = $4
|
|
WHERE id = $5 AND tenant_id = $6`,
|
|
newSecretHash, newPrefix, newEncryptedSecret, time.Now(), id, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PGWebhookStore) Revoke(ctx context.Context, id uuid.UUID) error {
|
|
tid, err := requireTenantID(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE webhooks SET revoked = true, updated_at = $1
|
|
WHERE id = $2 AND tenant_id = $3`,
|
|
time.Now(), id, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PGWebhookStore) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE webhooks SET last_used_at = $1 WHERE id = $2`,
|
|
time.Now(), id,
|
|
)
|
|
return err
|
|
}
|