CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
203 lines
6.8 KiB
Go
203 lines
6.8 KiB
Go
// Package trigger implementiert INT-20: Trigger loesen bei einem Ereignis
|
|
// (Upload abgeschlossen, Texterkennung fertig, E-Rechnung geprueft) eine
|
|
// Aktion aus (Webhook, Tag setzen, Dokumententyp setzen). Ein Trigger-Fehler
|
|
// wird protokolliert, blockiert aber nie die Verarbeitung des Dokuments
|
|
// selbst (fire-and-forget mit Nachweis in trigger_runs und dem Audit-Log).
|
|
package trigger
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
|
|
)
|
|
|
|
const (
|
|
EventUploaded = "document.uploaded"
|
|
EventOCRCompleted = "document.ocr_completed"
|
|
EventEInvoiceChecked = "document.einvoice_checked"
|
|
|
|
ActionWebhook = "webhook"
|
|
ActionSetTag = "set_tag"
|
|
ActionSetType = "set_type"
|
|
|
|
maxPatternLength = 500
|
|
maxNameLength = 200
|
|
)
|
|
|
|
var validEvents = map[string]bool{
|
|
EventUploaded: true,
|
|
EventOCRCompleted: true,
|
|
EventEInvoiceChecked: true,
|
|
}
|
|
|
|
var (
|
|
ErrNotFound = errors.New("trigger: nicht gefunden")
|
|
ErrInvalid = errors.New("trigger: ungueltig")
|
|
)
|
|
|
|
// Trigger ist eine Datenbankzeile; ActionSecret ist nur beim Anlegen/Aendern
|
|
// gesetzt (Klartext-Eingabe), nie beim Lesen (siehe Store.List/Get).
|
|
type Trigger struct {
|
|
ID string
|
|
Name string
|
|
Event string
|
|
FilenamePattern *string
|
|
DocumentTypeID *string
|
|
ActionType string
|
|
ActionTagID *string
|
|
ActionDocumentTypeID *string
|
|
WebhookURL *string
|
|
Enabled bool
|
|
CreatedBy string
|
|
}
|
|
|
|
func invalid(format string, a ...any) error {
|
|
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
func (t Trigger) validate() error {
|
|
name := strings.TrimSpace(t.Name)
|
|
if name == "" || len(name) > maxNameLength {
|
|
return invalid("name muss 1 bis %d zeichen lang sein", maxNameLength)
|
|
}
|
|
if !validEvents[t.Event] {
|
|
return invalid("unbekanntes ereignis %q", t.Event)
|
|
}
|
|
if t.FilenamePattern != nil {
|
|
if len(*t.FilenamePattern) == 0 || len(*t.FilenamePattern) > maxPatternLength {
|
|
return invalid("dateinamensmuster muss 1 bis %d zeichen lang sein", maxPatternLength)
|
|
}
|
|
if _, err := regexp.Compile(*t.FilenamePattern); err != nil {
|
|
return invalid("dateinamensmuster ist kein gueltiger regulaerer ausdruck: %v", err)
|
|
}
|
|
}
|
|
switch t.ActionType {
|
|
case ActionWebhook:
|
|
if t.WebhookURL == nil || strings.TrimSpace(*t.WebhookURL) == "" {
|
|
return invalid("webhook-aktion braucht eine ziel-url")
|
|
}
|
|
if err := ValidateWebhookURL(*t.WebhookURL); err != nil {
|
|
return invalid("webhook-url: %v", err)
|
|
}
|
|
case ActionSetTag:
|
|
if t.ActionTagID == nil || strings.TrimSpace(*t.ActionTagID) == "" {
|
|
return invalid("set_tag-aktion braucht ein tag")
|
|
}
|
|
case ActionSetType:
|
|
if t.ActionDocumentTypeID == nil || strings.TrimSpace(*t.ActionDocumentTypeID) == "" {
|
|
return invalid("set_type-aktion braucht einen dokumententyp")
|
|
}
|
|
default:
|
|
return invalid("unbekannte aktion %q", t.ActionType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const columns = `id, name, event, filename_pattern, document_type_id, action_type, action_tag_id, action_document_type_id, webhook_url, enabled, created_by`
|
|
|
|
type scanner interface{ Scan(dest ...any) error }
|
|
|
|
func scanTrigger(row scanner) (Trigger, error) {
|
|
var t Trigger
|
|
err := row.Scan(&t.ID, &t.Name, &t.Event, &t.FilenamePattern, &t.DocumentTypeID, &t.ActionType, &t.ActionTagID, &t.ActionDocumentTypeID, &t.WebhookURL, &t.Enabled, &t.CreatedBy)
|
|
return t, err
|
|
}
|
|
|
|
func referenceError(err error) error {
|
|
if strings.Contains(err.Error(), "SQLSTATE 23503") {
|
|
return invalid("verweist auf einen unbekannten dokumententyp, tag oder ersteller")
|
|
}
|
|
return fmt.Errorf("trigger: speichern: %w", err)
|
|
}
|
|
|
|
// Create legt einen Trigger an. secret ist nur bei action_type=webhook relevant
|
|
// (optional) und wird ueber Vault.Seal verpackt, nie im Klartext gespeichert.
|
|
func Create(ctx context.Context, pool *pgxpool.Pool, v Vault, tenantSlug, actor string, t Trigger, secret string) (Trigger, error) {
|
|
t.Enabled = true
|
|
t.CreatedBy = actor
|
|
if err := t.validate(); err != nil {
|
|
return Trigger{}, err
|
|
}
|
|
var wrappedDEK, secretEnc []byte
|
|
if t.ActionType == ActionWebhook && secret != "" {
|
|
var err error
|
|
wrappedDEK, secretEnc, err = v.Seal(ctx, tenantSlug, secret)
|
|
if err != nil {
|
|
return Trigger{}, fmt.Errorf("trigger: geheimnis verschluesseln: %w", err)
|
|
}
|
|
}
|
|
out, err := scanTrigger(pool.QueryRow(ctx, `
|
|
INSERT INTO triggers (name, event, filename_pattern, document_type_id, action_type, action_tag_id, action_document_type_id, webhook_url, webhook_secret_wrapped_dek, webhook_secret_enc, enabled, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING `+columns,
|
|
t.Name, t.Event, t.FilenamePattern, t.DocumentTypeID, t.ActionType, t.ActionTagID, t.ActionDocumentTypeID, t.WebhookURL, wrappedDEK, secretEnc, t.Enabled, actor))
|
|
if err != nil {
|
|
return Trigger{}, referenceError(err)
|
|
}
|
|
if err := audittrail.Record(ctx, pool, actor, "trigger_created", "trigger", out.ID, map[string]any{"name": out.Name, "event": out.Event}); err != nil {
|
|
return Trigger{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SetEnabled aktiviert/deaktiviert einen Trigger, ohne die Konfiguration zu aendern.
|
|
func SetEnabled(ctx context.Context, pool *pgxpool.Pool, actor, id string, enabled bool) error {
|
|
tag, err := pool.Exec(ctx, `UPDATE triggers SET enabled = $2, updated_at = now() WHERE id = $1`, id, enabled)
|
|
if err != nil {
|
|
return fmt.Errorf("trigger: aktivieren/deaktivieren: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
action := "trigger_disabled"
|
|
if enabled {
|
|
action = "trigger_enabled"
|
|
}
|
|
return audittrail.Record(ctx, pool, actor, action, "trigger", id, nil)
|
|
}
|
|
|
|
// Delete entfernt einen Trigger (und mit ihm die trigger_runs, ON DELETE CASCADE).
|
|
func Delete(ctx context.Context, pool *pgxpool.Pool, actor, id string) error {
|
|
tag, err := pool.Exec(ctx, `DELETE FROM triggers WHERE id = $1`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("trigger: loeschen: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return audittrail.Record(ctx, pool, actor, "trigger_deleted", "trigger", id, nil)
|
|
}
|
|
|
|
// Get liefert einen Trigger ohne das Webhook-Geheimnis.
|
|
func Get(ctx context.Context, pool *pgxpool.Pool, id string) (Trigger, error) {
|
|
t, err := scanTrigger(pool.QueryRow(ctx, `SELECT `+columns+` FROM triggers WHERE id = $1`, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Trigger{}, ErrNotFound
|
|
}
|
|
return t, err
|
|
}
|
|
|
|
// List liefert alle Trigger, ohne das Webhook-Geheimnis.
|
|
func List(ctx context.Context, pool *pgxpool.Pool) ([]Trigger, error) {
|
|
rows, err := pool.Query(ctx, `SELECT `+columns+` FROM triggers ORDER BY name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("trigger: liste lesen: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []Trigger{}
|
|
for rows.Next() {
|
|
t, err := scanTrigger(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|