From 6ad308da653ae66f22aacaf80f8d4e9c12582f20 Mon Sep 17 00:00:00 2001 From: Jonas Plum Date: Mon, 8 Jul 2024 01:09:28 +0200 Subject: [PATCH 01/17] feat: add actions --- .gitignore | 2 + .golangci.yml | 3 + Makefile | 6 ++ action/action.go | 33 ++++++ action/action_test.go | 55 ++++++++++ action/handle.go | 75 +++++++++++++ action/http.go | 71 +++++++++++++ action/http_test.go | 202 ++++++++++++++++++++++++++++++++++++ action/python.go | 79 ++++++++++++++ action/request.go | 44 ++++++++ action/response.go | 39 +++++++ action/response_test.go | 51 +++++++++ fakedata/records.go | 21 +++- go.mod | 5 +- go.sum | 1 + migrations/1_collections.go | 22 +++- routes.go | 3 + 17 files changed, 707 insertions(+), 5 deletions(-) create mode 100644 action/action.go create mode 100644 action/action_test.go create mode 100644 action/handle.go create mode 100644 action/http.go create mode 100644 action/http_test.go create mode 100644 action/python.go create mode 100644 action/request.go create mode 100644 action/response.go create mode 100644 action/response_test.go diff --git a/.gitignore b/.gitignore index 35ed6552..deb0cd8d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ dist pb_data catalyst catalyst_data + +coverage.out diff --git a/.golangci.yml b/.golangci.yml index 4349820c..92d4e290 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,6 +12,7 @@ linters: - nestif # disable + - bodyclose - depguard - dupl - err113 @@ -28,6 +29,8 @@ linters: - lll - makezero - mnd + - paralleltest + - perfsprint - prealloc - tagalign - tagliatelle diff --git a/Makefile b/Makefile index 6af92e8d..690ec0c0 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,12 @@ test: go test -v ./... cd ui && bun test +.PHONY: test-coverage +test-coverage: + @echo "Testing with coverage..." + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out + .PHONY: build-ui build-ui: @echo "Building..." diff --git a/action/action.go b/action/action.go new file mode 100644 index 00000000..8c759662 --- /dev/null +++ b/action/action.go @@ -0,0 +1,33 @@ +package action + +import ( + "errors" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + + "github.com/SecurityBrewery/catalyst/migrations" +) + +func findAction(app core.App, action string) (*models.Record, bool, error) { + records, err := app.Dao().FindRecordsByExpr(migrations.ActionCollectionName, dbx.HashExp{"name": action}) + if err != nil { + return nil, false, err + } + + if len(records) == 0 { + return nil, false, nil + } + + return records[0], true, nil +} + +func runAction(actionType, name, bootstrap, script, payload string) ([]byte, error) { + switch actionType { + case "python": + return runPythonAction(name, bootstrap, script, payload) + default: + return nil, errors.New("unsupported action type") + } +} diff --git a/action/action_test.go b/action/action_test.go new file mode 100644 index 00000000..f77aef9e --- /dev/null +++ b/action/action_test.go @@ -0,0 +1,55 @@ +package action + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testResult struct { + Code int + Header http.Header + Body string +} + +func resultEqual(t *testing.T, got *http.Response, want testResult) { + t.Helper() + + assert.Equal(t, want.Code, got.StatusCode) + + assertHeaderEqual(t, got.Header, want.Header) + + body, err := io.ReadAll(got.Body) + if assert.NoError(t, err) { + if strings.Contains(want.Header.Get("Content-Type"), "application/json") { + assert.JSONEq(t, want.Body, string(body)) + } else { + assert.Equal(t, want.Body, string(body)) + } + } +} + +func assertHeaderEqual(t *testing.T, got, want http.Header) { + t.Helper() + + if assert.Equal(t, len(want), len(got)) { + if assert.ElementsMatch(t, mapKeys(got), mapKeys(want)) { + for k := range got { + assert.ElementsMatch(t, got[k], want[k]) + } + } + } +} + +func mapKeys[M ~map[K]V, K comparable, V any](m M) []K { + keys := make([]K, 0, len(m)) + + for k := range m { + keys = append(keys, k) + } + + return keys +} diff --git a/action/handle.go b/action/handle.go new file mode 100644 index 00000000..554407cd --- /dev/null +++ b/action/handle.go @@ -0,0 +1,75 @@ +package action + +import ( + "net/http" + "strings" + + "github.com/pocketbase/pocketbase/core" +) + +const prefix = "/action/" + +func Handle(app core.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, prefix) { + errResponse(app.Logger(), w, http.StatusNotFound, "wrong prefix") + + return + } + + actionName := strings.TrimPrefix(r.URL.Path, prefix) + + action, found, err := findAction(app, actionName) + if err != nil { + errResponse(app.Logger(), w, http.StatusInternalServerError, err.Error()) + + return + } + + if !found { + errResponse(app.Logger(), w, http.StatusNotFound, "action not found") + + return + } + + token := action.GetString("token") + + if token != "" && bearerToken(r) != token { + errResponse(app.Logger(), w, http.StatusUnauthorized, "invalid token") + + return + } + + payload, err := requestToPayload(r) + if err != nil { + errResponse(app.Logger(), w, http.StatusInternalServerError, err.Error()) + + return + } + + output, err := runAction( + action.GetString("type"), + action.GetString("name"), + action.GetString("bootstrap"), + action.GetString("script"), + payload, + ) + if err != nil { + errResponse(app.Logger(), w, http.StatusInternalServerError, err.Error()) + + return + } + + outputToResponse(app.Logger(), w, output) + } +} + +func bearerToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + + if !strings.HasPrefix(auth, "Bearer ") { + return "" + } + + return strings.TrimPrefix(auth, "Bearer ") +} diff --git a/action/http.go b/action/http.go new file mode 100644 index 00000000..eecfbe70 --- /dev/null +++ b/action/http.go @@ -0,0 +1,71 @@ +package action + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" +) + +func requestToPayload(r *http.Request) (string, error) { + payload, err := json.Marshal(catalystActionRequest(r)) + if err != nil { + return "", err + } + + return string(payload), nil +} + +func outputToResponse(logger *slog.Logger, w http.ResponseWriter, output []byte) { + var catalystResponse CatalystActionResponse + if err := json.Unmarshal(output, &catalystResponse); err == nil { + catalystResponse.toResponse(logger, w) + + return + } + + textResponse(logger, w, output) +} + +func response(logger *slog.Logger, w http.ResponseWriter, statusCode int, body []byte) { + w.WriteHeader(statusCode) + + _, err := w.Write(body) + if err != nil { + logger.Error(fmt.Sprintf("Error writing response: %s", err.Error())) + } +} + +func errResponse(logger *slog.Logger, w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + body, err := json.Marshal(map[string]string{"error": msg}) + if err != nil { + body = []byte(fmt.Sprintf(`{"error": "%s"}`, msg)) + } + + response(logger, w, status, body) +} + +const ( + JSONContentType = "application/json; charset=utf-8" + TextContentType = "text/plain; charset=utf-8" +) + +// textResponse returns a response based on the output type. +func textResponse(logger *slog.Logger, w http.ResponseWriter, output []byte) { + if isJSON(output) { + w.Header().Set("Content-Type", JSONContentType) + } else { + w.Header().Set("Content-Type", TextContentType) + } + + response(logger, w, http.StatusOK, output) +} + +// isJSON checks if the data is JSON. +func isJSON(data []byte) bool { + var msg json.RawMessage + + return json.Unmarshal(data, &msg) == nil +} diff --git a/action/http_test.go b/action/http_test.go new file mode 100644 index 00000000..d6017d00 --- /dev/null +++ b/action/http_test.go @@ -0,0 +1,202 @@ +package action + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_requestToPayload(t *testing.T) { + type args struct { + r *http.Request + } + + tests := []struct { + name string + args args + want any + wantErr assert.ErrorAssertionFunc + }{ + { + name: "get request", + args: args{r: httptest.NewRequest(http.MethodGet, "/action/test", nil)}, + want: map[string]any{ + "method": "GET", + "path": "/action/test", + "headers": map[string]any{}, + "query": map[string]any{}, + "body": "", + "isBase64Encoded": false, + }, + wantErr: assert.NoError, + }, + { + name: "post request with query", + args: args{r: httptest.NewRequest(http.MethodPost, "/action/test?foo=bar", strings.NewReader("body"))}, + want: map[string]any{ + "method": "POST", + "path": "/action/test", + "headers": map[string]any{}, + "query": map[string]any{"foo": []string{"bar"}}, + "body": "body", + "isBase64Encoded": false, + }, + wantErr: assert.NoError, + }, + { + name: "post request with non-utf8 byte", + args: args{r: httptest.NewRequest(http.MethodPost, "/action/test", strings.NewReader("body\x80"))}, + want: map[string]any{ + "method": "POST", + "path": "/action/test", + "headers": map[string]any{}, + "query": map[string]any{}, + "body": "Ym9keYA=", + "isBase64Encoded": true, + }, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := requestToPayload(tt.args.r) + + if !tt.wantErr(t, err, fmt.Sprintf("requestToPayload(%v)", tt.args.r)) { + return + } + + want, err := json.Marshal(tt.want) + if assert.NoError(t, err, "json.Marshal(%v)", tt.want) { + assert.JSONEq(t, string(want), got, "requestToPayload(%v)", tt.args.r) + } + }) + } +} + +func Test_outputToResponse(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + + type args struct { + output []byte + } + + type want struct { + Result testResult + } + + tests := []struct { + name string + args args + want want + }{ + { + name: "non-http output", + args: args{ + output: []byte(`body`), + }, + want: want{ + Result: testResult{ + Code: 200, + Header: http.Header{"Content-Type": []string{TextContentType}}, + Body: "body", + }, + }, + }, + { + name: "http text output", + args: args{ + output: []byte(`{"statusCode": 200, "body": "body"}`), + }, + want: want{ + Result: testResult{ + Code: 200, + Header: http.Header{"Content-Type": []string{TextContentType}}, + Body: "body", + }, + }, + }, + { + name: "http json output", + args: args{ + output: []byte(`{"statusCode": 200, "body": "{\"key\": \"value\"}"}`), + }, + want: want{ + Result: testResult{ + Code: 200, + Header: http.Header{"Content-Type": []string{JSONContentType}}, + Body: `{"key": "value"}`, + }, + }, + }, + { + name: "http base64 output", + args: args{ + output: []byte(`{"statusCode": 200, "body": "Ym9keQ==", "isBase64Encoded": true}`), + }, + want: want{ + Result: testResult{ + Code: 200, + Header: http.Header{"Content-Type": []string{TextContentType}}, + Body: "body", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + + outputToResponse(logger, w, tt.args.output) + + w.Flush() + + resultEqual(t, w.Result(), tt.want.Result) + }) + } +} + +func Test_errResponse(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + + type args struct { + status int + msg string + } + + tests := []struct { + name string + args args + want testResult + }{ + { + name: "error response", + args: args{ + status: http.StatusInternalServerError, + msg: "error message", + }, + want: testResult{ + Code: http.StatusInternalServerError, + Header: http.Header{"Content-Type": []string{JSONContentType}}, + Body: `{"error": "error message"}`, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + + errResponse(logger, w, tt.args.status, tt.args.msg) + + w.Flush() + + resultEqual(t, w.Result(), tt.want) + }) + } +} diff --git a/action/python.go b/action/python.go new file mode 100644 index 00000000..cdf2e75f --- /dev/null +++ b/action/python.go @@ -0,0 +1,79 @@ +package action + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +func runPythonAction(name, bootstrap, script, payload string) ([]byte, error) { + tempDir, err := os.MkdirTemp("", "catalyst_action_"+name) + if err != nil { + return nil, err + } + + defer os.RemoveAll(tempDir) + + if err := pythonSetup(tempDir); err != nil { + return nil, err + } + + if err := pythonRunBootstrap(tempDir, bootstrap); err != nil { + return nil, err + } + + return pythonRunScript(tempDir, script, payload) +} + +func pythonSetup(tempDir string) error { + pythonPath, err := findExec("python", "python3") + if err != nil { + return fmt.Errorf("python or python3 binary not found, %w", err) + } + + // setup virtual environment + return exec.Command(pythonPath, "-m", "venv", tempDir+"/venv").Run() +} + +func pythonRunBootstrap(tempDir, bootstrap string) error { + hasBootstrap := len(strings.TrimSpace(bootstrap)) > 0 + + if !hasBootstrap { + return nil + } + + bootstrapPath := tempDir + "/requirements.txt" + + if err := os.WriteFile(bootstrapPath, []byte(bootstrap), 0o600); err != nil { + return err + } + + // install dependencies + pipPath := tempDir + "/venv/bin/pip" + + return exec.Command(pipPath, "install", "-r", bootstrapPath).Run() +} + +func pythonRunScript(tempDir, script, payload string) ([]byte, error) { + scriptPath := tempDir + "/script.py" + + if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { + return nil, err + } + + pythonPath := tempDir + "/venv/bin/python" + + return exec.Command(pythonPath, scriptPath, payload).Output() +} + +func findExec(name ...string) (string, error) { + for _, n := range name { + if p, err := exec.LookPath(n); err == nil { + return p, nil + } + } + + return "", errors.New("no executable found") +} diff --git a/action/request.go b/action/request.go new file mode 100644 index 00000000..6d642b87 --- /dev/null +++ b/action/request.go @@ -0,0 +1,44 @@ +package action + +import ( + "encoding/base64" + "io" + "net/http" + "net/url" + "unicode/utf8" +) + +type CatalystActionRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Headers http.Header `json:"headers"` + Query url.Values `json:"query"` + Body string `json:"body"` + IsBase64Encoded bool `json:"isBase64Encoded"` +} + +func catalystActionRequest(r *http.Request) *CatalystActionRequest { + body, isBase64Encoded := encodeBody(r) + + return &CatalystActionRequest{ + Method: r.Method, + Path: r.URL.EscapedPath(), + Headers: r.Header, + Query: r.URL.Query(), + Body: body, + IsBase64Encoded: isBase64Encoded, + } +} + +func encodeBody(request *http.Request) (string, bool) { + body, err := io.ReadAll(request.Body) + if err != nil { + return "", false + } + + if utf8.Valid(body) { + return string(body), false + } + + return base64.StdEncoding.EncodeToString(body), true +} diff --git a/action/response.go b/action/response.go new file mode 100644 index 00000000..a3121542 --- /dev/null +++ b/action/response.go @@ -0,0 +1,39 @@ +package action + +import ( + "encoding/base64" + "log/slog" + "net/http" +) + +type CatalystActionResponse struct { + StatusCode int `json:"statusCode"` + Headers http.Header `json:"headers"` + Body string `json:"body"` + IsBase64Encoded bool `json:"isBase64Encoded"` +} + +func (cr *CatalystActionResponse) toResponse(logger *slog.Logger, w http.ResponseWriter) { + for key, values := range cr.Headers { + for _, value := range values { + w.Header().Add(key, value) + } + } + + var body []byte + + if cr.IsBase64Encoded { + var err error + + body, err = base64.StdEncoding.DecodeString(cr.Body) + if err != nil { + errResponse(logger, w, http.StatusInternalServerError, "Error decoding base64 body") + + return + } + } else { + body = []byte(cr.Body) + } + + textResponse(logger, w, body) +} diff --git a/action/response_test.go b/action/response_test.go new file mode 100644 index 00000000..367d7399 --- /dev/null +++ b/action/response_test.go @@ -0,0 +1,51 @@ +package action + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCatalystActionResponse_toResponse(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + + type fields struct { + StatusCode int + Headers http.Header + Body string + IsBase64Encoded bool + } + + type want struct { + Result testResult + } + + tests := []struct { + name string + fields fields + want want + }{ + { + name: "Test 1", + fields: fields{StatusCode: 200, Headers: nil, Body: "body", IsBase64Encoded: false}, + want: want{Result: testResult{Code: 200, Header: map[string][]string{"Content-Type": {"text/plain; charset=utf-8"}}, Body: "body"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + + cr := &CatalystActionResponse{ + StatusCode: tt.fields.StatusCode, + Headers: tt.fields.Headers, + Body: tt.fields.Body, + IsBase64Encoded: tt.fields.IsBase64Encoded, + } + cr.toResponse(logger, w) + + resultEqual(t, w.Result(), tt.want.Result) + }) + } +} diff --git a/fakedata/records.go b/fakedata/records.go index 7fdd0d0f..b54cb9b5 100644 --- a/fakedata/records.go +++ b/fakedata/records.go @@ -36,8 +36,9 @@ func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error { users := userRecords(app.Dao(), userCount) tickets := ticketRecords(app.Dao(), users, types, ticketCount) webhooks := webhookRecords(app.Dao()) + actions := actionRecords(app.Dao()) - for _, records := range [][]*models.Record{users, tickets, webhooks} { + for _, records := range [][]*models.Record{users, tickets, webhooks, actions} { for _, record := range records { if err := app.Dao().SaveRecord(record); err != nil { app.Logger().Error(err.Error()) @@ -222,3 +223,21 @@ func webhookRecords(dao *daos.Dao) []*models.Record { return []*models.Record{record} } + +func actionRecords(dao *daos.Dao) []*models.Record { + collection, err := dao.FindCollectionByNameOrId(migrations.ActionCollectionName) + if err != nil { + panic(err) + } + + record := models.NewRecord(collection) + record.SetId("w_" + security.PseudorandomString(10)) + record.Set("name", "test") + record.Set("type", "python") + record.Set("bootstrap", "requests") + record.Set("script", `import sys + +print(sys.argv[1])`) + + return []*models.Record{record} +} diff --git a/go.mod b/go.mod index b714f467..02bda449 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/pocketbase v0.22.10 github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.9.0 ) require ( @@ -34,6 +35,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect github.com/aws/smithy-go v1.20.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -56,11 +58,11 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.9.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect go.opencensus.io v0.24.0 // indirect @@ -85,6 +87,7 @@ require ( google.golang.org/grpc v1.63.2 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect modernc.org/libc v1.50.2 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index f713614d..5020bd87 100644 --- a/go.sum +++ b/go.sum @@ -330,6 +330,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/migrations/1_collections.go b/migrations/1_collections.go index cdbf058f..f7c62ff5 100644 --- a/migrations/1_collections.go +++ b/migrations/1_collections.go @@ -11,15 +11,16 @@ import ( ) const ( - TimelineCollectionName = "timeline" + ActionCollectionName = "actions" CommentCollectionName = "comments" - fileCollectionName = "files" + FeatureCollectionName = "features" LinkCollectionName = "links" TaskCollectionName = "tasks" TicketCollectionName = "tickets" + TimelineCollectionName = "timeline" TypeCollectionName = "types" WebhookCollectionName = "webhooks" - FeatureCollectionName = "features" + fileCollectionName = "files" UserCollectionName = "_pb_users_auth_" ) @@ -113,6 +114,21 @@ func collectionsUp(db dbx.Builder) error { fmt.Sprintf("CREATE UNIQUE INDEX `unique_name` ON `%s` (`name`)", FeatureCollectionName), }, }, + internalCollection(&models.Collection{ + Name: ActionCollectionName, + Type: models.CollectionTypeBase, + Schema: schema.NewSchema( + &schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "type", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "token", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "description", Type: schema.FieldTypeText}, + &schema.SchemaField{Name: "bootstrap", Type: schema.FieldTypeText}, + &schema.SchemaField{Name: "script", Type: schema.FieldTypeText}, + ), + Indexes: types.JsonArray[string]{ + fmt.Sprintf("CREATE UNIQUE INDEX `unique_name` ON `%s` (`name`)", ActionCollectionName), + }, + }), } dao := daos.New(db) diff --git a/routes.go b/routes.go index dc9fa999..6092f2ea 100644 --- a/routes.go +++ b/routes.go @@ -12,6 +12,8 @@ import ( "github.com/labstack/echo/v5" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" + + "github.com/SecurityBrewery/catalyst/action" ) //go:embed ui/dist/* @@ -27,6 +29,7 @@ func addRoutes() func(*core.ServeEvent) error { return c.Redirect(http.StatusFound, "/ui/") }) e.Router.GET("/ui/*", staticFiles()) + e.Router.Any("/action/*", echo.WrapHandler(action.Handle(e.App))) e.Router.GET("/api/config", func(c echo.Context) error { flags, err := flags(e.App) if err != nil { From 7be63ce5c2c627a24e58b909feb075309b3e7e17 Mon Sep 17 00:00:00 2001 From: Jonas Plum Date: Thu, 11 Jul 2024 03:13:09 +0200 Subject: [PATCH 02/17] feat: better reaction ui --- action/action.go | 2 +- fakedata/records.go | 32 +++- migrations/1_collections.go | 16 -- migrations/5_actions.go | 101 ++++++++++ migrations/migrations.go | 1 + .../DeleteDialog.vue} | 41 +++-- ui/src/components/form/GrowTextarea.vue | 49 +++++ ui/src/components/layout/SideBar.vue | 21 ++- .../components/reaction/ReactionDisplay.vue | 173 ++++++++++++++++++ ui/src/components/reaction/ReactionList.vue | 70 +++++++ .../components/reaction/ReactionNewDialog.vue | 137 ++++++++++++++ .../reaction/ReactionPythonDisplay.vue | 77 ++++++++ .../reaction/ReactionPythonForm.vue | 95 ++++++++++ .../reaction/ReactionWebhookDisplay.vue | 76 ++++++++ .../reaction/ReactionWebhookForm.vue | 105 +++++++++++ ui/src/components/reaction/TriggerHook.vue | 75 ++++++++ .../components/reaction/TriggerTicketType.vue | 57 ++++++ ui/src/components/reaction/TriggerWebhook.vue | 61 ++++++ ui/src/components/ticket/TicketActionBar.vue | 11 +- .../components/ticket/TicketDeleteDialog.vue | 90 --------- ui/src/components/ticket/file/TicketFiles.vue | 16 +- .../ticket/link/LinkRemoveDialog.vue | 70 ------- ui/src/components/ticket/link/TicketLinks.vue | 18 +- .../ticket/task/TaskRemoveDialog.vue | 70 ------- ui/src/components/ticket/task/TicketTasks.vue | 16 +- ui/src/lib/types.ts | 32 ++++ ui/src/router/index.ts | 6 + ui/src/views/ReactionView.vue | 38 ++++ 28 files changed, 1274 insertions(+), 282 deletions(-) create mode 100644 migrations/5_actions.go rename ui/src/components/{ticket/file/FileRemoveDialog.vue => common/DeleteDialog.vue} (53%) create mode 100644 ui/src/components/form/GrowTextarea.vue create mode 100644 ui/src/components/reaction/ReactionDisplay.vue create mode 100644 ui/src/components/reaction/ReactionList.vue create mode 100644 ui/src/components/reaction/ReactionNewDialog.vue create mode 100644 ui/src/components/reaction/ReactionPythonDisplay.vue create mode 100644 ui/src/components/reaction/ReactionPythonForm.vue create mode 100644 ui/src/components/reaction/ReactionWebhookDisplay.vue create mode 100644 ui/src/components/reaction/ReactionWebhookForm.vue create mode 100644 ui/src/components/reaction/TriggerHook.vue create mode 100644 ui/src/components/reaction/TriggerTicketType.vue create mode 100644 ui/src/components/reaction/TriggerWebhook.vue delete mode 100644 ui/src/components/ticket/TicketDeleteDialog.vue delete mode 100644 ui/src/components/ticket/link/LinkRemoveDialog.vue delete mode 100644 ui/src/components/ticket/task/TaskRemoveDialog.vue create mode 100644 ui/src/views/ReactionView.vue diff --git a/action/action.go b/action/action.go index 8c759662..c51f9ee8 100644 --- a/action/action.go +++ b/action/action.go @@ -11,7 +11,7 @@ import ( ) func findAction(app core.App, action string) (*models.Record, bool, error) { - records, err := app.Dao().FindRecordsByExpr(migrations.ActionCollectionName, dbx.HashExp{"name": action}) + records, err := app.Dao().FindRecordsByExpr(migrations.ReactionPythonCollectionName, dbx.HashExp{"name": action}) // TODO if err != nil { return nil, false, err } diff --git a/fakedata/records.go b/fakedata/records.go index b54cb9b5..b9ce5191 100644 --- a/fakedata/records.go +++ b/fakedata/records.go @@ -36,9 +36,9 @@ func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error { users := userRecords(app.Dao(), userCount) tickets := ticketRecords(app.Dao(), users, types, ticketCount) webhooks := webhookRecords(app.Dao()) - actions := actionRecords(app.Dao()) + reactions := reactionRecords(app.Dao()) - for _, records := range [][]*models.Record{users, tickets, webhooks, actions} { + for _, records := range [][]*models.Record{users, tickets, webhooks, reactions} { for _, record := range records { if err := app.Dao().SaveRecord(record); err != nil { app.Logger().Error(err.Error()) @@ -224,8 +224,10 @@ func webhookRecords(dao *daos.Dao) []*models.Record { return []*models.Record{record} } -func actionRecords(dao *daos.Dao) []*models.Record { - collection, err := dao.FindCollectionByNameOrId(migrations.ActionCollectionName) +func reactionRecords(dao *daos.Dao) []*models.Record { + var records []*models.Record + + collection, err := dao.FindCollectionByNameOrId(migrations.ReactionWebhookCollectionName) if err != nil { panic(err) } @@ -233,11 +235,23 @@ func actionRecords(dao *daos.Dao) []*models.Record { record := models.NewRecord(collection) record.SetId("w_" + security.PseudorandomString(10)) record.Set("name", "test") - record.Set("type", "python") - record.Set("bootstrap", "requests") - record.Set("script", `import sys + record.Set("headers", `["Content-Type: application/json"]`) + record.Set("destination", "http://localhost:8080/webhook") -print(sys.argv[1])`) + records = append(records, record) - return []*models.Record{record} + collection, err = dao.FindCollectionByNameOrId(migrations.ReactionPythonCollectionName) + if err != nil { + panic(err) + } + + record = models.NewRecord(collection) + record.SetId("w_" + security.PseudorandomString(10)) + record.Set("name", "test") + record.Set("requirements", "requests") + record.Set("script", "import sys\n\nprint(sys.argv[1])") + + records = append(records, record) + + return records } diff --git a/migrations/1_collections.go b/migrations/1_collections.go index f7c62ff5..7d016384 100644 --- a/migrations/1_collections.go +++ b/migrations/1_collections.go @@ -11,7 +11,6 @@ import ( ) const ( - ActionCollectionName = "actions" CommentCollectionName = "comments" FeatureCollectionName = "features" LinkCollectionName = "links" @@ -114,21 +113,6 @@ func collectionsUp(db dbx.Builder) error { fmt.Sprintf("CREATE UNIQUE INDEX `unique_name` ON `%s` (`name`)", FeatureCollectionName), }, }, - internalCollection(&models.Collection{ - Name: ActionCollectionName, - Type: models.CollectionTypeBase, - Schema: schema.NewSchema( - &schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true}, - &schema.SchemaField{Name: "type", Type: schema.FieldTypeText, Required: true}, - &schema.SchemaField{Name: "token", Type: schema.FieldTypeText, Required: true}, - &schema.SchemaField{Name: "description", Type: schema.FieldTypeText}, - &schema.SchemaField{Name: "bootstrap", Type: schema.FieldTypeText}, - &schema.SchemaField{Name: "script", Type: schema.FieldTypeText}, - ), - Indexes: types.JsonArray[string]{ - fmt.Sprintf("CREATE UNIQUE INDEX `unique_name` ON `%s` (`name`)", ActionCollectionName), - }, - }), } dao := daos.New(db) diff --git a/migrations/5_actions.go b/migrations/5_actions.go new file mode 100644 index 00000000..e2c511cc --- /dev/null +++ b/migrations/5_actions.go @@ -0,0 +1,101 @@ +package migrations + +import ( + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" +) + +const ( + TriggerWebhookCollectionName = "triggers_webhooks" + TriggerHookCollectionName = "triggers_hooks" + ReactionViewName = "reactions" + ReactionPythonCollectionName = "reactions_python" + ReactionWebhookCollectionName = "reactions_webhooks" +) + +const reactionViewQuery = `SELECT id, name, type, created, updated FROM ( + SELECT id, name, created, updated, 'python' as type FROM reactions_python + UNION + SELECT id, name, created, updated, 'webhook' as type FROM reactions_webhooks +) as reactions;` + +func actionsUp(db dbx.Builder) error { + hookCollections := []string{TicketCollectionName, TaskCollectionName, CommentCollectionName, TimelineCollectionName, LinkCollectionName, fileCollectionName} + hookEvents := []string{"create", "update", "delete"} + + collections := []*models.Collection{ + internalCollection(&models.Collection{ + Name: TriggerWebhookCollectionName, + Type: models.CollectionTypeBase, + Schema: schema.NewSchema( + &schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "token", Type: schema.FieldTypeText}, + &schema.SchemaField{Name: "path", Type: schema.FieldTypeText, Required: true}, + ), + }), + internalCollection(&models.Collection{ + Name: TriggerHookCollectionName, + Type: models.CollectionTypeBase, + Schema: schema.NewSchema( + &schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "collection", Type: schema.FieldTypeSelect, Required: true, Options: &schema.SelectOptions{Values: hookCollections}}, + &schema.SchemaField{Name: "event", Type: schema.FieldTypeSelect, Required: true, Options: &schema.SelectOptions{Values: hookEvents}}, + ), + }), + internalCollection(&models.Collection{ + Name: ReactionPythonCollectionName, + Type: models.CollectionTypeBase, + Schema: schema.NewSchema( + &schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "requirements", Type: schema.FieldTypeText}, + &schema.SchemaField{Name: "script", Type: schema.FieldTypeText, Required: true}, + ), + }), + internalCollection(&models.Collection{ + Name: ReactionWebhookCollectionName, + Type: models.CollectionTypeBase, + Schema: schema.NewSchema( + &schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true}, + &schema.SchemaField{Name: "headers", Type: schema.FieldTypeText}, + &schema.SchemaField{Name: "destination", Type: schema.FieldTypeText, Required: true}, + ), + }), + internalView(ReactionViewName, reactionViewQuery), + } + + dao := daos.New(db) + for _, c := range collections { + if err := dao.SaveCollection(c); err != nil { + return err + } + } + + return nil +} + +func actionsDown(db dbx.Builder) error { + collections := []string{ + TriggerWebhookCollectionName, + TriggerHookCollectionName, + ReactionPythonCollectionName, + ReactionWebhookCollectionName, + ReactionViewName, + } + + dao := daos.New(db) + + for _, name := range collections { + id, err := dao.FindCollectionByNameOrId(name) + if err != nil { + return err + } + + if err := dao.DeleteCollection(id); err != nil { + return err + } + } + + return nil +} diff --git a/migrations/migrations.go b/migrations/migrations.go index d0ab957d..4f0e98e7 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -9,4 +9,5 @@ func Register() { migrations.Register(collectionsUp, collectionsDown, "1700000001_collections.go") migrations.Register(defaultDataUp, nil, "1700000003_defaultdata.go") migrations.Register(viewsUp, viewsDown, "1700000004_views.go") + migrations.Register(actionsUp, actionsDown, "1700000005_actions.go") } diff --git a/ui/src/components/ticket/file/FileRemoveDialog.vue b/ui/src/components/common/DeleteDialog.vue similarity index 53% rename from ui/src/components/ticket/file/FileRemoveDialog.vue rename to ui/src/components/common/DeleteDialog.vue index 1417b9ff..0c6bdcc9 100644 --- a/ui/src/components/ticket/file/FileRemoveDialog.vue +++ b/ui/src/components/common/DeleteDialog.vue @@ -16,24 +16,29 @@ import { Trash2 } from 'lucide-vue-next' import { useMutation, useQueryClient } from '@tanstack/vue-query' import { ref } from 'vue' +import { type RouteLocationRaw, useRouter } from 'vue-router' import { pb } from '@/lib/pocketbase' -import type { File, Ticket } from '@/lib/types' const queryClient = useQueryClient() +const router = useRouter() const props = defineProps<{ - ticket: Ticket - file: File + collection: string + id: string + name: string + singular: string + queryKey: string[] + to?: RouteLocationRaw }>() const isOpen = ref(false) -const removeFileMutation = useMutation({ - mutationFn: (): Promise => pb.collection('files').delete(props.file.id), +const deleteMutation = useMutation({ + mutationFn: () => pb.collection(props.collection).delete(props.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tickets', props.ticket.id] }) - isOpen.value = false + queryClient.invalidateQueries({ queryKey: props.queryKey }) + if (props.to) router.push(props.to) }, onError: (error) => toast({ @@ -47,23 +52,27 @@ const removeFileMutation = useMutation({