feat: add reactions (#1074)

This commit is contained in:
Jonas Plum
2024-07-20 06:39:02 +02:00
committed by GitHub
parent 82ad50d228
commit e2c8f1d223
78 changed files with 3270 additions and 257 deletions

View File

@@ -5,6 +5,22 @@ on:
release: { types: [ published ] } release: { types: [ published ] }
jobs: jobs:
fmt:
name: Fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- uses: oven-sh/setup-bun@v1
- run: make build-ui
- run: make install
- run: make fmt
- run: git diff --exit-code
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -31,3 +47,10 @@ jobs:
- run: make build-ui - run: make build-ui
- run: make test - run: make test
- run: make test-coverage
- uses: codecov/codecov-action@v4
with:
files: ./coverage.out
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -20,6 +20,6 @@ jobs:
with: with:
scopes: | scopes: |
deps deps
subjectPattern: "^(?!deps$).+" subjectPattern: ^(?![A-Z]).+$
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -34,3 +34,5 @@ dist
pb_data pb_data
catalyst catalyst
catalyst_data catalyst_data
coverage.out

View File

@@ -12,6 +12,7 @@ linters:
- nestif - nestif
# disable # disable
- bodyclose
- depguard - depguard
- dupl - dupl
- err113 - err113
@@ -28,6 +29,8 @@ linters:
- lll - lll
- makezero - makezero
- mnd - mnd
- paralleltest
- perfsprint
- prealloc - prealloc
- tagalign - tagalign
- tagliatelle - tagliatelle

View File

@@ -1,9 +1,9 @@
.PHONY: install .PHONY: install
install: install:
@echo "Installing..." @echo "Installing..."
go install github.com/bombsimon/wsl/v4/cmd...@master go install github.com/bombsimon/wsl/v4/cmd...@v4.4.1
go install mvdan.cc/gofumpt@latest go install mvdan.cc/gofumpt@v0.6.0
go install github.com/daixiang0/gci@latest go install github.com/daixiang0/gci@v0.13.4
.PHONY: fmt .PHONY: fmt
fmt: fmt:
@@ -26,6 +26,13 @@ test:
go test -v ./... go test -v ./...
cd ui && bun test cd ui && bun test
.PHONY: test-coverage
test-coverage:
@echo "Testing with coverage..."
go test -coverpkg=./... -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out
.PHONY: build-ui .PHONY: build-ui
build-ui: build-ui:
@echo "Building..." @echo "Building..."

44
app/app.go Normal file
View File

@@ -0,0 +1,44 @@
package app
import (
"os"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction"
"github.com/SecurityBrewery/catalyst/webhook"
)
func init() {
migrations.Register()
}
func App(dir string) *pocketbase.PocketBase {
app := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDev: dev(),
DefaultDataDir: dir,
})
BindHooks(app)
// Register additional commands
app.RootCmd.AddCommand(bootstrapCmd(app))
app.RootCmd.AddCommand(fakeDataCmd(app))
app.RootCmd.AddCommand(setFeatureFlagsCmd(app))
return app
}
func BindHooks(app core.App) {
webhook.BindHooks(app)
reaction.BindHooks(app)
app.OnBeforeServe().Add(addRoutes())
}
func dev() bool {
return strings.HasPrefix(os.Args[0], os.TempDir())
}

25
app/bootstrap.go Normal file
View File

@@ -0,0 +1,25 @@
package app
import (
"github.com/pocketbase/pocketbase/core"
"github.com/spf13/cobra"
)
func Bootstrap(app core.App) error {
if err := app.Bootstrap(); err != nil {
return err
}
return MigrateDBs(app)
}
func bootstrapCmd(app core.App) *cobra.Command {
return &cobra.Command{
Use: "bootstrap",
Run: func(_ *cobra.Command, _ []string) {
if err := Bootstrap(app); err != nil {
app.Logger().Error(err.Error())
}
},
}
}

27
app/fakedata.go Normal file
View File

@@ -0,0 +1,27 @@
package app
import (
"github.com/pocketbase/pocketbase/core"
"github.com/spf13/cobra"
"github.com/SecurityBrewery/catalyst/fakedata"
)
func fakeDataCmd(app core.App) *cobra.Command {
var userCount, ticketCount int
cmd := &cobra.Command{
Use: "fake-data",
Run: func(_ *cobra.Command, _ []string) {
if err := fakedata.Generate(app, userCount, ticketCount); err != nil {
app.Logger().Error(err.Error())
}
},
}
cmd.PersistentFlags().IntVar(&userCount, "users", 10, "Number of users to generate")
cmd.PersistentFlags().IntVar(&ticketCount, "tickets", 100, "Number of tickets to generate")
return cmd
}

80
app/flags.go Normal file
View File

@@ -0,0 +1,80 @@
package app
import (
"slices"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/spf13/cobra"
"github.com/SecurityBrewery/catalyst/migrations"
)
func Flags(app core.App) ([]string, error) {
records, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName)
if err != nil {
return nil, err
}
var flags []string
for _, r := range records {
flags = append(flags, r.GetString("name"))
}
return flags, nil
}
func SetFlags(app core.App, args []string) error {
featureCollection, err := app.Dao().FindCollectionByNameOrId(migrations.FeatureCollectionName)
if err != nil {
return err
}
featureRecords, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName)
if err != nil {
return err
}
var existingFlags []string
for _, featureRecord := range featureRecords {
// remove feature flags that are not in the args
if !slices.Contains(args, featureRecord.GetString("name")) {
if err := app.Dao().DeleteRecord(featureRecord); err != nil {
return err
}
continue
}
existingFlags = append(existingFlags, featureRecord.GetString("name"))
}
for _, arg := range args {
if slices.Contains(existingFlags, arg) {
continue
}
// add feature flags that are not in the args
record := models.NewRecord(featureCollection)
record.Set("name", arg)
if err := app.Dao().SaveRecord(record); err != nil {
return err
}
}
return nil
}
func setFeatureFlagsCmd(app core.App) *cobra.Command {
return &cobra.Command{
Use: "set-feature-flags",
Run: func(_ *cobra.Command, args []string) {
if err := SetFlags(app, args); err != nil {
app.Logger().Error(err.Error())
}
},
}
}

41
app/flags_test.go Normal file
View File

@@ -0,0 +1,41 @@
package app_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SecurityBrewery/catalyst/app"
catalystTesting "github.com/SecurityBrewery/catalyst/testing"
)
func Test_flags(t *testing.T) {
catalystApp, cleanup := catalystTesting.App(t)
defer cleanup()
got, err := app.Flags(catalystApp)
require.NoError(t, err)
want := []string{}
assert.ElementsMatch(t, want, got)
}
func Test_setFlags(t *testing.T) {
catalystApp, cleanup := catalystTesting.App(t)
defer cleanup()
require.NoError(t, app.SetFlags(catalystApp, []string{"test"}))
got, err := app.Flags(catalystApp)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"test"}, got)
require.NoError(t, app.SetFlags(catalystApp, []string{"test2"}))
got, err = app.Flags(catalystApp)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"test2"}, got)
}

72
app/migrate.go Normal file
View File

@@ -0,0 +1,72 @@
package app
import (
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/migrate"
)
type migration struct {
db *dbx.DB
migrations migrate.MigrationsList
}
func MigrateDBs(app core.App) error {
for _, m := range []migration{
{db: app.DB(), migrations: migrations.AppMigrations},
{db: app.LogsDB(), migrations: logs.LogsMigrations},
} {
runner, err := migrate.NewRunner(m.db, m.migrations)
if err != nil {
return err
}
if _, err := runner.Up(); err != nil {
return err
}
}
return nil
}
// this fix ignores some errors that come from upstream migrations.
var ignoreErrors = []string{
"1673167670_multi_match_migrate",
"1660821103_add_user_ip_column",
}
func isIgnored(err error) bool {
for _, ignore := range ignoreErrors {
if strings.Contains(err.Error(), ignore) {
return true
}
}
return false
}
func MigrateDBsDown(app core.App) error {
for _, m := range []migration{
{db: app.DB(), migrations: migrations.AppMigrations},
{db: app.LogsDB(), migrations: logs.LogsMigrations},
} {
runner, err := migrate.NewRunner(m.db, m.migrations)
if err != nil {
return err
}
if _, err := runner.Down(len(m.migrations.Items())); err != nil {
if isIgnored(err) {
continue
}
return err
}
}
return nil
}

21
app/migrate_test.go Normal file
View File

@@ -0,0 +1,21 @@
package app_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/SecurityBrewery/catalyst/app"
"github.com/SecurityBrewery/catalyst/migrations"
catalystTesting "github.com/SecurityBrewery/catalyst/testing"
)
func Test_MigrateDBsDown(t *testing.T) {
catalystApp, cleanup := catalystTesting.App(t)
defer cleanup()
_, err := catalystApp.Dao().FindCollectionByNameOrId(migrations.ReactionCollectionName)
require.NoError(t, err)
require.NoError(t, app.MigrateDBsDown(catalystApp))
}

View File

