mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-06 15:22:47 +01:00
feat: add reactions (#1074)
This commit is contained in:
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -34,3 +34,5 @@ dist
|
|||||||
pb_data
|
pb_data
|
||||||
catalyst
|
catalyst
|
||||||
catalyst_data
|
catalyst_data
|
||||||
|
|
||||||
|
coverage.out
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
Makefile
13
Makefile
@@ -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
44
app/app.go
Normal 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
25
app/bootstrap.go
Normal 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
27
app/fakedata.go
Normal 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
80
app/flags.go
Normal 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
41
app/flags_test.go
Normal 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
72
app/migrate.go
Normal 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
21
app/migrate_test.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
78
cmd.go
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
29
fakedata/records_test.go
Normal 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
48
fakedata/text_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
22
flags.go
22
flags.go
@@ -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
5
go.mod
@@ -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
1
go.sum
@@ -330,6 +330,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
|
|||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.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
26
main.go
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
40
migrations/5_reactions.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
44
reaction/action/action.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
reaction/action/python/python.go
Normal file
105
reaction/action/python/python.go
Normal 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")
|
||||||
|
}
|
||||||
20
reaction/action/webhook/payload.go
Normal file
20
reaction/action/webhook/payload.go
Normal 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
|
||||||
|
}
|
||||||
49
reaction/action/webhook/payload_test.go
Normal file
49
reaction/action/webhook/payload_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
12
reaction/action/webhook/response.go
Normal file
12
reaction/action/webhook/response.go
Normal 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"`
|
||||||
|
}
|
||||||
39
reaction/action/webhook/webhook.go
Normal file
39
reaction/action/webhook/webhook.go
Normal 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
13
reaction/trigger.go
Normal 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)
|
||||||
|
}
|
||||||
103
reaction/trigger/hook/hook.go
Normal file
103
reaction/trigger/hook/hook.go
Normal 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
|
||||||
|
}
|
||||||
23
reaction/trigger/webhook/request.go
Normal file
23
reaction/trigger/webhook/request.go
Normal 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
|
||||||
|
}
|
||||||
37
reaction/trigger/webhook/request_test.go
Normal file
37
reaction/trigger/webhook/request_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
146
reaction/trigger/webhook/webhook.go
Normal file
146
reaction/trigger/webhook/webhook.go
Normal 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))
|
||||||
|
}
|
||||||
250
testing/collection_reaction_test.go
Normal file
250
testing/collection_reaction_test.go
Normal 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
177
testing/reaction_test.go
Normal 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
79
testing/routes_test.go
Normal 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
103
testing/testdata.go
Normal 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
172
testing/testing.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
49
ui/src/components/form/GrowTextarea.vue
Normal file
49
ui/src/components/form/GrowTextarea.vue
Normal 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>
|
||||||
70
ui/src/components/form/ListInput.vue
Normal file
70
ui/src/components/form/ListInput.vue
Normal 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>
|
||||||
98
ui/src/components/form/MultiSelect.vue
Normal file
98
ui/src/components/form/MultiSelect.vue
Normal 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>
|
||||||
75
ui/src/components/form/TextInput.vue
Normal file
75
ui/src/components/form/TextInput.vue
Normal 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>
|
||||||
@@ -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 />
|
||||||
|
|||||||
36
ui/src/components/reaction/ActionPythonFormFields.vue
Normal file
36
ui/src/components/reaction/ActionPythonFormFields.vue
Normal 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>
|
||||||
40
ui/src/components/reaction/ActionWebhookFormFields.vue
Normal file
40
ui/src/components/reaction/ActionWebhookFormFields.vue
Normal 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>
|
||||||
62
ui/src/components/reaction/ReactionDisplay.vue
Normal file
62
ui/src/components/reaction/ReactionDisplay.vue
Normal 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>
|
||||||
318
ui/src/components/reaction/ReactionForm.vue
Normal file
318
ui/src/components/reaction/ReactionForm.vue
Normal 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>
|
||||||
81
ui/src/components/reaction/ReactionList.vue
Normal file
81
ui/src/components/reaction/ReactionList.vue
Normal 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>
|
||||||
63
ui/src/components/reaction/ReactionNewDialog.vue
Normal file
63
ui/src/components/reaction/ReactionNewDialog.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
36
ui/src/components/reaction/TriggerHookFormFieldEvents.vue
Normal file
36
ui/src/components/reaction/TriggerHookFormFieldEvents.vue
Normal 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>
|
||||||
36
ui/src/components/reaction/TriggerHookFormFields.vue
Normal file
36
ui/src/components/reaction/TriggerHookFormFields.vue
Normal 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>
|
||||||
47
ui/src/components/reaction/TriggerWebhookFormFields.vue
Normal file
47
ui/src/components/reaction/TriggerWebhookFormFields.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
36
ui/src/components/ui/tags-input/TagsInput.vue
Normal file
36
ui/src/components/ui/tags-input/TagsInput.vue
Normal 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>
|
||||||
23
ui/src/components/ui/tags-input/TagsInputInput.vue
Normal file
23
ui/src/components/ui/tags-input/TagsInputInput.vue
Normal 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>
|
||||||
30
ui/src/components/ui/tags-input/TagsInputItem.vue
Normal file
30
ui/src/components/ui/tags-input/TagsInputItem.vue
Normal 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>
|
||||||
29
ui/src/components/ui/tags-input/TagsInputItemDelete.vue
Normal file
29
ui/src/components/ui/tags-input/TagsInputItemDelete.vue
Normal 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>
|
||||||
23
ui/src/components/ui/tags-input/TagsInputItemText.vue
Normal file
23
ui/src/components/ui/tags-input/TagsInputItemText.vue
Normal 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>
|
||||||
5
ui/src/components/ui/tags-input/index.ts
Normal file
5
ui/src/components/ui/tags-input/index.ts
Normal 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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
35
ui/src/views/ReactionView.vue
Normal file
35
ui/src/views/ReactionView.vue
Normal 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
15
ui/ui.go
Normal 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
42
ui/ui_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user