146 lines
5.2 KiB
Go
146 lines
5.2 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/contract"
|
|
"git.ipao.vip/rogee/go-sip/internal/tenant"
|
|
)
|
|
|
|
var ErrUnidentifiableData = errors.New("rejected data identity cannot be verified")
|
|
|
|
// RejectData saves private original bytes before committing an immutable outbox
|
|
// rejection. The caller may acknowledge the input only after this succeeds.
|
|
func (s *Store) RejectData(dispatcherID string, original []byte, cause error) error {
|
|
var id struct {
|
|
EventID string `json:"event_id"`
|
|
EventType string `json:"event_type"`
|
|
DispatcherID string `json:"dispatcher_id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
if json.Unmarshal(original, &id) != nil || id.DispatcherID != dispatcherID || id.EventID == "" || len(id.EventID) > 255 {
|
|
return ErrUnidentifiableData
|
|
}
|
|
if id.EventType != "task.control" && id.EventType != "call.execute" && id.EventType != "sip.config" {
|
|
return ErrUnidentifiableData
|
|
}
|
|
defer s.HoldCallLifecycle()()
|
|
// A structurally valid receipt is not an original command identity. Never
|
|
// turn a bounced outbound receipt into another receipt.
|
|
if contract.ValidateCurrent("mq", original) == nil {
|
|
var envelope map[string]json.RawMessage
|
|
if err := json.Unmarshal(original, &envelope); err != nil {
|
|
return err
|
|
}
|
|
var payload map[string]json.RawMessage
|
|
if err := json.Unmarshal(envelope["payload"], &payload); err != nil {
|
|
return err
|
|
}
|
|
if _, receipt := payload["status"]; receipt {
|
|
return ErrUnidentifiableData
|
|
}
|
|
}
|
|
if id.EventType != "sip.config" {
|
|
var known int
|
|
if err := s.db.QueryRow(`SELECT count(*) FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=?`, dispatcherID, id.TenantID).Scan(&known); err != nil {
|
|
return err
|
|
}
|
|
if id.TenantID <= 0 || known == 0 {
|
|
return ErrUnidentifiableData
|
|
}
|
|
}
|
|
if cause == nil {
|
|
return errors.New("rejection requires an actual failure reason")
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var payload any = map[string]any{"status": "rejected", "reason_message": cause.Error()}
|
|
if id.EventType == "task.control" {
|
|
replayed, err := replayRejectedEvent(tx, dispatcherID, id.EventID, id.EventType, id.TenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if replayed {
|
|
return tx.Commit()
|
|
}
|
|
payload, err = rejectedControlPayload(tx, dispatcherID, id.TenantID, id.Payload, "invalid_data", cause.Error())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if id.EventType == "call.execute" {
|
|
payload = map[string]any{"status": "rejected", "reason_message": cause.Error(), "reason_code": nil}
|
|
}
|
|
receipt := map[string]any{"event_id": id.EventID, "event_type": id.EventType, "dispatcher_id": dispatcherID, "payload": payload}
|
|
if id.EventType != "sip.config" {
|
|
receipt["tenant_id"] = id.TenantID
|
|
}
|
|
body, err := json.Marshal(receipt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := contract.ValidateCurrent("mq", body); err != nil {
|
|
return fmt.Errorf("rejection feedback violates current Schema: %w", err)
|
|
}
|
|
var failure *contract.ValidationFailure
|
|
var invalidSource []byte
|
|
if errors.As(cause, &failure) {
|
|
invalidSource = failure.Raw
|
|
}
|
|
evidence, err := json.Marshal(struct {
|
|
Original []byte
|
|
InvalidSource []byte
|
|
Feedback json.RawMessage
|
|
}{original, invalidSource, body})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(evidence)
|
|
receiptSum := sha256.Sum256(body)
|
|
// A receipt fingerprint associates even damaged evidence with its proof;
|
|
// it changes only an internal filename, never a business event identity.
|
|
dir := filepath.Join(filepath.Dir(s.path), "rejected-data")
|
|
if err := persistPrivateData(dir, hex.EncodeToString(receiptSum[:])+"."+hex.EncodeToString(sum[:])+".json", evidence); err != nil {
|
|
return fmt.Errorf("persist rejected-data evidence: %w", err)
|
|
}
|
|
route, err := tenant.ResultRoute(dispatcherID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id.EventType == "call.execute" {
|
|
var admitted int
|
|
if err := tx.QueryRow(`SELECT count(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, id.EventID).Scan(&admitted); err != nil {
|
|
return err
|
|
}
|
|
if admitted != 0 {
|
|
return errors.New("structural rejection conflicts with an already durably admitted execution")
|
|
}
|
|
}
|
|
var oldType string
|
|
var old []byte
|
|
err = tx.QueryRow(`SELECT event_type,body FROM dispatcher_outbox WHERE dispatcher_id=? AND event_type=? AND event_id=?`, dispatcherID, id.EventType, id.EventID).Scan(&oldType, &old)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
_, err = tx.Exec(`INSERT INTO dispatcher_outbox(dispatcher_id,event_id,event_type,routing_key,body) VALUES(?,?,?,?,?)`, dispatcherID, id.EventID, id.EventType, route.BindingKey, body)
|
|
} else if err == nil {
|
|
if oldType != id.EventType || !bytes.Equal(old, body) {
|
|
return errors.New("rejection conflicts with original event result")
|
|
}
|
|
_, err = tx.Exec(`UPDATE dispatcher_outbox SET confirmed=0,confirmed_at=NULL WHERE dispatcher_id=? AND event_type=? AND event_id=?`, dispatcherID, id.EventType, id.EventID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("persist rejected-data feedback: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|