@@ -1,34 +1,26 @@
package main package app
import ( import (
"embed"
"io/fs"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os"
"strings"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/SecurityBrewery/catalyst/ui"
) )
//go:embed ui/dist/*
var ui embed.FS
func dev() bool {
return strings.HasPrefix(os.Args[0], os.TempDir())
}
func addRoutes() func(*core.ServeEvent) error { func addRoutes() func(*core.ServeEvent) error {
return func(e *core.ServeEvent) error { return func(e *core.ServeEvent) error {
e.Router.GET("/", func(c echo.Context) error { e.Router.GET("/", func(c echo.Context) error {
return c.Redirect(http.StatusFound, "/ui/") return c.Redirect(http.StatusFound, "/ui/")
}) })
e.Router.GET("/ui/*", staticFiles()) e.Router.GET("/ui/*", staticFiles())
e.Router.GET("/api/config", func(c echo.Context) error { e.Router.GET("/api/config", func(c echo.Context) error {
flags, err := flags(e.App) flags, err := Flags(e.App)
if err != nil { if err != nil {
return err return err
} }
@@ -55,8 +47,6 @@ func staticFiles() func(echo.Context) error {
return nil return nil
} }
fsys, _ := fs.Sub(ui, "ui/dist") return apis.StaticDirectoryHandler(ui.UI(), true)(c)
return apis.StaticDirectoryHandler(fsys, true)(c)
} }
} }

View File

@@ -1,50 +0,0 @@
package main
import (
"log"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
)
func bootstrapCmd(app *pocketbase.PocketBase) *cobra.Command {
return &cobra.Command{
Use: "bootstrap",
Run: func(_ *cobra.Command, _ []string) {
if err := app.Bootstrap(); err != nil {
log.Fatal(err)
}
if err := migrateDBs(app); err != nil {
log.Fatal(err)
}
},
}
}
type migration struct {
db *dbx.DB
migrations migrate.MigrationsList
}
func migrateDBs(app *pocketbase.PocketBase) error {
for _, m := range []migration{
{db: app.DB(), migrations: migrations.AppMigrations},
{db: app.LogsDB(), migrations: logs.LogsMigrations},
} {
runner, err := migrate.NewRunner(m.db, m.migrations)
if err != nil {
return err
}
if _, err := runner.Up(); err != nil {
return err
}
}
return nil
}

78
cmd.go
View File

@@ -1,78 +0,0 @@
package main
import (
"log"
"slices"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/models"
"github.com/spf13/cobra"
"github.com/SecurityBrewery/catalyst/fakedata"
"github.com/SecurityBrewery/catalyst/migrations"
)
func fakeDataCmd(app *pocketbase.PocketBase) *cobra.Command {
var userCount, ticketCount int
cmd := &cobra.Command{
Use: "fake-data",
Run: func(_ *cobra.Command, _ []string) {
if err := fakedata.Generate(app, userCount, ticketCount); err != nil {
log.Fatal(err)
}
},
}
cmd.PersistentFlags().IntVar(&userCount, "users", 10, "Number of users to generate")
cmd.PersistentFlags().IntVar(&ticketCount, "tickets", 100, "Number of tickets to generate")
return cmd
}
func setFeatureFlagsCmd(app *pocketbase.PocketBase) *cobra.Command {
return &cobra.Command{
Use: "set-feature-flags",
Run: func(_ *cobra.Command, args []string) {
featureCollection, err := app.Dao().FindCollectionByNameOrId(migrations.FeatureCollectionName)
if err != nil {
log.Fatal(err)
}
featureRecords, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName)
if err != nil {
log.Fatal(err)
}
var existingFlags []string
for _, featureRecord := range featureRecords {
// remove feature flags that are not in the args
if !slices.Contains(args, featureRecord.GetString("name")) {
if err := app.Dao().DeleteRecord(featureRecord); err != nil {
log.Fatal(err)
}
continue
}
existingFlags = append(existingFlags, featureRecord.GetString("name"))
}
for _, arg := range args {
if slices.Contains(existingFlags, arg) {
continue
}
// add feature flags that are not in the args
record := models.NewRecord(featureCollection)
record.Set("name", arg)
if err := app.Dao().SaveRecord(record); err != nil {
log.Fatal(err)
}
}
},
}
}

View File

@@ -6,7 +6,7 @@ import (
"time" "time"
"github.com/brianvoe/gofakeit/v7" "github.com/brianvoe/gofakeit/v7"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
@@ -19,7 +19,7 @@ const (
minimumTicketCount = 1 minimumTicketCount = 1
) )
func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error { func Generate(app core.App, userCount, ticketCount int) error {
if userCount < minimumUserCount { if userCount < minimumUserCount {
userCount = minimumUserCount userCount = minimumUserCount
} }
@@ -28,24 +28,39 @@ func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error {
ticketCount = minimumTicketCount ticketCount = minimumTicketCount
} }
types, err := app.Dao().FindRecordsByExpr(migrations.TypeCollectionName) records, err := Records(app, userCount, ticketCount)
if err != nil { if err != nil {
return err return err
} }
for _, record := range records {
if err := app.Dao().SaveRecord(record); err != nil {
return err
}
}
return nil
}
func Records(app core.App, userCount int, ticketCount int) ([]*models.Record, error) {
types, err := app.Dao().FindRecordsByExpr(migrations.TypeCollectionName)
if err != nil {
return nil, err
}
users := userRecords(app.Dao(), userCount) users := userRecords(app.Dao(), userCount)
tickets := ticketRecords(app.Dao(), users, types, ticketCount) tickets := ticketRecords(app.Dao(), users, types, ticketCount)
webhooks := webhookRecords(app.Dao()) webhooks := webhookRecords(app.Dao())
reactions := reactionRecords(app.Dao())
for _, records := range [][]*models.Record{users, tickets, webhooks} { var records []*models.Record
for _, record := range records { records = append(records, users...)
if err := app.Dao().SaveRecord(record); err != nil { records = append(records, types...)
app.Logger().Error(err.Error()) records = append(records, tickets...)
} records = append(records, webhooks...)
} records = append(records, reactions...)
}
return nil return records, nil
} }
func userRecords(dao *daos.Dao, count int) []*models.Record { func userRecords(dao *daos.Dao, count int) []*models.Record {
@@ -222,3 +237,41 @@ func webhookRecords(dao *daos.Dao) []*models.Record {
return []*models.Record{record} return []*models.Record{record}
} }
const (
triggerWebhook = `{"token":"1234567890","path":"webhook"}`
reactionPython = `{"requirements":"requests","script":"import sys\n\nprint(sys.argv[1])"}`
triggerHook = `{"collections":["tickets","comments"],"events":["create","update","delete"]}`
reactionWebhook = `{"headers":["Content-Type: application/json"],"url":"http://localhost:8080/webhook"}`
)
func reactionRecords(dao *daos.Dao) []*models.Record {
var records []*models.Record
collection, err := dao.FindCollectionByNameOrId(migrations.ReactionCollectionName)
if err != nil {
panic(err)
}
record := models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Test Reaction")
record.Set("trigger", "webhook")
record.Set("triggerdata", triggerWebhook)
record.Set("action", "python")
record.Set("actiondata", reactionPython)
records = append(records, record)
record = models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Test Reaction 2")
record.Set("trigger", "hook")
record.Set("triggerdata", triggerHook)
record.Set("action", "webhook")
record.Set("actiondata", reactionWebhook)
records = append(records, record)
return records
}

29
fakedata/records_test.go Normal file
View File

@@ -0,0 +1,29 @@
package fakedata_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SecurityBrewery/catalyst/fakedata"
catalystTesting "github.com/SecurityBrewery/catalyst/testing"
)
func Test_records(t *testing.T) {
app, cleanup := catalystTesting.App(t)
defer cleanup()
got, err := fakedata.Records(app, 2, 2)
require.NoError(t, err)
assert.Greater(t, len(got), 2)
}
func TestGenerate(t *testing.T) {
app, cleanup := catalystTesting.App(t)
defer cleanup()
err := fakedata.Generate(app, 0, 0)
require.NoError(t, err)
}

48
fakedata/text_test.go Normal file
View File

@@ -0,0 +1,48 @@
package fakedata
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_fakeTicketComment(t *testing.T) {
assert.NotEmpty(t, fakeTicketComment())
}
func Test_fakeTicketDescription(t *testing.T) {
assert.NotEmpty(t, fakeTicketDescription())
}
func Test_fakeTicketTask(t *testing.T) {
assert.NotEmpty(t, fakeTicketTask())
}
func Test_fakeTicketTimelineMessage(t *testing.T) {
assert.NotEmpty(t, fakeTicketTimelineMessage())
}
func Test_random(t *testing.T) {
type args[T any] struct {
e []T
}
type testCase[T any] struct {
name string
args args[T]
}
tests := []testCase[int]{
{
name: "Test random",
args: args[int]{e: []int{1, 2, 3, 4, 5}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := random(tt.args.e)
assert.Contains(t, tt.args.e, got)
})
}
}

View File

@@ -1,22 +0,0 @@
package main
import (
"github.com/pocketbase/pocketbase/core"
"github.com/SecurityBrewery/catalyst/migrations"
)
func flags(app core.App) ([]string, error) {
records, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName)
if err != nil {
return nil, err
}
var flags []string
for _, r := range records {
flags = append(flags, r.GetString("name"))
}
return flags, nil
}

