package main import ( "crypto/hmac" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "regexp" "strconv" "strings" "time" "unicode/utf8" _ "github.com/mattn/go-sqlite3" ) var ( timestampPattern = regexp.MustCompile(`^[0-9]{1,20}$`) signaturePattern = regexp.MustCompile(`^v1=[0-9a-f]{64}$`) uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$`) ) func verifySignature(rawBody []byte, timestamp, signature, secret string) bool { if !signaturePattern.MatchString(signature) { return false } provided, err := hex.DecodeString(signature[3:]) if err != nil { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte("v1." + timestamp + ".")) mac.Write(rawBody) return hmac.Equal(mac.Sum(nil), provided) } func storeEvent(database, subscriptionID, eventID string, rawBody []byte) error { connection, err := sql.Open("sqlite3", database) if err != nil { return err } defer connection.Close() connection.SetMaxOpenConns(1) if _, err = connection.Exec("PRAGMA busy_timeout = 5000"); err != nil { return err } if _, err = connection.Exec(`CREATE TABLE IF NOT EXISTS inbox ( subscription_id TEXT NOT NULL, event_id TEXT NOT NULL, body BLOB NOT NULL, state TEXT NOT NULL DEFAULT 'pending', PRIMARY KEY (subscription_id, event_id))`); err != nil { return err } _, err = connection.Exec(`INSERT INTO inbox (subscription_id, event_id, body) VALUES (?, ?, ?) ON CONFLICT(subscription_id, event_id) DO NOTHING`, subscriptionID, eventID, rawBody) return err } func header(request *http.Request, name string) (string, bool) { values := request.Header.Values(name) if len(values) != 1 || values[0] == "" { return "", false } return values[0], true } func receive(response http.ResponseWriter, request *http.Request, secrets map[string]string, subscriptionID, database string) int { if request.Method != http.MethodPost { response.Header().Set("Allow", "POST") return http.StatusMethodNotAllowed } for _, name := range []string{"X-ServiceApp-Timestamp", "X-ServiceApp-Signature", "X-ServiceApp-Event-Id", "X-ServiceApp-Key-Id", "Content-Type"} { if _, ok := header(request, name); !ok { return http.StatusBadRequest } } timestamp := request.Header.Get("X-ServiceApp-Timestamp") secret, known := secrets[request.Header.Get("X-ServiceApp-Key-Id")] seconds, err := strconv.ParseInt(timestamp, 10, 64) now := time.Now().Unix() if !known || !timestampPattern.MatchString(timestamp) || err != nil || seconds < now-300 || seconds > now+300 { return http.StatusBadRequest } if len(request.TransferEncoding) != 0 { return http.StatusBadRequest } if request.ContentLength < 1 || request.ContentLength > 65536 { return http.StatusRequestEntityTooLarge } contentType, _, _ := strings.Cut(request.Header.Get("Content-Type"), ";") if !strings.EqualFold(strings.TrimSpace(contentType), "application/json") { return http.StatusUnsupportedMediaType } request.Body = http.MaxBytesReader(response, request.Body, 65536) rawBody, err := io.ReadAll(request.Body) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { return http.StatusRequestEntityTooLarge } return http.StatusBadRequest } if int64(len(rawBody)) != request.ContentLength || !verifySignature(rawBody, timestamp, request.Header.Get("X-ServiceApp-Signature"), secret) { return http.StatusBadRequest } var event struct { EventID string `json:"event_id"` SchemaVersion json.RawMessage `json:"schema_version"` Resource json.RawMessage `json:"resource"` } if !utf8.Valid(rawBody) || json.Unmarshal(rawBody, &event) != nil || event.EventID != request.Header.Get("X-ServiceApp-Event-Id") || !uuidPattern.MatchString(event.EventID) || string(event.SchemaVersion) != "1" { return http.StatusBadRequest } var resource map[string]json.RawMessage if json.Unmarshal(event.Resource, &resource) != nil || resource == nil { return http.StatusBadRequest } if err := storeEvent(database, subscriptionID, event.EventID, rawBody); err != nil { return http.StatusServiceUnavailable } return http.StatusNoContent } func main() { var secrets map[string]string if err := json.Unmarshal([]byte(os.Getenv("SERVICEAPP_WEBHOOK_SECRETS")), &secrets); err != nil || len(secrets) == 0 { log.Fatal("Укажите пары идентификаторов ключей и секретов") } for key, secret := range secrets { if key == "" || secret == "" { log.Fatal("Укажите пары идентификаторов ключей и секретов") } } subscriptionID := os.Getenv("SERVICEAPP_WEBHOOK_SUBSCRIPTION_ID") if subscriptionID == "" { log.Fatal("Укажите ID подписки") } database := os.Getenv("SERVICEAPP_WEBHOOK_DB") if database == "" { database = "webhook-inbox.sqlite3" } server := &http.Server{ Addr: "127.0.0.1:8080", ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { defer request.Body.Close() status := receive(response, request, secrets, subscriptionID, database) response.Header().Set("Content-Length", "0") if status == http.StatusServiceUnavailable { response.Header().Set("Retry-After", "2") } response.WriteHeader(status) }), } if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(fmt.Errorf("Не удалось запустить обработчик: %w", err)) } }