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 ] }
|
||||
|
||||
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:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,3 +47,10 @@ jobs:
|
||||
- run: make build-ui
|
||||
|
||||
- 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:
|
||||
scopes: |
|
||||
deps
|
||||
subjectPattern: "^(?!deps$).+"
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,3 +34,5 @@ dist
|
||||
pb_data
|
||||
catalyst
|
||||
catalyst_data
|
||||
|
||||
coverage.out
|
||||
|
||||
@@ -12,6 +12,7 @@ linters:
|
||||
- nestif
|
||||
|
||||
# disable
|
||||
- bodyclose
|
||||
- depguard
|
||||
- dupl
|
||||
- err113
|
||||
@@ -28,6 +29,8 @@ linters:
|
||||
- lll
|
||||
- makezero
|
||||
- mnd
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
|
||||
13
Makefile
13
Makefile
@@ -1,9 +1,9 @@
|
||||
.PHONY: install
|
||||
install:
|
||||
@echo "Installing..."
|
||||
go install github.com/bombsimon/wsl/v4/cmd...@master
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
go install github.com/daixiang0/gci@latest
|
||||
go install github.com/bombsimon/wsl/v4/cmd...@v4.4.1
|
||||
go install mvdan.cc/gofumpt@v0.6.0
|
||||
go install github.com/daixiang0/gci@v0.13.4
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
@@ -26,6 +26,13 @@ test:
|
||||
go test -v ./...
|
||||
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
|
||||
build-ui:
|
||||
@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 (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"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 {
|
||||
return func(e *core.ServeEvent) error {
|
||||
e.Router.GET("/", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/ui/")
|
||||
})
|
||||
e.Router.GET("/ui/*", staticFiles())
|
||||
|
||||
e.Router.GET("/api/config", func(c echo.Context) error {
|
||||
flags, err := flags(e.App)
|
||||
flags, err := Flags(e.App)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -55,8 +47,6 @@ func staticFiles() func(echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fsys, _ := fs.Sub(ui, "ui/dist")
|
||||
|
||||
return apis.StaticDirectoryHandler(fsys, true)(c)
|
||||
return apis.StaticDirectoryHandler(ui.UI(), 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"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v7"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
minimumTicketCount = 1
|
||||
)
|
||||
|
||||
func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error {
|
||||
func Generate(app core.App, userCount, ticketCount int) error {
|
||||
if userCount < minimumUserCount {
|
||||
userCount = minimumUserCount
|
||||
}
|
||||
@@ -28,24 +28,39 @@ func Generate(app *pocketbase.PocketBase, userCount, ticketCount int) error {
|
||||
ticketCount = minimumTicketCount
|
||||
}
|
||||
|
||||
types, err := app.Dao().FindRecordsByExpr(migrations.TypeCollectionName)
|
||||
records, err := Records(app, userCount, ticketCount)
|
||||
if err != nil {
|
||||
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)
|
||||
tickets := ticketRecords(app.Dao(), users, types, ticketCount)
|
||||
webhooks := webhookRecords(app.Dao())
|
||||
reactions := reactionRecords(app.Dao())
|
||||
|
||||
for _, records := range [][]*models.Record{users, tickets, webhooks} {
|
||||
for _, record := range records {
|
||||
if err := app.Dao().SaveRecord(record); err != nil {
|
||||
app.Logger().Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
var records []*models.Record
|
||||
records = append(records, users...)
|
||||
records = append(records, types...)
|
||||
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 {
|
||||
@@ -222,3 +237,41 @@ func webhookRecords(dao *daos.Dao) []*models.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/pocketbase v0.22.14
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
)
|
||||
|
||||
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/sts v1.28.13 // indirect
|
||||
github.com/aws/smithy-go v1.20.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -56,11 +58,11 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
@@ -83,6 +85,7 @@ require (
|
||||
google.golang.org/grpc v1.64.1 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
|
||||
modernc.org/libc v1.53.3 // 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.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
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/yaml.v2 v2.2.2/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 (
|
||||
"log"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/migrations"
|
||||
"github.com/SecurityBrewery/catalyst/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
if err := app.App("./catalyst_data").Start(); err != nil {
|
||||
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 (
|
||||
TimelineCollectionName = "timeline"
|
||||
CommentCollectionName = "comments"
|
||||
fileCollectionName = "files"
|
||||
FeatureCollectionName = "features"
|
||||
LinkCollectionName = "links"
|
||||
TaskCollectionName = "tasks"
|
||||
TicketCollectionName = "tickets"
|
||||
TimelineCollectionName = "timeline"
|
||||
TypeCollectionName = "types"
|
||||
WebhookCollectionName = "webhooks"
|
||||
FeatureCollectionName = "features"
|
||||
fileCollectionName = "files"
|
||||
|
||||
UserCollectionName = "_pb_users_auth_"
|
||||
)
|
||||
@@ -138,14 +138,14 @@ func internalCollection(c *models.Collection) *models.Collection {
|
||||
|
||||
func collectionsDown(db dbx.Builder) error {
|
||||
collections := []string{
|
||||
TicketCollectionName,
|
||||
TypeCollectionName,
|
||||
fileCollectionName,
|
||||
LinkCollectionName,
|
||||
TaskCollectionName,
|
||||
CommentCollectionName,
|
||||
TimelineCollectionName,
|
||||
FeatureCollectionName,
|
||||
TicketCollectionName,
|
||||
TypeCollectionName,
|
||||
}
|
||||
|
||||
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(defaultDataUp, nil, "1700000003_defaultdata.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>
|
||||
|
||||
<DialogFooter class="mt-2">
|
||||
<DialogFooter class="mt-2 sm:justify-start">
|
||||
<Button type="button" variant="destructive" @click="deleteMutation.mutate"> Delete </Button>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant="destructive" @click="deleteMutation.mutate"> Delete </Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
</div>
|
||||
<NavList
|
||||
class="mt-auto"
|
||||
:is-collapsed="catalystStore.sidebarCollapsed"
|
||||
:links="[
|
||||
{
|
||||
@@ -43,10 +42,20 @@ const catalystStore = useCatalystStore()
|
||||
<Separator />
|
||||
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<Separator />
|
||||
<NavList
|
||||
:is-collapsed="catalystStore.sidebarCollapsed"
|
||||
:links="[
|
||||
{
|
||||
title: 'Reactions',
|
||||
icon: 'Zap',
|
||||
variant: 'ghost',
|
||||
to: '/reactions'
|
||||
}
|
||||
]"
|
||||
/>
|
||||
<Separator />
|
||||
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
|
||||
<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" />
|
||||
|
||||
<DialogFooter class="mt-2">
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary"> Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogFooter class="mt-2 sm:justify-start">
|
||||
<Button type="button" variant="default" @click="closeTicketMutation.mutate()">
|
||||
Close
|
||||
</Button>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -153,7 +153,7 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<TooltipProvider>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Info class="ml-2 size-4 text-muted-foreground" />
|
||||
|
||||
@@ -139,11 +139,11 @@ watch(isOpen, () => {
|
||||
|
||||
<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>
|
||||
<Button type="button" variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit"> Save </Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -25,7 +25,7 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
<Button v-if="!hideAdd" variant="ghost" size="icon" class="h-8 w-8" @click="emit('add')">
|
||||
<Plus class="size-4" />
|
||||
<span class="sr-only">Add link</span>
|
||||
<span class="sr-only">Add item</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Card v-if="$slots.default" class="p-0">
|
||||
|
||||
@@ -99,11 +99,11 @@ const save = () => editCommentMutation.mutate()
|
||||
<DialogTitle>Delete comment</DialogTitle>
|
||||
<DialogDescription> Are you sure you want to delete this comment?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogFooter class="sm:justify-start">
|
||||
<Button @click="deleteCommentMutation.mutate" variant="destructive">Delete</Button>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button @click="deleteCommentMutation.mutate" variant="destructive"> Delete </Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -65,11 +65,11 @@ function handleFileUpload($event: Event) {
|
||||
|
||||
<Input id="file" type="file" class="mt-2" @change="handleFileUpload($event)" />
|
||||
|
||||
<DialogFooter class="mt-2">
|
||||
<DialogFooter class="mt-2 sm:justify-start">
|
||||
<Button @click="save">Upload</Button>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button @click="save">Upload</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -90,10 +90,7 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<DialogFooter class="mt-2">
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary"> Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogFooter class="mt-2 sm:justify-start">
|
||||
<Button
|
||||
:title="submitDisabled ? 'Please fill out all required fields' : undefined"
|
||||
:disabled="submitDisabled"
|
||||
@@ -101,6 +98,9 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary"> Cancel</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -7,7 +7,6 @@ import TaskAddDialog from '@/components/ticket/task/TaskAddDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
import { Trash2, User2 } from 'lucide-vue-next'
|
||||
|
||||
|
||||
@@ -120,13 +120,13 @@ const save = () =>
|
||||
Are you sure you want to delete this timeline item?</DialogDescription
|
||||
>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary"> Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogFooter class="sm:justify-start">
|
||||
<Button @click="deleteTimelineItemMutation.mutate" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary"> Cancel</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
}
|
||||
|
||||
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 LoginView from '@/views/LoginView.vue'
|
||||
import ReactionView from '@/views/ReactionView.vue'
|
||||
import TicketView from '@/views/TicketView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -11,6 +12,11 @@ const router = createRouter({
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/reactions/:id?',
|
||||
name: 'reactions',
|
||||
component: ReactionView
|
||||
},
|
||||
{
|
||||
path: '/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 (
|
||||
"bytes"
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@@ -28,7 +27,7 @@ type Webhook struct {
|
||||
Destination string `db:"destination" json:"destination"`
|
||||
}
|
||||
|
||||
func attachWebhooks(app *pocketbase.PocketBase) {
|
||||
func BindHooks(app core.App) {
|
||||
migrations.Register(func(db dbx.Builder) error {
|
||||
return daos.New(db).SaveCollection(&models.Collection{
|
||||
Name: webhooksCollection,
|
||||
@@ -52,16 +51,7 @@ func attachWebhooks(app *pocketbase.PocketBase) {
|
||||
},
|
||||
),
|
||||
})
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
id, err := dao.FindCollectionByNameOrId(webhooksCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.DeleteCollection(id)
|
||||
}, "1690000000_webhooks.go")
|
||||
}, nil, "1690000000_webhooks.go")
|
||||
|
||||
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
|
||||
return event(app, "create", e.Collection.Name, e.Record, e.HttpContext)
|
||||
@@ -82,7 +72,7 @@ type Payload struct {
|
||||
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)
|
||||
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{
|
||||
Action: action,
|
||||
Action: event,
|
||||
Collection: collection,
|
||||
Record: record,
|
||||
Auth: auth,
|
||||
@@ -112,9 +102,9 @@ func event(app *pocketbase.PocketBase, action, collection string, record *models
|
||||
|
||||
for _, webhook := range webhooks {
|
||||
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 {
|
||||
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