5
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/pocketbase v0.22.14 github.com/pocketbase/pocketbase v0.22.14
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
) )
require ( require (
@@ -34,6 +35,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 // indirect
github.com/aws/smithy-go v1.20.2 // 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/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // 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/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
@@ -83,6 +85,7 @@ require (
google.golang.org/grpc v1.64.1 // indirect google.golang.org/grpc v1.64.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v2 v2.4.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/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.53.3 // indirect modernc.org/libc v1.53.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect

1
go.sum
View File

@@ -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.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
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/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.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

26
main.go
View File

@@ -3,33 +3,11 @@ package main
import ( import (
"log" "log"
"github.com/pocketbase/pocketbase" "github.com/SecurityBrewery/catalyst/app"
"github.com/SecurityBrewery/catalyst/migrations"
) )
func main() { func main() {
if err := run(); err != nil { if err := app.App("./catalyst_data").Start(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
func run() error {
migrations.Register()
app := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDev: dev(),
DefaultDataDir: "catalyst_data",
})
attachWebhooks(app)
// Register additional commands
app.RootCmd.AddCommand(bootstrapCmd(app))
app.RootCmd.AddCommand(fakeDataCmd(app))
app.RootCmd.AddCommand(setFeatureFlagsCmd(app))
app.OnBeforeServe().Add(addRoutes())
return app.Start()
}

View File

@@ -11,15 +11,15 @@ import (
) )
const ( const (
TimelineCollectionName = "timeline"
CommentCollectionName = "comments" CommentCollectionName = "comments"
fileCollectionName = "files" FeatureCollectionName = "features"
LinkCollectionName = "links" LinkCollectionName = "links"
TaskCollectionName = "tasks" TaskCollectionName = "tasks"
TicketCollectionName = "tickets" TicketCollectionName = "tickets"
TimelineCollectionName = "timeline"
TypeCollectionName = "types" TypeCollectionName = "types"
WebhookCollectionName = "webhooks" WebhookCollectionName = "webhooks"
FeatureCollectionName = "features" fileCollectionName = "files"
UserCollectionName = "_pb_users_auth_" UserCollectionName = "_pb_users_auth_"
) )
@@ -138,14 +138,14 @@ func internalCollection(c *models.Collection) *models.Collection {
func collectionsDown(db dbx.Builder) error { func collectionsDown(db dbx.Builder) error {
collections := []string{ collections := []string{
TicketCollectionName,
TypeCollectionName,
fileCollectionName, fileCollectionName,
LinkCollectionName, LinkCollectionName,
TaskCollectionName, TaskCollectionName,
CommentCollectionName, CommentCollectionName,
TimelineCollectionName, TimelineCollectionName,
FeatureCollectionName, FeatureCollectionName,
TicketCollectionName,
TypeCollectionName,
} }
dao := daos.New(db) dao := daos.New(db)

40
migrations/5_reactions.go Normal file
View File

@@ -0,0 +1,40 @@
package migrations
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
const ReactionCollectionName = "reactions"
func reactionsUp(db dbx.Builder) error {
triggers := []string{"webhook", "hook"}
reactions := []string{"python", "webhook"}
return daos.New(db).SaveCollection(internalCollection(&models.Collection{
Name: ReactionCollectionName,
Type: models.CollectionTypeBase,
Schema: schema.NewSchema(
&schema.SchemaField{Name: "name", Type: schema.FieldTypeText, Required: true},
&schema.SchemaField{Name: "trigger", Type: schema.FieldTypeSelect, Required: true, Options: &schema.SelectOptions{MaxSelect: 1, Values: triggers}},
&schema.SchemaField{Name: "triggerdata", Type: schema.FieldTypeJson, Required: true},
&schema.SchemaField{Name: "action", Type: schema.FieldTypeSelect, Required: true, Options: &schema.SelectOptions{MaxSelect: 1, Values: reactions}},
&schema.SchemaField{Name: "actiondata", Type: schema.FieldTypeJson, Required: true},
),
}))
}
func reactionsDown(db dbx.Builder) error {
dao := daos.New(db)
id, err := dao.FindCollectionByNameOrId(ReactionCollectionName)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", ReactionCollectionName, err)
}
return dao.DeleteCollection(id)
}

View File

@@ -9,4 +9,5 @@ func Register() {
migrations.Register(collectionsUp, collectionsDown, "1700000001_collections.go") migrations.Register(collectionsUp, collectionsDown, "1700000001_collections.go")
migrations.Register(defaultDataUp, nil, "1700000003_defaultdata.go") migrations.Register(defaultDataUp, nil, "1700000003_defaultdata.go")
migrations.Register(viewsUp, viewsDown, "1700000004_views.go") migrations.Register(viewsUp, viewsDown, "1700000004_views.go")
migrations.Register(reactionsUp, reactionsDown, "1700000005_reactions.go")
} }

44
reaction/action/action.go Normal file
View File

@@ -0,0 +1,44 @@
package action
import (
"context"
"encoding/json"
"fmt"
"github.com/SecurityBrewery/catalyst/reaction/action/python"
"github.com/SecurityBrewery/catalyst/reaction/action/webhook"
)
func Run(ctx context.Context, actionName, actionData, payload string) ([]byte, error) {
action, err := decode(actionName, actionData)
if err != nil {
return nil, err
}
return action.Run(ctx, payload)
}
type action interface {
Run(ctx context.Context, payload string) ([]byte, error)
}
func decode(actionName, actionData string) (action, error) {
switch actionName {
case "python":
var reaction python.Python
if err := json.Unmarshal([]byte(actionData), &reaction); err != nil {
return nil, err
}
return &reaction, nil
case "webhook":
var reaction webhook.Webhook
if err := json.Unmarshal([]byte(actionData), &reaction); err != nil {
return nil, err
}
return &reaction, nil
default:
return nil, fmt.Errorf("action %q not found", actionName)
}
}

View File

@@ -0,0 +1,105 @@
package python
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
)
type Python struct {
Bootstrap string `json:"bootstrap"`
Script string `json:"script"`
}
func (a *Python) Run(ctx context.Context, payload string) ([]byte, error) {
tempDir, err := os.MkdirTemp("", "catalyst_action")
if err != nil {
return nil, err
}
defer os.RemoveAll(tempDir)
if b, err := pythonSetup(ctx, tempDir); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
b = append(b, ee.Stderr...)
}
return nil, fmt.Errorf("failed to setup python, %w: %s", err, string(b))
}
if b, err := pythonRunBootstrap(ctx, tempDir, a.Bootstrap); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
b = append(b, ee.Stderr...)
}
return nil, fmt.Errorf("failed to run bootstrap, %w: %s", err, string(b))
}
b, err := pythonRunScript(ctx, tempDir, a.Script, payload)
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
b = append(b, ee.Stderr...)
}
return nil, fmt.Errorf("failed to run script, %w: %s", err, string(b))
}
return b, nil
}
func pythonSetup(ctx context.Context, tempDir string) ([]byte, error) {
pythonPath, err := findExec("python", "python3")
if err != nil {
return nil, fmt.Errorf("python or python3 binary not found, %w", err)
}
// setup virtual environment
return exec.CommandContext(ctx, pythonPath, "-m", "venv", tempDir+"/venv").Output()
}
func pythonRunBootstrap(ctx context.Context, tempDir, bootstrap string) ([]byte, error) {
hasBootstrap := len(strings.TrimSpace(bootstrap)) > 0
if !hasBootstrap {
return nil, nil
}
bootstrapPath := tempDir + "/requirements.txt"
if err := os.WriteFile(bootstrapPath, []byte(bootstrap), 0o600); err != nil {
return nil, err
}
// install dependencies
pipPath := tempDir + "/venv/bin/pip"
return exec.CommandContext(ctx, pipPath, "install", "-r", bootstrapPath).Output()
}
func pythonRunScript(ctx context.Context, 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.CommandContext(ctx, 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")
}

View File

@@ -0,0 +1,20 @@
package webhook
import (
"encoding/base64"
"io"
"unicode/utf8"
)
func EncodeBody(requestBody io.Reader) (string, bool) {
body, err := io.ReadAll(requestBody)
if err != nil {
return "", false
}
if utf8.Valid(body) {
return string(body), false
}
return base64.StdEncoding.EncodeToString(body), true
}

View File

@@ -0,0 +1,49 @@
package webhook
import (
"bytes"
"io"
"testing"
)
func TestEncodeBody(t *testing.T) {
type args struct {
requestBody io.Reader
}
tests := []struct {
name string
args args
want string
want1 bool
}{
{
name: "utf8",
args: args{
requestBody: bytes.NewBufferString("body"),
},
want: "body",
want1: false,
},
{
name: "non-utf8",
args: args{
requestBody: bytes.NewBufferString("body\xe0"),
},
want: "Ym9keeA=",
want1: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1 := EncodeBody(tt.args.requestBody)
if got != tt.want {
t.Errorf("EncodeBody() got = %v, want %v", got, tt.want)
}
if got1 != tt.want1 {
t.Errorf("EncodeBody() got1 = %v, want %v", got1, tt.want1)
}
})
}
}

View File

@@ -0,0 +1,12 @@
package webhook
import (
"net/http"
)
type Response struct {
StatusCode int `json:"statusCode"`
Headers http.Header `json:"headers"`
Body string `json:"body"`
IsBase64Encoded bool `json:"isBase64Encoded"`
}

View File

@@ -0,0 +1,39 @@
package webhook
import (
"context"
"encoding/json"
"net/http"
"strings"
)
type Webhook struct {
Headers map[string]string `json:"headers"`
URL string `json:"url"`
}
func (a *Webhook) Run(ctx context.Context, payload string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.URL, strings.NewReader(payload))
if err != nil {
return nil, err
}
for key, value := range a.Headers {
req.Header.Set(key, value)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, isBase64Encoded := EncodeBody(res.Body)
return json.Marshal(Response{
StatusCode: res.StatusCode,
Headers: res.Header,
Body: body,
IsBase64Encoded: isBase64Encoded,
})
}

13
reaction/trigger.go Normal file
View File

@@ -0,0 +1,13 @@
package reaction
import (
"github.com/pocketbase/pocketbase/core"
"github.com/SecurityBrewery/catalyst/reaction/trigger/hook"
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
)
func BindHooks(app core.App) {
hook.BindHooks(app)
webhook.BindHooks(app)
}

View File

@@ -0,0 +1,103 @@
package hook
import (
"encoding/json"
"fmt"
"slices"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action"
"github.com/SecurityBrewery/catalyst/webhook"
)
type Hook struct {
Collections []string `json:"collections"`
Events []string `json:"events"`
}
func BindHooks(app core.App) {
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
if err := hook(app.Dao(), "create", e.Collection.Name, e.Record, e.HttpContext); err != nil {
app.Logger().Error("failed to find hook reaction", "error", err.Error())
}
return nil
})
app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
if err := hook(app.Dao(), "update", e.Collection.Name, e.Record, e.HttpContext); err != nil {
app.Logger().Error("failed to find hook reaction", "error", err.Error())
}
return nil
})
app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
if err := hook(app.Dao(), "delete", e.Collection.Name, e.Record, e.HttpContext); err != nil {
app.Logger().Error("failed to find hook reaction", "error", err.Error())
}
return nil
})
}
func hook(dao *daos.Dao, event, collection string, record *models.Record, ctx echo.Context) error {
auth, _ := ctx.Get(apis.ContextAuthRecordKey).(*models.Record)
admin, _ := ctx.Get(apis.ContextAdminKey).(*models.Admin)
hook, found, err := findByHookTrigger(dao, collection, event)
if err != nil {
return fmt.Errorf("failed to find hook reaction: %w", err)
}
if !found {
return nil
}
payload, err := json.Marshal(&webhook.Payload{
Action: event,
Collection: collection,
Record: record,
Auth: auth,
Admin: admin,
})
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
_, err = action.Run(ctx.Request().Context(), hook.GetString("action"), hook.GetString("actiondata"), string(payload))
if err != nil {
return fmt.Errorf("failed to run hook reaction: %w", err)
}
return nil
}
func findByHookTrigger(dao *daos.Dao, collection, event string) (*models.Record, bool, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "hook"})
if err != nil {
return nil, false, err
}
if len(records) == 0 {
return nil, false, nil
}
for _, record := range records {
var hook Hook
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &hook); err != nil {
return nil, false, err
}
if slices.Contains(hook.Collections, collection) && slices.Contains(hook.Events, event) {
return record, true, nil
}
}
return nil, false, nil
}

View File

@@ -0,0 +1,23 @@
package webhook
import (
"encoding/json"
"net/http"
"net/url"
)
type Request 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"`
}
// isJSON checks if the data is JSON.
func isJSON(data []byte) bool {
var msg json.RawMessage
return json.Unmarshal(data, &msg) == nil
}

View File

@@ -0,0 +1,37 @@
package webhook
import "testing"
func Test_isJSON(t *testing.T) {
type args struct {
data []byte
}
tests := []struct {
name string
args args
want bool
}{
{
name: "valid JSON",
args: args{
data: []byte(`{"key": "value"}`),
},
want: true,
},
{
name: "invalid JSON",
args: args{
data: []byte(`{"key": "value"`),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isJSON(tt.args.data); got != tt.want {
t.Errorf("isJSON() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,146 @@
package webhook
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action"
"github.com/SecurityBrewery/catalyst/reaction/action/webhook"
)
type Webhook struct {
Token string `json:"token"`
Path string `json:"path"`
}
const prefix = "/reaction/"
func BindHooks(app core.App) {
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
e.Router.Any(prefix+"*", handle(e.App.Dao()))
return nil
})
}
func handle(dao *daos.Dao) func(c echo.Context) error {
return func(c echo.Context) error {
record, payload, apiErr := parseRequest(dao, c.Request())
if apiErr != nil {
return apiErr
}
output, err := action.Run(c.Request().Context(), record.GetString("action"), record.GetString("actiondata"), string(payload))
if err != nil {
return apis.NewApiError(http.StatusInternalServerError, err.Error(), nil)
}
return writeOutput(c, output)
}
}
func parseRequest(dao *daos.Dao, r *http.Request) (*models.Record, []byte, *apis.ApiError) {
if !strings.HasPrefix(r.URL.Path, prefix) {
return nil, nil, apis.NewApiError(http.StatusNotFound, "wrong prefix", nil)
}
reactionName := strings.TrimPrefix(r.URL.Path, prefix)
record, trigger, found, err := findByWebhookTrigger(dao, reactionName)
if err != nil {
return nil, nil, apis.NewNotFoundError(err.Error(), nil)
}
if !found {
return nil, nil, apis.NewNotFoundError("reaction not found", nil)
}
if trigger.Token != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return nil, nil, apis.NewUnauthorizedError("missing token", nil)
}
if trigger.Token != strings.TrimPrefix(auth, "Bearer ") {
return nil, nil, apis.NewUnauthorizedError("invalid token", nil)
}
}
body, isBase64Encoded := webhook.EncodeBody(r.Body)
payload, err := json.Marshal(&Request{
Method: r.Method,
Path: r.URL.EscapedPath(),
Headers: r.Header,
Query: r.URL.Query(),
Body: body,
IsBase64Encoded: isBase64Encoded,
})
if err != nil {
return nil, nil, apis.NewApiError(http.StatusInternalServerError, err.Error(), nil)
}
return record, payload, nil
}
func findByWebhookTrigger(dao *daos.Dao, path string) (*models.Record, *Webhook, bool, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "webhook"})
if err != nil {
return nil, nil, false, err
}
if len(records) == 0 {
return nil, nil, false, nil
}
for _, record := range records {
var webhook Webhook
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &webhook); err != nil {
return nil, nil, false, err
}
if webhook.Path == path {
return record, &webhook, true, nil
}
}
return nil, nil, false, nil
}
func writeOutput(c echo.Context, output []byte) error {
var catalystResponse webhook.Response
if err := json.Unmarshal(output, &catalystResponse); err == nil && catalystResponse.StatusCode != 0 {
for key, values := range catalystResponse.Headers {
for _, value := range values {
c.Response().Header().Add(key, value)
}
}
if catalystResponse.IsBase64Encoded {
output, err = base64.StdEncoding.DecodeString(catalystResponse.Body)
if err != nil {
return fmt.Errorf("error decoding base64 body: %w", err)
}
} else {
output = []byte(catalystResponse.Body)
}
}
if isJSON(output) {
return c.JSON(http.StatusOK, json.RawMessage(output))
}
return c.String(http.StatusOK, string(output))
}

View File

@@ -0,0 +1,250 @@
package testing
import (
"net/http"
"testing"
)
func TestReactionsCollection(t *testing.T) {
baseApp, adminToken, analystToken, baseAppCleanup := BaseApp(t)
defer baseAppCleanup()
testSets := []authMatrixText{
{
baseTest: BaseTest{
Name: "ListReactions",
Method: http.MethodGet,
URL: "/api/collections/reactions/records",
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"totalItems":3`,
`"id":"r_reaction"`,
},
NotExpectedContent: []string{
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"totalItems":3`,
`"id":"r_reaction"`,
},
NotExpectedContent: []string{
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
},
},
{
baseTest: BaseTest{
Name: "CreateReaction",
Method: http.MethodPost,
RequestHeaders: map[string]string{"Content-Type": "application/json"},
URL: "/api/collections/reactions/records",
Body: s(map[string]any{
"name": "test",
"trigger": "webhook",
"triggerdata": map[string]any{"path": "test"},
"action": "python",
"actiondata": map[string]any{"script": "print('Hello, World!')"},
}),
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusBadRequest,
ExpectedContent: []string{
`"message":"Failed to create record."`,
},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"name":"test"`,
},
NotExpectedContent: []string{
`"items":[]`,
},
ExpectedEvents: map[string]int{
"OnModelAfterCreate": 1,
"OnModelBeforeCreate": 1,
"OnRecordAfterCreateRequest": 1,
"OnRecordBeforeCreateRequest": 1,
},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"name":"test"`,
},
NotExpectedContent: []string{
`"items":[]`,
},
ExpectedEvents: map[string]int{
"OnModelAfterCreate": 1,
"OnModelBeforeCreate": 1,
"OnRecordAfterCreateRequest": 1,
"OnRecordBeforeCreateRequest": 1,
},
},
},
},
{
baseTest: BaseTest{
Name: "GetReaction",
Method: http.MethodGet,
RequestHeaders: map[string]string{"Content-Type": "application/json"},
URL: "/api/collections/reactions/records/r_reaction",
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusNotFound,
ExpectedContent: []string{
`"message":"The requested resource wasn't found."`,
},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"id":"r_reaction"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"id":"r_reaction"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
},
},
{
baseTest: BaseTest{
Name: "UpdateReaction",
Method: http.MethodPatch,
RequestHeaders: map[string]string{"Content-Type": "application/json"},
URL: "/api/collections/reactions/records/r_reaction",
Body: s(map[string]any{"name": "update"}),
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusNotFound,
ExpectedContent: []string{
`"message":"The requested resource wasn't found."`,
},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"id":"r_reaction"`,
`"name":"update"`,
},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"id":"r_reaction"`,
`"name":"update"`,
},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
},
},
},
{
baseTest: BaseTest{
Name: "DeleteReaction",
Method: http.MethodDelete,
URL: "/api/collections/reactions/records/r_reaction",
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusNotFound,
ExpectedContent: []string{
`"message":"The requested resource wasn't found."`,
},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusNoContent,
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusNoContent,
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
},
},
},
}
for _, testSet := range testSets {
t.Run(testSet.baseTest.Name, func(t *testing.T) {
for _, authBasedExpectation := range testSet.authBasedExpectations {
scenario := mergeScenario(testSet.baseTest, authBasedExpectation)
scenario.Test(t)
}
})
}
}

177
testing/reaction_test.go Normal file
View File

@@ -0,0 +1,177 @@
package testing
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestWebhookReactions(t *testing.T) {
baseApp, adminToken, analystToken, baseAppCleanup := BaseApp(t)
defer baseAppCleanup()
server := testWebhookServer()
defer server.Close()
go server.ListenAndServe() //nolint:errcheck
testSets := []authMatrixText{
{
baseTest: BaseTest{
Name: "TriggerWebhookReaction",
Method: http.MethodGet,
URL: "/reaction/test",
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`Hello, World!`},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`Hello, World!`},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`Hello, World!`},
},
},
},
{
baseTest: BaseTest{
Name: "TriggerWebhookReaction2",
Method: http.MethodGet,
URL: "/reaction/test2",
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`"test":true`},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`"test":true`},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`"test":true`},
},
},
},
}
for _, testSet := range testSets {
t.Run(testSet.baseTest.Name, func(t *testing.T) {
for _, authBasedExpectation := range testSet.authBasedExpectations {
scenario := mergeScenario(testSet.baseTest, authBasedExpectation)
scenario.Test(t)
}
})
}
}
func testWebhookServer() *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("/webhook", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"test":true}`)) //nolint:errcheck
})
return &http.Server{
Addr: "127.0.0.1:12345",
Handler: mux,
ReadHeaderTimeout: 3 * time.Second,
}
}
type RecordingServer struct {
Entries []string
}
func NewRecordingServer() *RecordingServer {
return &RecordingServer{}
}
func (s *RecordingServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.Entries = append(s.Entries, r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"test":true}`)) //nolint:errcheck
}
func TestHookReactions(t *testing.T) {
baseApp, _, analystToken, baseAppCleanup := BaseApp(t)
defer baseAppCleanup()
server := NewRecordingServer()
go http.ListenAndServe("127.0.0.1:12346", server) //nolint:gosec,errcheck
testSets := []authMatrixText{
{
baseTest: BaseTest{
Name: "TriggerHookReaction",
Method: http.MethodPost,
RequestHeaders: map[string]string{"Content-Type": "application/json"},
URL: "/api/collections/tickets/records",
Body: s(map[string]any{
"name": "test",
}),
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
// {
// Name: "Unauthorized",
// ExpectedStatus: http.StatusOK,
// ExpectedContent: []string{`Hello, World!`},
// },
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"collectionName":"tickets"`,
`"name":"test"`,
},
ExpectedEvents: map[string]int{
"OnModelAfterCreate": 1,
"OnModelBeforeCreate": 1,
"OnRecordAfterCreateRequest": 1,
"OnRecordBeforeCreateRequest": 1,
},
},
// {
// Name: "Admin",
// RequestHeaders: map[string]string{"Authorization": adminToken},
// ExpectedStatus: http.StatusOK,
// ExpectedContent: []string{`Hello, World!`},
// },
},
},
}
for _, testSet := range testSets {
t.Run(testSet.baseTest.Name, func(t *testing.T) {
for _, authBasedExpectation := range testSet.authBasedExpectations {
scenario := mergeScenario(testSet.baseTest, authBasedExpectation)
scenario.Test(t)
}
require.NotEmpty(t, server.Entries)
})
}
}

79
testing/routes_test.go Normal file
View File

@@ -0,0 +1,79 @@
package testing
import (
"net/http"
"testing"
)
func Test_Routes(t *testing.T) {
baseApp, adminToken, analystToken, baseAppCleanup := BaseApp(t)
defer baseAppCleanup()
testSets := []authMatrixText{
{
baseTest: BaseTest{
Name: "Root",
Method: http.MethodGet,
URL: "/",
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusFound,
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusFound,
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusFound,
},
},
},
{
baseTest: BaseTest{
Name: "Config",
Method: http.MethodGet,
URL: "/api/config",
TestAppFactory: AppFactory(baseApp),
},
authBasedExpectations: []AuthBasedExpectation{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"flags":null`,
},
},
{
Name: "Analyst",
RequestHeaders: map[string]string{"Authorization": analystToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"flags":null`,
},
},
{
Name: "Admin",
RequestHeaders: map[string]string{"Authorization": adminToken},
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"flags":null`,
},
},
},
},
}
for _, testSet := range testSets {
t.Run(testSet.baseTest.Name, func(t *testing.T) {
for _, authBasedExpectation := range testSet.authBasedExpectations {
scenario := mergeScenario(testSet.baseTest, authBasedExpectation)
scenario.Test(t)
}
})
}
}

103
testing/testdata.go Normal file
View File

@@ -0,0 +1,103 @@
package testing
import (
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/SecurityBrewery/catalyst/migrations"
)
const (
adminEmail = "admin@catalyst-soar.com"
analystEmail = "analyst@catalyst-soar.com"
)
func defaultTestData(t *testing.T, app core.App) {
t.Helper()
adminTestData(t, app)
userTestData(t, app)
reactionTestData(t, app)
}
func adminTestData(t *testing.T, app core.App) {
t.Helper()
admin := &models.Admin{Email: adminEmail}
if err := admin.SetPassword("password"); err != nil {
t.Fatal(err)
}
if err := app.Dao().SaveAdmin(admin); err != nil {
t.Fatal(err)
}
}
func userTestData(t *testing.T, app core.App) {
t.Helper()
collection, err := app.Dao().FindCollectionByNameOrId(migrations.UserCollectionName)
if err != nil {
t.Fatal(err)
}
record := models.NewRecord(collection)
record.SetId("u_bob_analyst")
_ = record.SetUsername("u_bob_analyst")
_ = record.SetPassword("password")
record.Set("name", "Bob Analyst")
record.Set("email", analystEmail)
_ = record.SetVerified(true)
if err := app.Dao().SaveRecord(record); err != nil {
t.Fatal(err)
}
}
func reactionTestData(t *testing.T, app core.App) {
t.Helper()
collection, err := app.Dao().FindCollectionByNameOrId(migrations.ReactionCollectionName)
if err != nil {
t.Fatal(err)
}
record := models.NewRecord(collection)
record.SetId("r_reaction")
record.Set("name", "Reaction")
record.Set("trigger", "webhook")
record.Set("triggerdata", `{"path":"test"}`)
record.Set("action", "python")
record.Set("actiondata", `{"bootstrap":"requests","script":"print('Hello, World!')"}`)
if err := app.Dao().SaveRecord(record); err != nil {
t.Fatal(err)
}
record = models.NewRecord(collection)
record.SetId("r_reaction_webhook")
record.Set("name", "Reaction")
record.Set("trigger", "webhook")
record.Set("triggerdata", `{"path":"test2"}`)
record.Set("action", "webhook")
record.Set("actiondata", `{"headers":{"Content-Type":"application/json"},"url":"http://127.0.0.1:12345/webhook"}`)
if err := app.Dao().SaveRecord(record); err != nil {
t.Fatal(err)
}
record = models.NewRecord(collection)
record.SetId("r_reaction_hook")
record.Set("name", "Hook")
record.Set("trigger", "hook")
record.Set("triggerdata", `{"collections":["tickets"],"events":["create"]}`)
record.Set("action", "python")
record.Set("actiondata", `{"bootstrap":"requests","script":"import requests\nrequests.post('http://127.0.0.1:12346/test', json={'test':True})"}`)
if err := app.Dao().SaveRecord(record); err != nil {
t.Fatal(err)
}
}

172
testing/testing.go Normal file
View File

@@ -0,0 +1,172 @@
package testing
import (
"bytes"
"encoding/json"
"os"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tokens"
"github.com/SecurityBrewery/catalyst/app"
"github.com/SecurityBrewery/catalyst/migrations"
)
func BaseApp(t *testing.T) (core.App, string, string, func()) {
t.Helper()
temp, err := os.MkdirTemp("", "catalyst_test_data")
if err != nil {
t.Fatal(err)
}
baseApp := app.App(temp)
if err := app.Bootstrap(baseApp); err != nil {
t.Fatal(err)
}
defaultTestData(t, baseApp)
adminToken, err := generateAdminToken(t, baseApp, adminEmail)
if err != nil {
t.Fatal(err)
}
analystToken, err := generateRecordToken(t, baseApp, analystEmail)
if err != nil {
t.Fatal(err)
}
return baseApp, adminToken, analystToken, func() { _ = os.RemoveAll(temp) }
}
func AppFactory(baseApp core.App) func(t *testing.T) *tests.TestApp {
return func(t *testing.T) *tests.TestApp {
t.Helper()
testApp, err := tests.NewTestApp(baseApp.DataDir())
if err != nil {
t.Fatal(err)
}
app.BindHooks(testApp)
if err := app.Bootstrap(testApp); err != nil {
t.Fatal(err)
}
return testApp
}
}
func App(t *testing.T) (*tests.TestApp, func()) {
t.Helper()
baseApp, _, _, cleanup := BaseApp(t)
testApp := AppFactory(baseApp)(t)
return testApp, cleanup
}
func generateAdminToken(t *testing.T, baseApp core.App, email string) (string, error) {
t.Helper()
app, err := tests.NewTestApp(baseApp.DataDir())
if err != nil {
return "", err
}
defer app.Cleanup()
admin, err := app.Dao().FindAdminByEmail(email)
if err != nil {
return "", err
}
return tokens.NewAdminAuthToken(app, admin)
}
func generateRecordToken(t *testing.T, baseApp core.App, email string) (string, error) {
t.Helper()
app, err := tests.NewTestApp(baseApp.DataDir())
if err != nil {
t.Fatal(err)
}
defer app.Cleanup()
record, err := app.Dao().FindAuthRecordByEmail(migrations.UserCollectionName, email)
if err != nil {
return "", err
}
return tokens.NewRecordAuthToken(app, record)
}
type BaseTest struct {
Name string
Method string
RequestHeaders map[string]string
URL string
Body string
TestAppFactory func(t *testing.T) *tests.TestApp
}
type AuthBasedExpectation struct {
Name string
RequestHeaders map[string]string
ExpectedStatus int
ExpectedContent []string
NotExpectedContent []string
ExpectedEvents map[string]int
}
type authMatrixText struct {
baseTest BaseTest
authBasedExpectations []AuthBasedExpectation
}
func mergeScenario(base BaseTest, expectation AuthBasedExpectation) tests.ApiScenario {
return tests.ApiScenario{
Name: expectation.Name,
Method: base.Method,
Url: base.URL,
Body: bytes.NewBufferString(base.Body),
TestAppFactory: base.TestAppFactory,
RequestHeaders: mergeMaps(base.RequestHeaders, expectation.RequestHeaders),
ExpectedStatus: expectation.ExpectedStatus,
ExpectedContent: expectation.ExpectedContent,
NotExpectedContent: expectation.NotExpectedContent,
ExpectedEvents: expectation.ExpectedEvents,
}
}
func mergeMaps(a, b map[string]string) map[string]string {
if a == nil {
return b
}
if b == nil {
return a
}
for k, v := range b {
a[k] = v
}
return a
}
func b(data map[string]any) []byte {
b, _ := json.Marshal(data) //nolint:errchkjson
return b
}
func s(data map[string]any) string {
return string(b(data))
}

View File

@@ -63,11 +63,11 @@ const deleteMutation = useMutation({
> >
</DialogHeader> </DialogHeader>
<DialogFooter class="mt-2"> <DialogFooter class="mt-2 sm:justify-start">
<Button type="button" variant="destructive" @click="deleteMutation.mutate"> Delete </Button>
<DialogClose as-child> <DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button> <Button type="button" variant="secondary">Cancel</Button>
</DialogClose> </DialogClose>
<Button type="button" variant="destructive" @click="deleteMutation.mutate"> Delete </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { Textarea } from '@/components/ui/textarea'
import { useVModel } from '@vueuse/core'
import { type HTMLAttributes, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
defaultValue?: string | number
modelValue?: string | number
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
})
const textarea = ref<HTMLElement | null>(null)
const resize = () => {
if (!textarea.value) return
textarea.value.style.height = 'auto' // Reset to default or minimum height
textarea.value.style.height = textarea.value.scrollHeight + 2 + 'px'
}
onMounted(() => resize())
</script>
<template>
<textarea
ref="textarea"
rows="1"
@focus="resize"
@input="resize"
v-model="modelValue"
:class="
cn(
'flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"
/>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import TextInput from '@/components/form/TextInput.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string[]
placeholder?: string
}>(),
{
modelValue: () => [],
placeholder: ''
}
)
const emit = defineEmits(['update:modelValue'])
const newItem = ref('')
const updateModelValue = (value: string, index: number) => {
const newValue = props.modelValue
newValue[index] = value
emit('update:modelValue', newValue)
}
const addModelValue = () => {
emit('update:modelValue', [...props.modelValue, newItem.value])
newItem.value = ''
}
const removeModelValue = (index: number) =>
emit(
'update:modelValue',
props.modelValue.filter((_, i) => i !== index)
)
</script>
<template>
<div class="flex flex-col gap-2">
<div v-for="(item, index) in modelValue" :key="item" class="flex flex-row items-center gap-2">
<TextInput
:modelValue="item"
@update:modelValue="updateModelValue($event, index)"
:placeholder="placeholder"
/>
<Button variant="outline" size="icon" @click="removeModelValue(index)" class="shrink-0">
<Trash2 class="size-4" />
<span class="sr-only">Remove item</span>
</Button>
</div>
<div class="flex flex-row items-center gap-2">
<Input v-model="newItem" :placeholder="placeholder" @keydown.enter.prevent="addModelValue" />
<Button
variant="outline"
size="icon"
@click="addModelValue"
:disabled="newItem === ''"
class="shrink-0"
>
<Plus class="size-4" />
<span class="sr-only">Add item</span>
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { ComboboxAnchor, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string[]
items: string[]
placeholder?: string
}>(),
{
modelValue: () => [],
items: () => [],
placeholder: ''
}
)
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const searchTerm = ref('')
const selectedItems = ref<string[]>(props.modelValue)
watch(selectedItems.value, (value) => emit('update:modelValue', value))
const filteredItems = computed(() => {
if (!selectedItems.value) return props.items
return props.items.filter((i) => !selectedItems.value.includes(i))
})
</script>
<template>
<TagsInput class="flex items-center gap-2 px-0" :modelValue="selectedItems">
<div class="flex flex-wrap items-center">
<TagsInputItem v-for="item in selectedItems" :key="item" :value="item" class="ml-2">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
</div>
<ComboboxRoot
v-model="selectedItems"
v-model:open="open"
v-model:searchTerm="searchTerm"
class="flex-1"
>
<ComboboxAnchor as-child>
<ComboboxInput
:placeholder="placeholder"
as-child
:class="selectedItems.length < items.length ? '' : 'hidden'"
>
<TagsInputInput @keydown.enter.prevent @focus="open = true" @blur="open = false" />
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal>
<CommandList
v-if="selectedItems.length < items.length"
position="popper"
class="mt-2 w-[--radix-popper-anchor-width] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandEmpty />
<CommandGroup>
<CommandItem
v-for="item in filteredItems"
:key="item"
:value="item"
@select.prevent="
(ev) => {
if (typeof ev.detail.value === 'string') {
searchTerm = ''
selectedItems.push(ev.detail.value)
}
if (filteredItems.length === 0) {
open = false
}
}
"
>
{{ item }}
</CommandItem>
</CommandGroup>
</CommandList>
</ComboboxPortal>
</ComboboxRoot>
</TagsInput>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Save, X } from 'lucide-vue-next'
import { type HTMLAttributes, ref, watchEffect } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
class?: HTMLAttributes['class']
}>(),
{
modelValue: '',
placeholder: ''
}
)
const emit = defineEmits(['update:modelValue'])
const text = ref(props.modelValue)
const editMode = ref(false)
const input = ref<HTMLInputElement | null>(null)
const setValue = () => emit('update:modelValue', text.value)
watchEffect(() => {
if (editMode.value && input.value) {
input.value.$el.focus()
}
})
const edit = () => {
text.value = props.modelValue
editMode.value = true
}
const cancel = () => {
text.value = props.modelValue
editMode.value = false
}
</script>
<template>
<Button v-if="!editMode" variant="outline" size="icon" @click="edit" class="flex-1">
<div class="ml-3 w-full text-start font-normal">
{{ text }}
</div>
</Button>
<div v-else class="flex w-full flex-row gap-2">
<Input
ref="input"
v-model="text"
:placeholder="placeholder"
@keydown.enter="setValue"
class="flex-1"
/>
<Button variant="outline" size="icon" @click="cancel" class="shrink-0">
<X class="size-4" />
<span class="sr-only">Cancel</span>
</Button>
<Button
variant="outline"
size="icon"
@click="setValue"
:disabled="text === modelValue"
class="shrink-0"
>
<Save class="size-4" />
<span class="sr-only">Save</span>
</Button>
</div>
</template>

View File

@@ -29,7 +29,6 @@ const catalystStore = useCatalystStore()
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1> <h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div> </div>
<NavList <NavList
class="mt-auto"
:is-collapsed="catalystStore.sidebarCollapsed" :is-collapsed="catalystStore.sidebarCollapsed"
:links="[ :links="[
{ {
@@ -43,10 +42,20 @@ const catalystStore = useCatalystStore()
<Separator /> <Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" /> <IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<div class="flex-1" /> <div class="flex-1" />
<Separator />
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Reactions',
icon: 'Zap',
variant: 'ghost',
to: '/reactions'
}
]"
/>
<Separator /> <Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" /> <UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator /> <Separator />

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import GrowTextarea from '@/components/form/GrowTextarea.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
</script>
<template>
<FormField name="actiondata.requirements" v-slot="{ componentField }">
<FormItem>
<FormLabel for="requirements" class="text-right">requirements.txt</FormLabel>
<FormControl>
<GrowTextarea id="requirements" class="col-span-3" v-bind="componentField" />
</FormControl>
<FormDescription> Specify the Python packages required to run the script. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="actiondata.script" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="script" class="text-right">Script</FormLabel>
<FormControl>
<GrowTextarea id="script" class="col-span-3" v-bind="componentField" />
</FormControl>
<FormDescription>
Write a Python script to run when the reaction is triggered.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import GrowListTextarea from '@/components/form/ListInput.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>
<template>
<FormField name="actiondata.headers" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel for="headers" class="text-right">Headers</FormLabel>
<FormControl>
<GrowListTextarea
id="headers"
:modelValue="value"
@update:modelValue="handleChange"
placeholder="Content-Type: application/json"
/>
</FormControl>
<FormDescription> Specify the headers to include in the request. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="actiondata.url" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="url" class="text-right">URL</FormLabel>
<FormControl>
<Input id="url" v-bind="componentField" placeholder="https://example.com/webhook" />
</FormControl>
<FormDescription> Specify the URL to send the request to. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { pb } from '@/lib/pocketbase'
import type { Reaction } from '@/lib/types'
import { handleError } from '@/lib/utils'
const queryClient = useQueryClient()
const props = defineProps<{
id: string
}>()
const {
isPending,
isError,
data: reaction,
error
} = useQuery({
queryKey: ['reactions', props.id],
queryFn: (): Promise<Reaction> => pb.collection('reactions').getOne(props.id)
})
const updateReactionMutation = useMutation({
mutationFn: (update: any) => pb.collection('reactions').update(props.id, update),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['reactions'] }),
onError: handleError
})
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reaction">
<div class="flex h-full flex-1 flex-col overflow-hidden">
<div class="flex items-center bg-background px-4 py-2">
<div class="ml-auto">
<DeleteDialog
v-if="reaction"
collection="reactions"
:id="reaction.id"
:name="reaction.name"
:singular="'Reaction'"
:to="{ name: 'reactions' }"
:queryKey="['reactions']"
/>
</div>
</div>
<Separator />
<ScrollArea v-if="reaction" class="flex-1">
<div class="flex max-w-[640px] flex-col gap-4 p-4">
<ReactionForm :reaction="reaction" @submit="updateReactionMutation.mutate" hide-cancel />
</div>
</ScrollArea>
</div>
</TanView>
</template>

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue'
import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue'
import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue'
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { defineRule, useForm } from 'vee-validate'
import { computed, ref, watch } from 'vue'
import type { Reaction } from '@/lib/types'
const submitDisabledReason = ref<string>('')
const props = defineProps<{
reaction?: Reaction
}>()
const emit = defineEmits(['submit'])
defineRule('required', (value: string) => {
if (!value || !value.length) {
return 'This field is required'
}
return true
})
defineRule('triggerdata.token', (value: string) => {
return true
})
defineRule('triggerdata.path', (value: string) => {
if (values.trigger !== 'webhook') {
return true
}
if (!value) {
return 'This field is required'
}
const expression = /^[a-zA-Z0-9-_]+$/
if (!value.match(expression)) {
return 'Invalid path, only letters, numbers, dashes, and underscores are allowed'
}
return true
})
defineRule('triggerdata.collections', (value: string[]) => {
if (values.trigger !== 'hook') {
return true
}
if (!value) {
return 'This field is required'
}
if (value.length === 0) {
return 'At least one collection is required'
}
return true
})
defineRule('triggerdata.events', (value: string[]) => {
if (values.trigger !== 'hook') {
return true
}
if (!value) {
return 'This field is required'
}
if (value.length === 0) {
return 'At least one event is required'
}
return true
})
defineRule('actiondata.script', (value: string) => {
if (values.action !== 'python') {
return true
}
if (!value) {
return 'This field is required'
}
return true
})
defineRule('actiondata.url', (value: string) => {
if (values.action !== 'webhook') {
return true
}
if (!value) {
return 'This field is required'
}
if (!(value.startsWith('http://') || value.startsWith('https://'))) {
return 'Invalid URL, must start with http:// or https://'
}
return true
})
const { handleSubmit, validate, values } = useForm({
initialValues: props.reaction || {
name: '',
trigger: '',
triggerdata: {},
action: '',
actiondata: {}
},
validationSchema: {
name: 'required',
trigger: 'required',
'triggerdata.token': 'triggerdata.token',
'triggerdata.path': 'triggerdata.path',
'triggerdata.collections': 'triggerdata.collections',
'triggerdata.events': 'triggerdata.events',
'actiondata.script': 'actiondata.script',
'actiondata.url': 'actiondata.url',
action: 'required'
}
})
const equalReaction = (values: Reaction, reaction?: Reaction): boolean => {
if (!reaction) return false
return (
reaction.name === values.name &&
reaction.trigger === values.trigger &&
JSON.stringify(reaction.triggerdata) === JSON.stringify(values.triggerdata) &&
reaction.action === values.action &&
JSON.stringify(reaction.actiondata) === JSON.stringify(values.actiondata)
)
}
watch(
() => props.reaction,
() => {
if (equalReaction(values, props.reaction)) {
submitDisabledReason.value = 'Make changes to save'
}
},
{ immediate: true }
)
watch(
values,
() => {
if (equalReaction(values, props.reaction)) {
submitDisabledReason.value = 'Make changes to save'
return
}
validate({ mode: 'silent' }).then((res) => {
if (res.valid) {
submitDisabledReason.value = ''
} else {
submitDisabledReason.value = 'Please fix the errors'
}
})
},
{ deep: true, immediate: true }
)
const onSubmit = handleSubmit((values) => emit('submit', values))
const curlExample = computed(() => {
let cmd = `curl`
if (values.triggerdata.token) {
cmd += ` -H "Auth: Bearer ${values.triggerdata.token}"`
}
if (values.triggerdata.path) {
cmd += ` https://${location.hostname}/reaction/${values.triggerdata.path}`
}
return cmd
})
</script>
<template>
<form @submit="onSubmit" class="flex flex-col items-start gap-4">
<FormField name="name" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="name" class="text-right">Name</FormLabel>
<Input id="name" class="col-span-3" v-bind="componentField" />
<FormMessage />
</FormItem>
</FormField>
<Card class="w-full">
<CardHeader>
<CardTitle>Trigger</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<FormField name="trigger" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="trigger" class="text-right">Type</FormLabel>
<FormControl>
<Select id="trigger" class="col-span-3" v-bind="componentField">
<SelectTrigger class="font-medium">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
<SelectItem value="hook">Collection Hook</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<p>HTTP / Webhook: Receive a HTTP request.</p>
<p>Collection Hook: Triggered by a collection and event.</p>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<TriggerWebhookFormFields v-if="values.trigger === 'webhook'" />
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
<div v-if="values.trigger === 'webhook'">
<Label for="url">Usage</Label>
<Input id="url" readonly :modelValue="curlExample" class="bg-accent" />
</div>
</CardContent>
</Card>
<Card class="w-full">
<CardHeader>
<CardTitle>Action</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<FormField name="action" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="action" class="text-right">Type</FormLabel>
<FormControl>
<Select id="action" class="col-span-3" v-bind="componentField">
<SelectTrigger class="font-medium">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="python">Python</SelectItem>
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<p>Python: Execute a Python script.</p>
<p>HTTP / Webhook: Send an HTTP request.</p>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<ActionPythonFormFields v-if="values.action === 'python'" />
<ActionWebhookFormFields v-else-if="values.action === 'webhook'" />
</CardContent>
</Card>
<div class="flex gap-4">
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger class="cursor-default">
<Button
type="submit"
:variant="submitDisabledReason !== '' ? 'secondary' : 'default'"
:disabled="submitDisabledReason !== ''"
:title="submitDisabledReason"
>
Save
</Button>
</TooltipTrigger>
<TooltipContent>
<span v-if="submitDisabledReason !== ''">
{{ submitDisabledReason }}
</span>
<span v-else> Save the reaction. </span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<slot name="cancel" />
</div>
</form>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import ResourceListElement from '@/components/common/ResourceListElement.vue'
import ReactionNewDialog from '@/components/reaction/ReactionNewDialog.vue'
import { Separator } from '@/components/ui/separator'
import { useQuery } from '@tanstack/vue-query'
import { useRoute } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Reaction } from '@/lib/types'
const route = useRoute()
const {
isPending,
isError,
data: reactions,
error
} = useQuery({
queryKey: ['reactions'],
queryFn: (): Promise<Array<Reaction>> =>
pb.collection('reactions').getFullList({
sort: '-created'
})
})
const subtitle = (reaction: Reaction) =>
triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction)
const triggerNiceName = (reaction: Reaction) => {
if (reaction.trigger === 'hook') {
return 'Collection Hook'
} else if (reaction.trigger === 'webhook') {
return 'HTTP / Webhook'
} else {
return 'Unknown'
}
}
const reactionNiceName = (reaction: Reaction) => {
if (reaction.action === 'python') {
return 'Python'
} else if (reaction.action === 'webhook') {
return 'HTTP / Webhook'
} else {
return 'Unknown'
}
}
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reactions">
<div class="flex h-screen flex-col">
<div class="flex items-center bg-background px-4 py-2">
<h1 class="text-xl font-bold">Reactions</h1>
<div class="ml-auto">
<ReactionNewDialog />
</div>
</div>
<Separator />
<div class="mt-2 flex flex-1 flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear>
<ResourceListElement
v-for="reaction in reactions"
:key="reaction.id"
:title="reaction.name"
:created="reaction.created"
:subtitle="subtitle(reaction)"
description=""
:active="route.params.id === reaction.id"
:to="{ name: 'reactions', params: { id: reaction.id } }"
:open="false"
>
{{ reaction.name }}
</ResourceListElement>
</TransitionGroup>
</div>
</div>
</TanView>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogScrollContent,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Reaction, Ticket } from '@/lib/types'
import { handleError } from '@/lib/utils'
const queryClient = useQueryClient()
const router = useRouter()
const isOpen = ref(false)
const addReactionMutation = useMutation({
mutationFn: (values: Reaction): Promise<Reaction> => pb.collection('reactions').create(values),
onSuccess: (data: Ticket) => {
router.push({ name: 'reactions', params: { id: data.id } })
queryClient.invalidateQueries({ queryKey: ['reactions'] })
isOpen.value = false
},
onError: handleError
})
const cancel = () => (isOpen.value = false)
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogTrigger as-child>
<Button variant="ghost">New Reaction</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Reaction</DialogTitle>
<DialogDescription>Create a new reaction</DialogDescription>
</DialogHeader>
<DialogScrollContent>
<ReactionForm @submit="addReactionMutation.mutate">
<template #cancel>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
</template>
</ReactionForm>
</DialogScrollContent>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import MultiSelect from '@/components/form/MultiSelect.vue'
import { computed } from 'vue'
const modelValue = defineModel<string[]>({
default: []
})
const items = ['Tickets', 'Tasks', 'Comments', 'Timeline', 'Links', 'Files']
const mapping: Record<string, string> = {
tickets: 'Tickets',
tasks: 'Tasks',
comments: 'Comments',
timeline: 'Timeline',
links: 'Links',
files: 'Files'
}
const niceNames = computed(() => modelValue.value.map((collection) => mapping[collection]))
const updateModelValue = (values: string[]) => {
modelValue.value = values.map(
(value) => Object.keys(mapping).find((key) => mapping[key] === value)!
)
}
</script>
<template>
<MultiSelect
:modelValue="niceNames"
@update:modelValue="updateModelValue"
:items="items"
placeholder="Select collections..."
/>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import MultiSelect from '@/components/form/MultiSelect.vue'
import { computed } from 'vue'
const modelValue = defineModel<string[]>({
default: []
})
const items = ['Create Events', 'Update Events', 'Delete Events']
const mapping: Record<string, string> = {
create: 'Create Events',
update: 'Update Events',
delete: 'Delete Events'
}
const niceNames = computed(() =>
modelValue.value.map((collection) => mapping[collection] as string)
)
const updateModelValue = (values: string[]) => {
modelValue.value = values.map(
(value) => Object.keys(mapping).find((key) => mapping[key] === value)!
)
}
</script>
<template>
<MultiSelect
:modelValue="niceNames"
@update:modelValue="updateModelValue"
:items="items"
placeholder="Select events..."
/>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import TriggerHookFormFieldCollections from '@/components/reaction/TriggerHookFormFieldCollections.vue'
import TriggerHookFormFieldEvents from '@/components/reaction/TriggerHookFormFieldEvents.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
</script>
<template>
<FormField name="triggerdata.collections" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="collections" class="text-right">Collections</FormLabel>
<FormControl>
<TriggerHookFormFieldCollections id="collections" v-bind="componentField" />
</FormControl>
<FormDescription> Specify the collections to trigger the reaction. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="triggerdata.events" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="events" class="text-right">Events</FormLabel>
<FormControl>
<TriggerHookFormFieldEvents id="events" v-bind="componentField" />
</FormControl>
<FormDescription> Specify the events to trigger the reaction. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>
<template>
<FormField name="triggerdata.token" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="token" class="text-right">Token</FormLabel>
<FormControl>
<Input
id="token"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a token (e.g. 'xyz...')"
/>
</FormControl>
<FormDescription>
Optional. Include an authorization token in the request headers.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="triggerdata.path" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="path" class="text-right">Path</FormLabel>
<FormControl>
<Input
id="path"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a path (e.g. 'action1')"
/>
</FormControl>
<FormDescription> Specify the path to trigger the reaction. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -56,13 +56,13 @@ const closeTicketMutation = useMutation({
<Textarea v-model="resolution" placeholder="Closing reason" /> <Textarea v-model="resolution" placeholder="Closing reason" />
<DialogFooter class="mt-2"> <DialogFooter class="mt-2 sm:justify-start">
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<Button type="button" variant="default" @click="closeTicketMutation.mutate()"> <Button type="button" variant="default" @click="closeTicketMutation.mutate()">
Close Close
</Button> </Button>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -153,7 +153,7 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
</span> </span>
<div> <div>
<TooltipProvider> <TooltipProvider :delay-duration="0">
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Info class="ml-2 size-4 text-muted-foreground" /> <Info class="ml-2 size-4 text-muted-foreground" />

View File

@@ -139,11 +139,11 @@ watch(isOpen, () => {
<JSONSchemaFormFields v-model="state" :schema="selectedType.schema" /> <JSONSchemaFormFields v-model="state" :schema="selectedType.schema" />
<DialogFooter class="mt-4"> <DialogFooter class="mt-4 sm:justify-start">
<Button type="submit"> Save </Button>
<DialogClose as-child> <DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button> <Button type="button" variant="secondary">Cancel</Button>
</DialogClose> </DialogClose>
<Button type="submit"> Save </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>

View File

@@ -25,7 +25,7 @@ withDefaults(defineProps<Props>(), {
<Button v-if="!hideAdd" variant="ghost" size="icon" class="h-8 w-8" @click="emit('add')"> <Button v-if="!hideAdd" variant="ghost" size="icon" class="h-8 w-8" @click="emit('add')">
<Plus class="size-4" /> <Plus class="size-4" />
<span class="sr-only">Add link</span> <span class="sr-only">Add item</span>
</Button> </Button>
</div> </div>
<Card v-if="$slots.default" class="p-0"> <Card v-if="$slots.default" class="p-0">

View File

@@ -99,11 +99,11 @@ const save = () => editCommentMutation.mutate()
<DialogTitle>Delete comment</DialogTitle> <DialogTitle>Delete comment</DialogTitle>
<DialogDescription> Are you sure you want to delete this comment?</DialogDescription> <DialogDescription> Are you sure you want to delete this comment?</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter class="sm:justify-start">
<Button @click="deleteCommentMutation.mutate" variant="destructive">Delete</Button>
<DialogClose as-child> <DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button> <Button type="button" variant="secondary">Cancel</Button>
</DialogClose> </DialogClose>
<Button @click="deleteCommentMutation.mutate" variant="destructive"> Delete </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -65,11 +65,11 @@ function handleFileUpload($event: Event) {
<Input id="file" type="file" class="mt-2" @change="handleFileUpload($event)" /> <Input id="file" type="file" class="mt-2" @change="handleFileUpload($event)" />
<DialogFooter class="mt-2"> <DialogFooter class="mt-2 sm:justify-start">
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<Button @click="save">Upload</Button> <Button @click="save">Upload</Button>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -90,10 +90,7 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
</FormItem> </FormItem>
</FormField> </FormField>
<DialogFooter class="mt-2"> <DialogFooter class="mt-2 sm:justify-start">
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<Button <Button
:title="submitDisabled ? 'Please fill out all required fields' : undefined" :title="submitDisabled ? 'Please fill out all required fields' : undefined"
:disabled="submitDisabled" :disabled="submitDisabled"
@@ -101,6 +98,9 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
> >
Save Save
</Button> </Button>
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>

View File

@@ -7,7 +7,6 @@ import TaskAddDialog from '@/components/ticket/task/TaskAddDialog.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { toast } from '@/components/ui/toast'
import { Trash2, User2 } from 'lucide-vue-next' import { Trash2, User2 } from 'lucide-vue-next'

View File

@@ -120,13 +120,13 @@ const save = () =>
Are you sure you want to delete this timeline item?</DialogDescription Are you sure you want to delete this timeline item?</DialogDescription
> >
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter class="sm:justify-start">
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<Button @click="deleteTimelineItemMutation.mutate" variant="destructive"> <Button @click="deleteTimelineItemMutation.mutate" variant="destructive">
Delete Delete
</Button> </Button>
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import {
TagsInputRoot,
type TagsInputRootEmits,
type TagsInputRootProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TagsInputRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TagsInputRoot
v-bind="forwarded"
:class="
cn(
'flex flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm',
props.class
)
"
>
<slot />
</TagsInputRoot>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { TagsInputInput, type TagsInputInputProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputInputProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputInput
v-bind="forwardedProps"
:class="cn('min-h-6 flex-1 bg-transparent px-1 text-sm focus:outline-none', props.class)"
/>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { TagsInputItem, type TagsInputItemProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItem
v-bind="forwardedProps"
:class="
cn(
'flex h-6 items-center rounded bg-secondary ring-offset-background data-[state=active]:ring-2 data-[state=active]:ring-ring data-[state=active]:ring-offset-2',
props.class
)
"
>
<slot />
</TagsInputItem>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { TagsInputItemDelete, type TagsInputItemDeleteProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItemDelete
v-bind="forwardedProps"
:class="cn('mr-1 flex rounded bg-transparent', props.class)"
>
<slot>
<X class="h-4 w-4" />
</slot>
</TagsInputItemDelete>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { TagsInputItemText, type TagsInputItemTextProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemTextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItemText
v-bind="forwardedProps"
:class="cn('rounded bg-transparent px-2 py-1 text-sm', props.class)"
/>
</template>

View File

@@ -0,0 +1,5 @@
export { default as TagsInput } from './TagsInput.vue'
export { default as TagsInputInput } from './TagsInputInput.vue'
export { default as TagsInputItem } from './TagsInputItem.vue'
export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue'
export { default as TagsInputItemText } from './TagsInputItemText.vue'

View File

@@ -130,3 +130,16 @@ export interface JSONSchema {
> >
required?: Array<string> required?: Array<string>
} }
export interface Reaction {
id: string
name: string
trigger: string
triggerdata: any
action: string
actiondata: any
created: string
updated: string
}

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '@/views/DashboardView.vue' import DashboardView from '@/views/DashboardView.vue'
import LoginView from '@/views/LoginView.vue' import LoginView from '@/views/LoginView.vue'
import ReactionView from '@/views/ReactionView.vue'
import TicketView from '@/views/TicketView.vue' import TicketView from '@/views/TicketView.vue'
const router = createRouter({ const router = createRouter({
@@ -11,6 +12,11 @@ const router = createRouter({
path: '/', path: '/',
redirect: '/dashboard' redirect: '/dashboard'
}, },
{
path: '/reactions/:id?',
name: 'reactions',
component: ReactionView
},
{ {
path: '/dashboard', path: '/dashboard',
name: 'dashboard', name: 'dashboard',

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import ThreeColumn from '@/components/layout/ThreeColumn.vue'
import ReactionDisplay from '@/components/reaction/ReactionDisplay.vue'
import ReactionList from '@/components/reaction/ReactionList.vue'
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string)
onMounted(() => {
if (!pb.authStore.model) {
router.push({ name: 'login' })
}
})
</script>
<template>
<ThreeColumn>
<template #list>
<ReactionList />
</template>
<template #single>
<div v-if="!id" class="flex h-full w-full items-center justify-center text-lg text-gray-500">
No reaction selected
</div>
<ReactionDisplay v-else :key="id" :id="id" />
</template>
</ThreeColumn>
</template>

15
ui/ui.go Normal file
View File

@@ -0,0 +1,15 @@
package ui
import (
"embed"
"io/fs"
)
//go:embed dist/*
var ui embed.FS
func UI() fs.FS {
fsys, _ := fs.Sub(ui, "dist")
return fsys
}

42
ui/ui_test.go Normal file
View File

@@ -0,0 +1,42 @@
package ui
import (
"io/fs"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUI(t *testing.T) {
tests := []struct {
name string
wantFiles []string
}{
{
name: "TestUI",
wantFiles: []string{
"index.html",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := UI()
var gotFiles []string
require.NoError(t, fs.WalkDir(got, ".", func(path string, d fs.DirEntry, _ error) error {
if !d.IsDir() {
gotFiles = append(gotFiles, path)
}
return nil
}))
for _, wantFile := range tt.wantFiles {
assert.Contains(t, gotFiles, wantFile)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package main package webhook
import ( import (
"bytes" "bytes"
@@ -10,7 +10,6 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
@@ -28,7 +27,7 @@ type Webhook struct {
Destination string `db:"destination" json:"destination"` Destination string `db:"destination" json:"destination"`
} }
func attachWebhooks(app *pocketbase.PocketBase) { func BindHooks(app core.App) {
migrations.Register(func(db dbx.Builder) error { migrations.Register(func(db dbx.Builder) error {
return daos.New(db).SaveCollection(&models.Collection{ return daos.New(db).SaveCollection(&models.Collection{
Name: webhooksCollection, Name: webhooksCollection,
@@ -52,16 +51,7 @@ func attachWebhooks(app *pocketbase.PocketBase) {
}, },
), ),
}) })
}, func(db dbx.Builder) error { }, nil, "1690000000_webhooks.go")
dao := daos.New(db)
id, err := dao.FindCollectionByNameOrId(webhooksCollection)
if err != nil {
return err
}
return dao.DeleteCollection(id)
}, "1690000000_webhooks.go")
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error { app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
return event(app, "create", e.Collection.Name, e.Record, e.HttpContext) return event(app, "create", e.Collection.Name, e.Record, e.HttpContext)
@@ -82,7 +72,7 @@ type Payload struct {
Admin *models.Admin `json:"admin,omitempty"` Admin *models.Admin `json:"admin,omitempty"`
} }
func event(app *pocketbase.PocketBase, action, collection string, record *models.Record, ctx echo.Context) error { func event(app core.App, event, collection string, record *models.Record, ctx echo.Context) error {
auth, _ := ctx.Get(apis.ContextAuthRecordKey).(*models.Record) auth, _ := ctx.Get(apis.ContextAuthRecordKey).(*models.Record)
admin, _ := ctx.Get(apis.ContextAdminKey).(*models.Admin) admin, _ := ctx.Get(apis.ContextAdminKey).(*models.Admin)
@@ -100,7 +90,7 @@ func event(app *pocketbase.PocketBase, action, collection string, record *models
} }
payload, err := json.Marshal(&Payload{ payload, err := json.Marshal(&Payload{
Action: action, Action: event,
Collection: collection, Collection: collection,
Record: record, Record: record,
Auth: auth, Auth: auth,
@@ -112,9 +102,9 @@ func event(app *pocketbase.PocketBase, action, collection string, record *models
for _, webhook := range webhooks { for _, webhook := range webhooks {
if err := sendWebhook(ctx.Request().Context(), webhook, payload); err != nil { if err := sendWebhook(ctx.Request().Context(), webhook, payload); err != nil {
app.Logger().Error("failed to send webhook", "action", action, "name", webhook.Name, "collection", webhook.Collection, "destination", webhook.Destination, "error", err.Error()) app.Logger().Error("failed to send webhook", "action", event, "name", webhook.Name, "collection", webhook.Collection, "destination", webhook.Destination, "error", err.Error())
} else { } else {
app.Logger().Info("webhook sent", "action", action, "name", webhook.Name, "collection", webhook.Collection, "destination", webhook.Destination) app.Logger().Info("webhook sent", "action", event, "name", webhook.Name, "collection", webhook.Collection, "destination", webhook.Destination)
} }
} }