Compare commits

..

33 Commits

Author SHA1 Message Date
Jonas Plum
97d0cd3428 fix: goreleaser docker (#1113) 2024-11-06 02:02:47 +01:00
Jonas Plum
baba5b7a45 feat: docker entrypoint with environment variables (#1112) 2024-11-06 01:52:48 +01:00
Jonas Plum
d1cf75ab79 refactor: subcommands (#1111) 2024-11-06 01:21:31 +01:00
Jonas Plum
38a89f2c94 fix: docker latest image (#1110) 2024-11-04 23:08:28 +01:00
Jonas Plum
8c36ea5243 feat: scheduler example (#1109) 2024-11-04 23:07:17 +01:00
Jonas Plum
a2bdeecb0d feat: scheduler (#1108) 2024-11-04 22:30:20 +01:00
Jonas Plum
42797509f7 fix: set-app-url (#1107) 2024-11-04 20:50:18 +00:00
Jonas Plum
70ba16a6bd feat: docker healthcheck (#1106) 2024-11-04 20:47:55 +00:00
Jonas Plum
f42de34780 fix: ci docker tags 2024-09-30 03:55:39 +02:00
Jonas Plum
88f56a2bdb fix: ci docker login 2024-09-30 03:45:24 +02:00
Jonas Plum
88cc02b350 fix: goreleaser ci permissions (#1105) 2024-09-30 03:30:29 +02:00
Jonas Plum
46f7815699 feat: docker files (#1104) 2024-09-30 03:20:26 +02:00
Jonas Plum
ea03a3ed23 fix: prevent view update (#1102) 2024-09-20 00:02:15 +02:00
Jonas Plum
6346140de5 fix: multiple hooks (#1101) 2024-09-19 23:23:45 +02:00
Jonas Plum
d7bdf1d276 fix: curl example (#1099) 2024-08-13 08:09:46 +02:00
Jonas Plum
1e1022ab15 fix: reaction names (#1098) 2024-08-13 07:44:06 +02:00
Jonas Plum
a2dd6c05e6 feat: mobile ui (#1096) 2024-08-07 22:18:59 +02:00
Jonas Plum
96b7a9604c fix: multi select state handling (#1094) 2024-08-05 15:22:01 +02:00
Jonas Plum
21f1c3d328 feat: reset password (#1092) 2024-08-03 16:26:09 +02:00
Jonas Plum
84ae933cfb perf: search (#1091) 2024-08-03 14:58:55 +02:00
Jonas Plum
b929100d30 feat: enum custom field (#1090) 2024-08-03 13:43:41 +02:00
Jonas Plum
aba3dfaaa4 docs: add reaction docs (#1086) 2024-07-21 10:23:25 +02:00
Jonas Plum
c491f4e810 fix: search bar (#1088) 2024-07-21 10:09:55 +02:00
Jonas Plum
4db718660a feat: provide app url (#1087) 2024-07-21 09:24:06 +02:00
Jonas Plum
83251af565 fix: python PATH order (#1085) 2024-07-21 04:21:32 +02:00
Jonas Plum
a9e885598c feat: demo flags (#1084) 2024-07-21 04:07:23 +02:00
Jonas Plum
91429effe2 feat: improve python actions (#1083) 2024-07-21 02:56:43 +02:00
Jonas Plum
81bfbb2072 fix: redirect to login (#1082) 2024-07-20 06:51:14 +00:00
Jonas Plum
e9583a29fa test: reactions (#1081) 2024-07-20 07:50:42 +02:00
Jonas Plum
e2c8f1d223 feat: add reactions (#1074) 2024-07-20 06:39:02 +02:00
Jonas Plum
82ad50d228 refactor: create TicketDeleteDialog (#1079) 2024-07-12 21:09:48 +02:00
Jonas Plum
00b7ab585c chore: extract handleError function (#1078) 2024-07-12 18:52:00 +00:00
dependabot[bot]
a700791f43 build(deps): bump google.golang.org/grpc from 1.64.0 to 1.64.1 (#1077)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-10 09:22:11 +02:00
177 changed files with 5709 additions and 2250 deletions

11
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
coverage:
status:
project:
default:
threshold: 5%
patch: off
comment:
layout: diff
parsers:
go:
partials_as_hits: true

View File

@@ -5,9 +5,44 @@ 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: |
bun install
mkdir -p dist
touch dist/index.html
working-directory: ui
- run: make install
- run: make fmt
- run: git diff --exit-code
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- run: |
mkdir -p ui/dist
touch ui/dist/index.html
- uses: golangci/golangci-lint-action@v6
with: { version: 'v1.59' }
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -16,9 +51,6 @@ jobs:
- run: make build-ui
- uses: golangci/golangci-lint-action@v6
with: { version: 'v1.59' }
test:
name: Test
runs-on: ubuntu-latest
@@ -28,6 +60,13 @@ jobs:
with: { go-version: '1.22' }
- uses: oven-sh/setup-bun@v1
- run: make build-ui
- run: |
mkdir -p ui/dist
touch ui/dist/index.html
- run: make test
- run: make test-coverage
- uses: codecov/codecov-action@v4
with:
files: ./coverage.out
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -7,6 +7,8 @@ on:
permissions:
contents: write
id-token: write
packages: write
jobs:
goreleaser:
@@ -21,6 +23,11 @@ jobs:
- run: make build-ui
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: "securitybrewery"
password: ${{ secrets.GITHUB_TOKEN }}
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser

View File

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

2
.gitignore vendored
View File

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

View File

@@ -5,36 +5,20 @@ linters:
enable-all: true
disable:
# complexity
- cyclop
- gocognit
- gocyclo
- maintidx
- nestif
- funlen
# disable
- depguard
- dupl
- err113
- execinquery
- exhaustruct
- funlen
- gochecknoglobals
- gochecknoinits
- goconst
- godox
- gomnd
- gomoddirectives
- ireturn
- lll
- makezero
- mnd
- prealloc
- tagalign
- tagliatelle
- testpackage
- varnamelen
- wrapcheck
- wsl
linters-settings:
gci:
sections:

View File

@@ -11,6 +11,16 @@ builds:
- linux
- darwin
dockers:
- ids: [ catalyst ]
dockerfile: docker/Dockerfile
image_templates:
- "ghcr.io/securitybrewery/catalyst:main"
- "{{if not .Prerelease}}ghcr.io/securitybrewery/catalyst:latest{{end}}"
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
extra_files:
- docker/entrypoint.sh
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.

View File

@@ -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,22 +26,51 @@ test:
go test -v ./...
cd ui && bun test
.PHONY: test-coverage
test-coverage:
@echo "Testing with coverage..."
go test -coverpkg=./... -coverprofile=coverage.out -count 1 ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out
.PHONY: build-ui
build-ui:
@echo "Building..."
cd ui && bun install
cd ui && bun build-only
.PHONY: build
build: build-ui
@echo "Building..."
go build -o catalyst .
.PHONY: build-linux
build-linux: build-ui
@echo "Building..."
GOOS=linux GOARCH=amd64 go build -o catalyst .
.PHONY: docker
docker: build-linux
@echo "Building Docker image..."
docker build -f docker/Dockerfile -t catalyst .
.PHONY: dev
dev:
@echo "Running..."
rm -rf catalyst_data
go run . bootstrap
go run . admin create admin@catalyst-soar.com 1234567890
go run . set-feature-flags dev
go run . fake-data
go run . serve
go run . serve --app-url http://localhost:8090 --flags dev
.PHONY: dev-ui
.PHONY: dev-10000
dev-10000:
@echo "Running..."
rm -rf catalyst_data
go run . admin create admin@catalyst-soar.com 1234567890
go run . fake-data --users 100 --tickets 10000
go run . serve --app-url http://localhost:8090 --flags dev
.PHONY: serve-ui
serve-ui:
cd ui && bun dev --port 3000

View File

@@ -26,59 +26,62 @@ They represent alerts, incidents, forensics investigations,
threat hunts or any other event you want to handle in your organisation.
<center>
<a href="docs/screenshots/ticket.png">
<img alt="Screenshot of a ticket" src="docs/screenshots/ticket.png" />
<a href="/docs/screenshots/ticket.png">
<img alt="Screenshot of a ticket" src="/docs/screenshots/ticket.png" />
</a>
</center>
### Ticket Types
Templates define the custom information for tickets.
The core information for tickets like title, creation date or closing status is kept quite minimal
and other information like criticality, description or MITRE ATT&CK information can be added individually.
### Timelines
Timelines are used to document the progress of an investigation.
They can be used to document the steps taken during an investigation, the findings or the results of the investigation.
### Tasks
Tasks are the smallest unit of work in Catalyst. They can be assigned to users and have a status.
Tasks can be used to document the progress of an investigation or to assign work to different users.
<center>
<a href="docs/screenshots/tasks.png">
<img alt="Screenshot of the tasks part of a ticket" src="docs/screenshots/tasks.png" />
<a href="/docs/screenshots/tasks.png">
<img alt="Screenshot of the tasks part of a ticket" src="/docs/screenshots/tasks.png" />
</a>
</center>
### Reactions
Reactions are a way to automate Catalyst.
Each reaction is composed of a trigger and an action.
The trigger listens for events and the action is executed when the trigger is activated.
There are triggers for HTTP/Webhooks and Collection Hooks and actions for Python and HTTP/Webhooks.
<center>
<a href="/docs/screenshots/reactions.png">
<img alt="Screenshot of the reactions" src="/docs/screenshots/reactions.png" />
</a>
</center>
### Timelines
Timelines are used to document the progress of an investigation.
They can be used to document the steps taken during an investigation, the findings or the results of the investigation.
### Dashboards
Catalyst comes with a dashboard that presents the most important information at a glance.
<center>
<a href="/docs/screenshots/dashboard.png">
<img alt="Screenshot of the dashboard" src="/docs/screenshots/dashboard.png" />
</a>
</center>
### Ticket Types
Templates define the custom information for tickets.
The core information for tickets like title, creation date or closing status is kept quite minimal
and other information like criticality, description or MITRE ATT&CK information can be added individually.
### Custom Fields
Custom fields can be added to tickets to store additional information.
They can be used to store information like the affected system, the attacker's IP address or the type of malware.
Custom fields can be added to ticket types and are then available for all tickets of this type.
### Dashboards
Catalyst comes with a dashboard that presents the most important information at a glance.
<center>
<a href="docs/screenshots/dashboard.png">
<img alt="Screenshot of the dashboard" src="docs/screenshots/dashboard.png" />
</a>
</center>
### Webhooks
Catalyst can send webhooks to other systems.
This can be used to trigger actions in other systems and create automated workflows.
### Users
Catalyst supports authentication via username and password
or via OAuth2 with an external identity provider like Google, GitHub or GitLab.
### More
Catalyst supports a lot more features like: Links, Files, or Comments on tickets.

85
app/app.go Normal file
View File

@@ -0,0 +1,85 @@
package app
import (
"fmt"
"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() { //nolint:gochecknoinits
migrations.Register()
}
func App(dir string, test bool) (*pocketbase.PocketBase, error) {
app := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDev: test || dev(),
DefaultDataDir: dir,
})
var appURL string
app.RootCmd.PersistentFlags().StringVar(&appURL, "app-url", "", "the app's URL")
var flags []string
app.RootCmd.PersistentFlags().StringSliceVar(&flags, "flags", nil, "feature flags")
_ = app.RootCmd.ParseFlags(os.Args[1:])
app.RootCmd.AddCommand(fakeDataCmd(app))
webhook.BindHooks(app)
reaction.BindHooks(app, test)
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
if err := MigrateDBs(e.App); err != nil {
return err
}
if err := SetFlags(e.App, flags); err != nil {
return err
}
if HasFlag(e.App, "demo") {
bindDemoHooks(e.App)
}
if appURL != "" {
s := e.App.Settings()
s.Meta.AppUrl = appURL
if err := e.App.Dao().SaveSettings(s); err != nil {
return err
}
}
return e.App.RefreshSettings()
})
app.OnBeforeServe().Add(addRoutes())
return app, nil
}
func bindDemoHooks(app core.App) {
app.OnRecordBeforeCreateRequest("files", "reactions").Add(func(e *core.RecordCreateEvent) error {
return fmt.Errorf("cannot create %s in demo mode", e.Record.Collection().Name)
})
app.OnRecordBeforeUpdateRequest("files", "reactions").Add(func(e *core.RecordUpdateEvent) error {
return fmt.Errorf("cannot update %s in demo mode", e.Record.Collection().Name)
})
app.OnRecordBeforeDeleteRequest("files", "reactions").Add(func(e *core.RecordDeleteEvent) error {
return fmt.Errorf("cannot delete %s in demo mode", e.Record.Collection().Name)
})
}
func dev() bool {
return strings.HasPrefix(os.Args[0], os.TempDir())
}

25
app/fakedata.go Normal file
View File

@@ -0,0 +1,25 @@
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",
RunE: func(_ *cobra.Command, _ []string) error {
return fakedata.Generate(app, userCount, ticketCount)
},
}
cmd.PersistentFlags().IntVar(&userCount, "users", 10, "Number of users to generate")
cmd.PersistentFlags().IntVar(&ticketCount, "tickets", 100, "Number of tickets to generate")
return cmd
}

86
app/flags.go Normal file
View File

@@ -0,0 +1,86 @@
package app
import (
"slices"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/SecurityBrewery/catalyst/migrations"
)
func HasFlag(app core.App, flag string) bool {
records, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName, dbx.HashExp{"name": flag})
if err != nil {
app.Logger().Error(err.Error())
return false
}
for _, r := range records {
if r.GetString("name") == flag {
return true
}
}
return false
}
func Flags(app core.App) ([]string, error) {
records, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName)
if err != nil {
return nil, err
}
flags := make([]string, 0, len(records))
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 //nolint:prealloc
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
}

69
app/flags_test.go Normal file
View File

@@ -0,0 +1,69 @@
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 TestHasFlag(t *testing.T) {
t.Parallel()
catalystApp, _, cleanup := catalystTesting.App(t)
defer cleanup()
// stage 1
assert.False(t, app.HasFlag(catalystApp, "test"))
// stage 2
require.NoError(t, app.SetFlags(catalystApp, []string{"test"}))
assert.True(t, app.HasFlag(catalystApp, "test"))
}
func Test_flags(t *testing.T) {
t.Parallel()
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) {
t.Parallel()
catalystApp, _, cleanup := catalystTesting.App(t)
defer cleanup()
// stage 1
require.NoError(t, app.SetFlags(catalystApp, []string{"test"}))
got, err := app.Flags(catalystApp)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"test"}, got)
// stage 2
require.NoError(t, app.SetFlags(catalystApp, []string{"test2"}))
got, err = app.Flags(catalystApp)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"test2"}, got)
// stage 3
require.NoError(t, app.SetFlags(catalystApp, []string{"test", "test2"}))
got, err = app.Flags(catalystApp)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"test", "test2"}, got)
}

72
app/migrate.go Normal file
View File

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

View File

@@ -0,0 +1,39 @@
package app
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_isIgnored(t *testing.T) {
t.Parallel()
type args struct {
err error
}
tests := []struct {
name string
args args
want bool
}{
{
name: "error is ignored",
args: args{err: errors.New("1673167670_multi_match_migrate")},
want: true,
},
{
name: "error is not ignored",
args: args{err: errors.New("1673167670_multi_match")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equalf(t, tt.want, isIgnored(tt.args.err), "isIgnored(%v)", tt.args.err)
})
}
}

23
app/migrate_test.go Normal file
View File

@@ -0,0 +1,23 @@
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) {
t.Parallel()
catalystApp, _, cleanup := catalystTesting.App(t)
defer cleanup()
_, err := catalystApp.Dao().FindCollectionByNameOrId(migrations.ReactionCollectionName)
require.NoError(t, err)
require.NoError(t, app.MigrateDBsDown(catalystApp))
}

View File

@@ -1,34 +1,33 @@
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("/health", func(c echo.Context) error {
if _, err := Flags(e.App); err != nil {
return err
}
return c.String(http.StatusOK, "OK")
})
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
}
@@ -46,17 +45,14 @@ func staticFiles() func(echo.Context) error {
return func(c echo.Context) error {
if dev() {
u, _ := url.Parse("http://localhost:3000/")
proxy := httputil.NewSingleHostReverseProxy(u)
c.Request().Host = c.Request().URL.Host
proxy.ServeHTTP(c.Response(), c.Request())
httputil.NewSingleHostReverseProxy(u).ServeHTTP(c.Response(), c.Request())
return nil
}
fsys, _ := fs.Sub(ui, "ui/dist")
return apis.StaticDirectoryHandler(fsys, true)(c)
return apis.StaticDirectoryHandler(ui.UI(), true)(c)
}
}

21
app/routes_test.go Normal file
View File

@@ -0,0 +1,21 @@
package app
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/require"
)
func Test_staticFiles(t *testing.T) {
t.Parallel()
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
require.NoError(t, staticFiles()(c))
}

View File

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

78
cmd.go
View File

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

16
docker/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl python3 python3-pip python3-venv
COPY catalyst /usr/local/bin/catalyst
EXPOSE 8080
VOLUME /usr/local/bin/catalyst_data
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
COPY docker/entrypoint.sh /entrypoint.sh
CMD ["/entrypoint.sh"]

15
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Set the flags
FLAGS=""
if [ -n "$CATALYST_FLAGS" ]; then
FLAGS="$CATALYST_FLAGS"
fi
# Set the app url
APP_URL=""
if [ -n "$CATALYST_APP_URL" ]; then
APP_URL="$CATALYST_APP_URL"
fi
/usr/local/bin/catalyst serve --http 0.0.0.0:8080 --flags "$FLAGS" --app-url "$APP_URL"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 KiB

After

Width:  |  Height:  |  Size: 286 KiB

View File

@@ -1,12 +1,13 @@
package fakedata
import (
"encoding/json"
"fmt"
"strings"
"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 +20,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,33 +29,46 @@ 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
}
users := userRecords(app.Dao(), userCount)
tickets := ticketRecords(app.Dao(), users, types, ticketCount)
webhooks := webhookRecords(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())
}
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)
reactions := reactionRecords(app.Dao())
var records []*models.Record
records = append(records, users...)
records = append(records, types...)
records = append(records, tickets...)
records = append(records, reactions...)
return records, nil
}
func userRecords(dao *daos.Dao, count int) []*models.Record {
collection, err := dao.FindCollectionByNameOrId(migrations.UserCollectionName)
if err != nil {
panic(err)
}
var records []*models.Record
records := make([]*models.Record, 0, count)
// create the test user
if _, err := dao.FindRecordById(migrations.UserCollectionName, "u_test"); err != nil {
@@ -90,7 +104,7 @@ func ticketRecords(dao *daos.Dao, users, types []*models.Record, count int) []*m
panic(err)
}
var records []*models.Record
records := make([]*models.Record, 0, count)
created := time.Now()
number := gofakeit.Number(200*count, 300*count)
@@ -114,111 +128,260 @@ func ticketRecords(dao *daos.Dao, users, types []*models.Record, count int) []*m
record.Set("description", fakeTicketDescription())
record.Set("open", gofakeit.Bool())
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`)
record.Set("state", `{"tlp":"AMBER"}`)
record.Set("state", `{"severity":"Medium"}`)
record.Set("owner", random(users).GetId())
records = append(records, record)
// Add comments
for range gofakeit.IntN(5) {
commentCollection, err := dao.FindCollectionByNameOrId(migrations.CommentCollectionName)
if err != nil {
panic(err)
}
commentCreated := gofakeit.DateRange(created, time.Now())
commentUpdated := gofakeit.DateRange(commentCreated, time.Now())
commentRecord := models.NewRecord(commentCollection)
commentRecord.SetId("c_" + security.PseudorandomString(10))
commentRecord.Set("created", commentCreated.Format("2006-01-02T15:04:05Z"))
commentRecord.Set("updated", commentUpdated.Format("2006-01-02T15:04:05Z"))
commentRecord.Set("ticket", record.GetId())
commentRecord.Set("author", random(users).GetId())
commentRecord.Set("message", fakeTicketComment())
records = append(records, commentRecord)
}
// Add timeline
for range gofakeit.IntN(5) {
timelineCollection, err := dao.FindCollectionByNameOrId(migrations.TimelineCollectionName)
if err != nil {
panic(err)
}
timelineCreated := gofakeit.DateRange(created, time.Now())
timelineUpdated := gofakeit.DateRange(timelineCreated, time.Now())
timelineRecord := models.NewRecord(timelineCollection)
timelineRecord.SetId("tl_" + security.PseudorandomString(10))
timelineRecord.Set("created", timelineCreated.Format("2006-01-02T15:04:05Z"))
timelineRecord.Set("updated", timelineUpdated.Format("2006-01-02T15:04:05Z"))
timelineRecord.Set("ticket", record.GetId())
timelineRecord.Set("time", gofakeit.DateRange(created, time.Now()).Format("2006-01-02T15:04:05Z"))
timelineRecord.Set("message", fakeTicketTimelineMessage())
records = append(records, timelineRecord)
}
// Add tasks
for range gofakeit.IntN(5) {
taskCollection, err := dao.FindCollectionByNameOrId(migrations.TaskCollectionName)
if err != nil {
panic(err)
}
taskCreated := gofakeit.DateRange(created, time.Now())
taskUpdated := gofakeit.DateRange(taskCreated, time.Now())
taskRecord := models.NewRecord(taskCollection)
taskRecord.SetId("ts_" + security.PseudorandomString(10))
taskRecord.Set("created", taskCreated.Format("2006-01-02T15:04:05Z"))
taskRecord.Set("updated", taskUpdated.Format("2006-01-02T15:04:05Z"))
taskRecord.Set("ticket", record.GetId())
taskRecord.Set("name", fakeTicketTask())
taskRecord.Set("open", gofakeit.Bool())
taskRecord.Set("owner", random(users).GetId())
records = append(records, taskRecord)
}
// Add links
for range gofakeit.IntN(5) {
linkCollection, err := dao.FindCollectionByNameOrId(migrations.LinkCollectionName)
if err != nil {
panic(err)
}
linkCreated := gofakeit.DateRange(created, time.Now())
linkUpdated := gofakeit.DateRange(linkCreated, time.Now())
linkRecord := models.NewRecord(linkCollection)
linkRecord.SetId("l_" + security.PseudorandomString(10))
linkRecord.Set("created", linkCreated.Format("2006-01-02T15:04:05Z"))
linkRecord.Set("updated", linkUpdated.Format("2006-01-02T15:04:05Z"))
linkRecord.Set("ticket", record.GetId())
linkRecord.Set("url", gofakeit.URL())
linkRecord.Set("name", random([]string{"Blog", "Forum", "Wiki", "Documentation"}))
records = append(records, linkRecord)
}
records = append(records, commentRecords(dao, users, created, record)...)
records = append(records, timelineRecords(dao, created, record)...)
records = append(records, taskRecords(dao, users, created, record)...)
records = append(records, linkRecords(dao, created, record)...)
}
return records
}
func webhookRecords(dao *daos.Dao) []*models.Record {
collection, err := dao.FindCollectionByNameOrId(migrations.WebhookCollectionName)
func commentRecords(dao *daos.Dao, users []*models.Record, created time.Time, record *models.Record) []*models.Record {
commentCollection, err := dao.FindCollectionByNameOrId(migrations.CommentCollectionName)
if err != nil {
panic(err)
}
records := make([]*models.Record, 0, 5)
for range gofakeit.IntN(5) {
commentCreated := gofakeit.DateRange(created, time.Now())
commentUpdated := gofakeit.DateRange(commentCreated, time.Now())
commentRecord := models.NewRecord(commentCollection)
commentRecord.SetId("c_" + security.PseudorandomString(10))
commentRecord.Set("created", commentCreated.Format("2006-01-02T15:04:05Z"))
commentRecord.Set("updated", commentUpdated.Format("2006-01-02T15:04:05Z"))
commentRecord.Set("ticket", record.GetId())
commentRecord.Set("author", random(users).GetId())
commentRecord.Set("message", fakeTicketComment())
records = append(records, commentRecord)
}
return records
}
func timelineRecords(dao *daos.Dao, created time.Time, record *models.Record) []*models.Record {
timelineCollection, err := dao.FindCollectionByNameOrId(migrations.TimelineCollectionName)
if err != nil {
panic(err)
}
records := make([]*models.Record, 0, 5)
for range gofakeit.IntN(5) {
timelineCreated := gofakeit.DateRange(created, time.Now())
timelineUpdated := gofakeit.DateRange(timelineCreated, time.Now())
timelineRecord := models.NewRecord(timelineCollection)
timelineRecord.SetId("tl_" + security.PseudorandomString(10))
timelineRecord.Set("created", timelineCreated.Format("2006-01-02T15:04:05Z"))
timelineRecord.Set("updated", timelineUpdated.Format("2006-01-02T15:04:05Z"))
timelineRecord.Set("ticket", record.GetId())
timelineRecord.Set("time", gofakeit.DateRange(created, time.Now()).Format("2006-01-02T15:04:05Z"))
timelineRecord.Set("message", fakeTicketTimelineMessage())
records = append(records, timelineRecord)
}
return records
}
func taskRecords(dao *daos.Dao, users []*models.Record, created time.Time, record *models.Record) []*models.Record {
taskCollection, err := dao.FindCollectionByNameOrId(migrations.TaskCollectionName)
if err != nil {
panic(err)
}
records := make([]*models.Record, 0, 5)
for range gofakeit.IntN(5) {
taskCreated := gofakeit.DateRange(created, time.Now())
taskUpdated := gofakeit.DateRange(taskCreated, time.Now())
taskRecord := models.NewRecord(taskCollection)
taskRecord.SetId("ts_" + security.PseudorandomString(10))
taskRecord.Set("created", taskCreated.Format("2006-01-02T15:04:05Z"))
taskRecord.Set("updated", taskUpdated.Format("2006-01-02T15:04:05Z"))
taskRecord.Set("ticket", record.GetId())
taskRecord.Set("name", fakeTicketTask())
taskRecord.Set("open", gofakeit.Bool())
taskRecord.Set("owner", random(users).GetId())
records = append(records, taskRecord)
}
return records
}
func linkRecords(dao *daos.Dao, created time.Time, record *models.Record) []*models.Record {
linkCollection, err := dao.FindCollectionByNameOrId(migrations.LinkCollectionName)
if err != nil {
panic(err)
}
records := make([]*models.Record, 0, 5)
for range gofakeit.IntN(5) {
linkCreated := gofakeit.DateRange(created, time.Now())
linkUpdated := gofakeit.DateRange(linkCreated, time.Now())
linkRecord := models.NewRecord(linkCollection)
linkRecord.SetId("l_" + security.PseudorandomString(10))
linkRecord.Set("created", linkCreated.Format("2006-01-02T15:04:05Z"))
linkRecord.Set("updated", linkUpdated.Format("2006-01-02T15:04:05Z"))
linkRecord.Set("ticket", record.GetId())
linkRecord.Set("url", gofakeit.URL())
linkRecord.Set("name", random([]string{"Blog", "Forum", "Wiki", "Documentation"}))
records = append(records, linkRecord)
}
return records
}
const createTicketPy = `import sys
import json
import random
import os
from pocketbase import PocketBase
# Connect to the PocketBase server
client = PocketBase(os.environ["CATALYST_APP_URL"])
client.auth_store.save(token=os.environ["CATALYST_TOKEN"])
newtickets = client.collection("tickets").get_list(1, 200, {"filter": 'name = "New Ticket"'})
for ticket in newtickets.items:
client.collection("tickets").delete(ticket.id)
# Create a new ticket
client.collection("tickets").create({
"name": "New Ticket",
"type": "alert",
"open": True,
})`
const alertIngestPy = `import sys
import json
import random
import os
from pocketbase import PocketBase
# Parse the event from the webhook payload
event = json.loads(sys.argv[1])
body = json.loads(event["body"])
# Connect to the PocketBase server
client = PocketBase(os.environ["CATALYST_APP_URL"])
client.auth_store.save(token=os.environ["CATALYST_TOKEN"])
# Create a new ticket
client.collection("tickets").create({
"name": body["name"],
"type": "alert",
"open": True,
})`
const assignTicketsPy = `import sys
import json
import random
import os
from pocketbase import PocketBase
# Parse the ticket from the input
ticket = json.loads(sys.argv[1])
# Connect to the PocketBase server
client = PocketBase(os.environ["CATALYST_APP_URL"])
client.auth_store.save(token=os.environ["CATALYST_TOKEN"])
# Get a random user
users = client.collection("users").get_list(1, 200)
random_user = random.choice(users.items)
# Assign the ticket to the random user
client.collection("tickets").update(ticket["record"]["id"], {
"owner": random_user.id,
})`
const (
triggerSchedule = `{"expression":"12 * * * *"}`
triggerWebhook = `{"token":"1234567890","path":"webhook"}`
triggerHook = `{"collections":["tickets"],"events":["create"]}`
)
func reactionRecords(dao *daos.Dao) []*models.Record {
var records []*models.Record
collection, err := dao.FindCollectionByNameOrId(migrations.ReactionCollectionName)
if err != nil {
panic(err)
}
createTicketActionData, err := json.Marshal(map[string]interface{}{
"requirements": "pocketbase",
"script": createTicketPy,
})
if err != nil {
panic(err)
}
record := models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Test Webhook")
record.Set("collection", "tickets")
record.Set("destination", "http://localhost:8080/webhook")
record.Set("name", "Create New Ticket")
record.Set("trigger", "schedule")
record.Set("triggerdata", triggerSchedule)
record.Set("action", "python")
record.Set("actiondata", string(createTicketActionData))
return []*models.Record{record}
records = append(records, record)
alertIngestActionData, err := json.Marshal(map[string]interface{}{
"requirements": "pocketbase",
"script": alertIngestPy,
})
if err != nil {
panic(err)
}
record = models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Alert Ingest Webhook")
record.Set("trigger", "webhook")
record.Set("triggerdata", triggerWebhook)
record.Set("action", "python")
record.Set("actiondata", string(alertIngestActionData))
records = append(records, record)
assignTicketsActionData, err := json.Marshal(map[string]interface{}{
"requirements": "pocketbase",
"script": assignTicketsPy,
})
if err != nil {
panic(err)
}
record = models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Assign new Tickets")
record.Set("trigger", "hook")
record.Set("triggerdata", triggerHook)
record.Set("action", "python")
record.Set("actiondata", string(assignTicketsActionData))
records = append(records, record)
return records
}

33
fakedata/records_test.go Normal file
View File

@@ -0,0 +1,33 @@
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) {
t.Parallel()
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) {
t.Parallel()
app, _, cleanup := catalystTesting.App(t)
defer cleanup()
err := fakedata.Generate(app, 0, 0)
require.NoError(t, err)
}

60
fakedata/text_test.go Normal file
View File

@@ -0,0 +1,60 @@
package fakedata
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_fakeTicketComment(t *testing.T) {
t.Parallel()
assert.NotEmpty(t, fakeTicketComment())
}
func Test_fakeTicketDescription(t *testing.T) {
t.Parallel()
assert.NotEmpty(t, fakeTicketDescription())
}
func Test_fakeTicketTask(t *testing.T) {
t.Parallel()
assert.NotEmpty(t, fakeTicketTask())
}
func Test_fakeTicketTimelineMessage(t *testing.T) {
t.Parallel()
assert.NotEmpty(t, fakeTicketTimelineMessage())
}
func Test_random(t *testing.T) {
t.Parallel()
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) {
t.Parallel()
got := random(tt.args.e)
assert.Contains(t, tt.args.e, got)
})
}
}

View File

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

14
go.mod
View File

@@ -4,10 +4,14 @@ go 1.22.1
require (
github.com/brianvoe/gofakeit/v7 v7.0.3
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
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
github.com/tidwall/sjson v1.2.5
go.uber.org/multierr v1.11.0
)
require (
@@ -34,6 +38,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
@@ -43,7 +48,6 @@ require (
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.12.4 // indirect
@@ -56,11 +60,14 @@ 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/tidwall/gjson v1.14.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.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
@@ -80,9 +87,10 @@ require (
google.golang.org/api v0.184.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/grpc v1.64.0 // indirect
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

15
go.sum
View File

@@ -204,6 +204,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -221,6 +229,8 @@ go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D
go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s=
go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM=
go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro=
gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -317,8 +327,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -330,6 +340,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=

31
main.go
View File

@@ -3,33 +3,16 @@ 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 {
catalyst, err := app.App("./catalyst_data", false)
if err != nil {
log.Fatal(err)
}
if err := catalyst.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()
}

View File

@@ -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)

View File

@@ -1,10 +1,11 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security"
)
func defaultDataUp(db dbx.Builder) error {
@@ -30,22 +31,46 @@ func typeRecords(dao *daos.Dao) []*models.Record {
var records []*models.Record
record := models.NewRecord(collection)
record.SetId("y_" + security.PseudorandomString(5))
record.SetId("incident")
record.Set("singular", "Incident")
record.Set("plural", "Incidents")
record.Set("icon", "Flame")
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`)
record.Set("schema", s(map[string]any{
"type": "object",
"properties": map[string]any{
"severity": map[string]any{
"title": "Severity",
"enum": []string{"Low", "Medium", "High"},
},
},
"required": []string{"severity"},
}))
records = append(records, record)
record = models.NewRecord(collection)
record.SetId("y_" + security.PseudorandomString(5))
record.SetId("alert")
record.Set("singular", "Alert")
record.Set("plural", "Alerts")
record.Set("icon", "AlertTriangle")
record.Set("schema", `{"type":"object","properties":{"severity":{"title":"Severity","type":"string"}},"required": ["severity"]}`)
record.Set("schema", s(map[string]any{
"type": "object",
"properties": map[string]any{
"severity": map[string]any{
"title": "Severity",
"enum": []string{"Low", "Medium", "High"},
},
},
"required": []string{"severity"},
}))
records = append(records, record)
return records
}
func s(m map[string]any) string {
b, _ := json.Marshal(m) //nolint:errchkjson
return string(b)
}

40
migrations/5_reactions.go Normal file
View File

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

View File

@@ -0,0 +1,37 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
const SystemUserID = "system"
func systemuserUp(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId(UserCollectionName)
if err != nil {
return err
}
record := models.NewRecord(collection)
record.SetId(SystemUserID)
record.Set("name", "system")
record.Set("username", "system")
record.Set("verified", true)
return dao.SaveRecord(record)
}
func systemuserDown(db dbx.Builder) error {
dao := daos.New(db)
record, err := dao.FindRecordById(UserCollectionName, SystemUserID)
if err != nil {
return err
}
return dao.DeleteRecord(record)
}

View File

@@ -0,0 +1,49 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
)
const searchViewName = "ticket_search"
const searchViewQuery = `
SELECT
tickets.id,
tickets.name,
tickets.created,
tickets.description,
tickets.open,
tickets.type,
tickets.state,
users.name as owner_name,
group_concat(comments.message) as comment_messages,
group_concat(files.name) as file_names,
group_concat(links.name) as link_names,
group_concat(links.url) as link_urls,
group_concat(tasks.name) as task_names,
group_concat(timeline.message) as timeline_messages
FROM tickets
LEFT JOIN comments ON comments.ticket = tickets.id
LEFT JOIN files ON files.ticket = tickets.id
LEFT JOIN links ON links.ticket = tickets.id
LEFT JOIN tasks ON tasks.ticket = tickets.id
LEFT JOIN timeline ON timeline.ticket = tickets.id
LEFT JOIN users ON users.id = tickets.owner
GROUP BY tickets.id
`
func searchViewUp(db dbx.Builder) error {
return daos.New(db).SaveCollection(internalView(searchViewName, searchViewQuery))
}
func searchViewDown(db dbx.Builder) error {
dao := daos.New(db)
id, err := dao.FindCollectionByNameOrId(searchViewName)
if err != nil {
return err
}
return dao.DeleteCollection(id)
}

View File

@@ -0,0 +1,43 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tools/types"
)
const dashboardCountsViewUpdateQuery = `SELECT id, count FROM (
SELECT 'users' as id, COUNT(users.id) as count FROM users
UNION
SELECT 'tickets' as id, COUNT(tickets.id) as count FROM tickets
UNION
SELECT 'tasks' as id, COUNT(tasks.id) as count FROM tasks
UNION
SELECT 'reactions' as id, COUNT(reactions.id) as count FROM reactions
) as counts;`
func dashboardCountsViewUpdateUp(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId(dashboardCountsViewName)
if err != nil {
return err
}
collection.Options = types.JsonMap{"query": dashboardCountsViewUpdateQuery}
return dao.SaveCollection(collection)
}
func dashboardCountsViewUpdateDown(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId(dashboardCountsViewName)
if err != nil {
return err
}
collection.Options = types.JsonMap{"query": dashboardCountsViewQuery}
return dao.SaveCollection(collection)
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/schema"
)
func reactionsUpdateUp(db dbx.Builder) error {
dao := daos.New(db)
triggers := []string{"webhook", "hook", "schedule"}
col, err := dao.FindCollectionByNameOrId(ReactionCollectionName)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", ReactionCollectionName, err)
}
field := col.Schema.GetFieldByName("trigger")
field.Options = &schema.SelectOptions{MaxSelect: 1, Values: triggers}
col.Schema.AddField(field)
return dao.SaveCollection(col)
}

View File

@@ -9,4 +9,9 @@ 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")
migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go")
migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go")
migrations.Register(dashboardCountsViewUpdateUp, dashboardCountsViewUpdateDown, "1700000008_dashboardview.go")
migrations.Register(reactionsUpdateUp, nil, "1700000009_reactions_update.go")
}

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

@@ -0,0 +1,84 @@
package action
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action/python"
"github.com/SecurityBrewery/catalyst/reaction/action/webhook"
)
func Run(ctx context.Context, app core.App, actionName, actionData, payload string) ([]byte, error) {
action, err := decode(actionName, actionData)
if err != nil {
return nil, err
}
if a, ok := action.(authenticatedAction); ok {
token, err := systemToken(app)
if err != nil {
return nil, fmt.Errorf("failed to get system token: %w", err)
}
a.SetEnv([]string{
"CATALYST_APP_URL=" + app.Settings().Meta.AppUrl,
"CATALYST_TOKEN=" + token,
})
}
return action.Run(ctx, payload)
}
type action interface {
Run(ctx context.Context, payload string) ([]byte, error)
}
type authenticatedAction interface {
SetEnv(env []string)
}
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)
}
}
func systemToken(app core.App) (string, error) {
authRecord, err := app.Dao().FindAuthRecordByUsername(migrations.UserCollectionName, migrations.SystemUserID)
if err != nil {
return "", fmt.Errorf("failed to find system auth record: %w", err)
}
return security.NewJWT(
jwt.MapClaims{
"id": authRecord.Id,
"type": tokens.TypeAuthRecord,
"collectionId": authRecord.Collection().Id,
},
authRecord.TokenKey()+app.Settings().RecordAuthToken.Secret,
int64(time.Second*60),
)
}

View File

@@ -0,0 +1,117 @@
package python
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
)
type Python struct {
Requirements string `json:"requirements"`
Script string `json:"script"`
env []string
}
func (a *Python) SetEnv(env []string) {
a.env = env
}
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)
b, err := pythonSetup(ctx, tempDir)
if 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))
}
b, err = a.pythonInstallRequirements(ctx, tempDir)
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
b = append(b, ee.Stderr...)
}
return nil, fmt.Errorf("failed to run install requirements, %w: %s", err, string(b))
}
b, err = a.pythonRunScript(ctx, tempDir, 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("python3", "python")
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 (a *Python) pythonInstallRequirements(ctx context.Context, tempDir string) ([]byte, error) {
hasRequirements := len(strings.TrimSpace(a.Requirements)) > 0
if !hasRequirements {
return nil, nil
}
requirementsPath := tempDir + "/requirements.txt"
if err := os.WriteFile(requirementsPath, []byte(a.Requirements), 0o600); err != nil {
return nil, err
}
// install dependencies
pipPath := tempDir + "/venv/bin/pip"
return exec.CommandContext(ctx, pipPath, "install", "-r", requirementsPath).Output()
}
func (a *Python) pythonRunScript(ctx context.Context, tempDir, payload string) ([]byte, error) {
scriptPath := tempDir + "/script.py"
if err := os.WriteFile(scriptPath, []byte(a.Script), 0o600); err != nil {
return nil, err
}
pythonPath := tempDir + "/venv/bin/python"
cmd := exec.CommandContext(ctx, pythonPath, scriptPath, payload)
cmd.Env = a.env
return cmd.Output()
}
func findExec(name ...string) (string, error) {
for _, n := range name {
if p, err := exec.LookPath(n); err == nil {
return p, nil
}
}
return "", errors.New("no executable found")
}

View File

@@ -0,0 +1,104 @@
package python_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/SecurityBrewery/catalyst/reaction/action/python"
)
func TestPython_Run(t *testing.T) {
t.Parallel()
type fields struct {
Requirements string
Script string
}
type args struct {
payload string
}
tests := []struct {
name string
fields fields
args args
want []byte
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty",
fields: fields{
Script: "pass",
},
args: args{
payload: "test",
},
want: []byte(""),
wantErr: assert.NoError,
},
{
name: "hello world",
fields: fields{
Script: "print('hello world')",
},
args: args{
payload: "test",
},
want: []byte("hello world\n"),
wantErr: assert.NoError,
},
{
name: "echo",
fields: fields{
Script: "import sys; print(sys.argv[1])",
},
args: args{
payload: "test",
},
want: []byte("test\n"),
wantErr: assert.NoError,
},
{
name: "error",
fields: fields{
Script: "import sys; sys.exit(1)",
},
args: args{
payload: "test",
},
want: nil,
wantErr: assert.Error,
},
{
name: "requests",
fields: fields{
Requirements: "requests",
Script: "import requests\nprint(requests.get('https://xkcd.com/2961/info.0.json').text)",
},
args: args{
payload: "test",
},
want: []byte("{\"month\": \"7\", \"num\": 2961, \"link\": \"\", \"year\": \"2024\", \"news\": \"\", \"safe_title\": \"CrowdStrike\", \"transcript\": \"\", \"alt\": \"We were going to try swordfighting, but all my compiling is on hold.\", \"img\": \"https://imgs.xkcd.com/comics/crowdstrike.png\", \"title\": \"CrowdStrike\", \"day\": \"19\"}\n"),
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
a := &python.Python{
Requirements: tt.fields.Requirements,
Script: tt.fields.Script,
}
got, err := a.Run(ctx, tt.args.payload)
tt.wantErr(t, err)
assert.Equal(t, tt.want, got)
})
}
}

View File

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

View File

@@ -0,0 +1,55 @@
package webhook_test
import (
"bytes"
"io"
"testing"
"github.com/SecurityBrewery/catalyst/reaction/action/webhook"
)
func TestEncodeBody(t *testing.T) {
t.Parallel()
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) {
t.Parallel()
got, got1 := webhook.EncodeBody(tt.args.requestBody)
if got != tt.want {
t.Errorf("EncodeBody() got = %v, want %v", got, tt.want)
}
if got1 != tt.want1 {
t.Errorf("EncodeBody() got1 = %v, want %v", got1, tt.want1)
}
})
}
}

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
package webhook_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/sjson"
"github.com/SecurityBrewery/catalyst/reaction/action/webhook"
catalystTesting "github.com/SecurityBrewery/catalyst/testing"
)
func TestWebhook_Run(t *testing.T) {
t.Parallel()
server := catalystTesting.NewRecordingServer()
go http.ListenAndServe("127.0.0.1:12347", server) //nolint:gosec,errcheck
if err := catalystTesting.WaitForStatus("http://127.0.0.1:12347/health", http.StatusOK, 5*time.Second); err != nil {
t.Fatal(err)
}
type fields struct {
Headers map[string]string
URL string
}
type args struct {
payload string
}
tests := []struct {
name string
fields fields
args args
want map[string]any
wantErr assert.ErrorAssertionFunc
}{
{
name: "",
fields: fields{
Headers: map[string]string{},
URL: "http://127.0.0.1:12347/foo",
},
args: args{
payload: "test",
},
want: map[string]any{
"statusCode": 200,
"headers": map[string]any{
"Content-Length": []any{"14"},
"Content-Type": []any{"application/json; charset=UTF-8"},
},
"body": "{\"test\":true}\n",
"isBase64Encoded": false,
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
a := &webhook.Webhook{
Headers: tt.fields.Headers,
URL: tt.fields.URL,
}
got, err := a.Run(ctx, tt.args.payload)
tt.wantErr(t, err)
want, err := json.Marshal(tt.want)
require.NoError(t, err)
got, err = sjson.DeleteBytes(got, "headers.Date")
require.NoError(t, err)
assert.JSONEq(t, string(want), string(got))
})
}
}

View File

@@ -0,0 +1,101 @@
package schedule
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/cron"
"go.uber.org/multierr"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action"
)
type Schedule struct {
Expression string `json:"expression"`
}
func Start(pb *pocketbase.PocketBase) {
scheduler := cron.New()
if err := scheduler.Add("reactions", "* * * * *", func() {
ctx := context.Background()
moment := cron.NewMoment(time.Now())
if err := runSchedule(ctx, pb.App, moment); err != nil {
slog.ErrorContext(ctx, fmt.Sprintf("failed to run hook reaction: %v", err))
}
}); err != nil {
slog.Error(fmt.Sprintf("failed to add cron job: %v", err))
}
scheduler.Start()
}
func runSchedule(ctx context.Context, app core.App, moment *cron.Moment) error {
var errs error
records, err := findByScheduleTrigger(app.Dao(), moment)
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to find schedule reaction: %w", err))
}
if len(records) == 0 {
return nil
}
for _, hook := range records {
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), "{}")
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
}
}
return errs
}
func findByScheduleTrigger(dao *daos.Dao, moment *cron.Moment) ([]*models.Record, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "schedule"})
if err != nil {
return nil, fmt.Errorf("failed to find schedule reaction: %w", err)
}
if len(records) == 0 {
return nil, nil
}
var errs error
var matchedRecords []*models.Record
for _, record := range records {
var schedule Schedule
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &schedule); err != nil {
errs = multierr.Append(errs, err)
continue
}
s, err := cron.NewSchedule(schedule.Expression)
if err != nil {
errs = multierr.Append(errs, err)
continue
}
if s.IsDue(moment) {
matchedRecords = append(matchedRecords, record)
}
}
return matchedRecords, errs
}

15
reaction/trigger.go Normal file
View File

@@ -0,0 +1,15 @@
package reaction
import (
"github.com/pocketbase/pocketbase"
"github.com/SecurityBrewery/catalyst/reaction/schedule"
"github.com/SecurityBrewery/catalyst/reaction/trigger/hook"
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
)
func BindHooks(pb *pocketbase.PocketBase, test bool) {
schedule.Start(pb)
hook.BindHooks(pb, test)
webhook.BindHooks(pb)
}

View File

@@ -0,0 +1,119 @@
package hook
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"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"
"github.com/pocketbase/pocketbase/models"
"go.uber.org/multierr"
"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(pb *pocketbase.PocketBase, test bool) {
pb.App.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
return hook(e.HttpContext, pb.App, "create", e.Collection.Name, e.Record, test)
})
pb.App.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
return hook(e.HttpContext, pb.App, "update", e.Collection.Name, e.Record, test)
})
pb.App.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
return hook(e.HttpContext, pb.App, "delete", e.Collection.Name, e.Record, test)
})
}
func hook(ctx echo.Context, app core.App, event, collection string, record *models.Record, test bool) error {
auth, _ := ctx.Get(apis.ContextAuthRecordKey).(*models.Record)
admin, _ := ctx.Get(apis.ContextAdminKey).(*models.Admin)
if !test {
go mustRunHook(app, collection, event, record, auth, admin)
} else {
mustRunHook(app, collection, event, record, auth, admin)
}
return nil
}
func mustRunHook(app core.App, collection, event string, record, auth *models.Record, admin *models.Admin) {
ctx := context.Background()
if err := runHook(ctx, app, collection, event, record, auth, admin); err != nil {
slog.ErrorContext(ctx, fmt.Sprintf("failed to run hook reaction: %v", err))
}
}
func runHook(ctx context.Context, app core.App, collection, event string, record, auth *models.Record, admin *models.Admin) error {
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 webhook payload: %w", err)
}
hooks, err := findByHookTrigger(app.Dao(), collection, event)
if err != nil {
return fmt.Errorf("failed to find hook by trigger: %w", err)
}
if len(hooks) == 0 {
return nil
}
var errs error
for _, hook := range hooks {
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), string(payload))
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
}
}
return errs
}
func findByHookTrigger(dao *daos.Dao, collection, event string) ([]*models.Record, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "hook"})
if err != nil {
return nil, fmt.Errorf("failed to find hook reaction: %w", err)
}
if len(records) == 0 {
return nil, nil
}
var matchedRecords []*models.Record
for _, record := range records {
var hook Hook
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &hook); err != nil {
return nil, err
}
if slices.Contains(hook.Collections, collection) && slices.Contains(hook.Events, event) {
matchedRecords = append(matchedRecords, record)
}
}
return matchedRecords, nil
}

View File

@@ -0,0 +1,23 @@
package webhook
import (
"encoding/json"
"net/http"
"net/url"
)
type Request struct {
Method string `json:"method"`
Path string `json:"path"`
Headers http.Header `json:"headers"`
Query url.Values `json:"query"`
Body string `json:"body"`
IsBase64Encoded bool `json:"isBase64Encoded"`
}
// IsJSON checks if the data is JSON.
func IsJSON(data []byte) bool {
var msg json.RawMessage
return json.Unmarshal(data, &msg) == nil
}

View File

@@ -0,0 +1,45 @@
package webhook_test
import (
"testing"
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
)
func Test_isJSON(t *testing.T) {
t.Parallel()
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) {
t.Parallel()
if got := webhook.IsJSON(tt.args.data); got != tt.want {
t.Errorf("isJSON() = %v, want %v", got, tt.want)
}
})
}
}

View File

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

View File

@@ -0,0 +1,249 @@
package testing
import (
"net/http"
"testing"
)
func TestReactionsCollection(t *testing.T) {
t.Parallel()
testSets := []catalystTest{
{
baseTest: BaseTest{
Name: "ListReactions",
Method: http.MethodGet,
URL: "/api/collections/reactions/records",
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "Analyst",
AuthRecord: analystEmail,
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"totalItems":3`,
`"id":"r_reaction"`,
},
NotExpectedContent: []string{
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "Admin",
Admin: adminEmail,
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!')"},
}),
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusBadRequest,
ExpectedContent: []string{
`"message":"Failed to create record."`,
},
},
{
Name: "Analyst",
AuthRecord: analystEmail,
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"name":"test"`,
},
NotExpectedContent: []string{
`"items":[]`,
},
ExpectedEvents: map[string]int{
"OnModelAfterCreate": 1,
"OnModelBeforeCreate": 1,
"OnRecordAfterCreateRequest": 1,
"OnRecordBeforeCreateRequest": 1,
},
},
{
Name: "Admin",
Admin: adminEmail,
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",
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusNotFound,
ExpectedContent: []string{
`"message":"The requested resource wasn't found."`,
},
},
{
Name: "Analyst",
AuthRecord: analystEmail,
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"id":"r_reaction"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "Admin",
Admin: adminEmail,
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"}),
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusNotFound,
ExpectedContent: []string{
`"message":"The requested resource wasn't found."`,
},
},
{
Name: "Analyst",
AuthRecord: analystEmail,
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"id":"r_reaction"`,
`"name":"update"`,
},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
},
{
Name: "Admin",
Admin: adminEmail,
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",
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusNotFound,
ExpectedContent: []string{
`"message":"The requested resource wasn't found."`,
},
},
{
Name: "Analyst",
AuthRecord: analystEmail,
ExpectedStatus: http.StatusNoContent,
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
},
{
Name: "Admin",
Admin: adminEmail,
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) {
t.Parallel()
for _, userTest := range testSet.userTests {
t.Run(userTest.Name, func(t *testing.T) {
t.Parallel()
runMatrixTest(t, testSet.baseTest, userTest)
})
}
})
}
}

36
testing/counter.go Normal file
View File

@@ -0,0 +1,36 @@
package testing
import "sync"
type Counter struct {
mux sync.Mutex
counts map[string]int
}
func NewCounter() *Counter {
return &Counter{
counts: make(map[string]int),
}
}
func (c *Counter) Increment(name string) {
c.mux.Lock()
defer c.mux.Unlock()
if _, ok := c.counts[name]; !ok {
c.counts[name] = 0
}
c.counts[name]++
}
func (c *Counter) Count(name string) int {
c.mux.Lock()
defer c.mux.Unlock()
if _, ok := c.counts[name]; !ok {
return 0
}
return c.counts[name]
}

41
testing/counter_test.go Normal file
View File

@@ -0,0 +1,41 @@
package testing
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCounter(t *testing.T) {
t.Parallel()
type args struct {
name string
repeat int
}
tests := []struct {
name string
args args
want int
}{
{
name: "Test Counter",
args: args{name: "test", repeat: 5},
want: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := NewCounter()
for range tt.args.repeat {
c.Increment(tt.args.name)
}
assert.Equal(t, tt.want, c.Count(tt.args.name))
})
}
}

37
testing/http.go Normal file
View File

@@ -0,0 +1,37 @@
package testing
import (
"context"
"errors"
"net/http"
"time"
)
func WaitForStatus(url string, status int, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
start := time.Now()
for {
if time.Since(start) > timeout {
return errors.New("timeout")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err == nil && resp.StatusCode == status {
resp.Body.Close()
break
}
time.Sleep(100 * time.Millisecond)
}
return nil
}

135
testing/reaction_test.go Normal file
View File

@@ -0,0 +1,135 @@
package testing
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestWebhookReactions(t *testing.T) {
t.Parallel()
server := NewRecordingServer()
go http.ListenAndServe("127.0.0.1:12345", server) //nolint:gosec,errcheck
if err := WaitForStatus("http://127.0.0.1:12345/health", http.StatusOK, 5*time.Second); err != nil {
t.Fatal(err)
}
testSets := []catalystTest{
{
baseTest: BaseTest{
Name: "TriggerWebhookReaction",
Method: http.MethodGet,
RequestHeaders: map[string]string{"Authorization": "Bearer 1234567890"},
URL: "/reaction/test",
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`Hello, World!`},
},
},
},
{
baseTest: BaseTest{
Name: "TriggerWebhookReaction2",
Method: http.MethodGet,
URL: "/reaction/test2",
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{`"test":true`},
},
},
},
}
for _, testSet := range testSets {
t.Run(testSet.baseTest.Name, func(t *testing.T) {
t.Parallel()
for _, userTest := range testSet.userTests {
t.Run(userTest.Name, func(t *testing.T) {
t.Parallel()
runMatrixTest(t, testSet.baseTest, userTest)
})
}
})
}
}
func TestHookReactions(t *testing.T) {
t.Parallel()
server := NewRecordingServer()
go http.ListenAndServe("127.0.0.1:12346", server) //nolint:gosec,errcheck
if err := WaitForStatus("http://127.0.0.1:12346/health", http.StatusOK, 5*time.Second); err != nil {
t.Fatal(err)
}
testSets := []catalystTest{
{
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",
}),
},
userTests: []UserTest{
// {
// Name: "Unauthorized",
// ExpectedStatus: http.StatusOK,
// ExpectedContent: []string{`Hello, World!`},
// },
{
Name: "Analyst",
AuthRecord: analystEmail,
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) {
t.Parallel()
for _, userTest := range testSet.userTests {
t.Run(userTest.Name, func(t *testing.T) {
t.Parallel()
runMatrixTest(t, testSet.baseTest, userTest)
require.NotEmpty(t, server.Entries)
})
}
})
}
}

View File

@@ -0,0 +1,38 @@
package testing
import (
"net/http"
"github.com/labstack/echo/v5"
)
type RecordingServer struct {
server *echo.Echo
Entries []string
}
func NewRecordingServer() *RecordingServer {
e := echo.New()
e.GET("/health", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]any{
"status": "ok",
})
})
e.Any("/*", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]any{
"test": true,
})
})
return &RecordingServer{
server: e,
}
}
func (s *RecordingServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.Entries = append(s.Entries, r.URL.Path)
s.server.ServeHTTP(w, r)
}

81
testing/routes_test.go Normal file
View File

@@ -0,0 +1,81 @@
package testing
import (
"net/http"
"testing"
)
func Test_Routes(t *testing.T) {
t.Parallel()
testSets := []catalystTest{
{
baseTest: BaseTest{
Name: "Root",
Method: http.MethodGet,
URL: "/",
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusFound,
},
{
Name: "Analyst",
AuthRecord: analystEmail,
ExpectedStatus: http.StatusFound,
},
{
Name: "Admin",
Admin: adminEmail,
ExpectedStatus: http.StatusFound,
},
},
},
{
baseTest: BaseTest{
Name: "Config",
Method: http.MethodGet,
URL: "/api/config",
},
userTests: []UserTest{
{
Name: "Unauthorized",
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"flags":[]`,
},
},
{
Name: "Analyst",
AuthRecord: analystEmail,
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"flags":[]`,
},
},
{
Name: "Admin",
Admin: adminEmail,
ExpectedStatus: http.StatusOK,
ExpectedContent: []string{
`"flags":[]`,
},
},
},
},
}
for _, testSet := range testSets {
t.Run(testSet.baseTest.Name, func(t *testing.T) {
t.Parallel()
for _, userTest := range testSet.userTests {
t.Run(userTest.Name, func(t *testing.T) {
t.Parallel()
runMatrixTest(t, testSet.baseTest, userTest)
})
}
})
}
}

164
testing/testapp.go Normal file
View File

@@ -0,0 +1,164 @@
package testing
import (
"fmt"
"os"
"testing"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tokens"
"github.com/SecurityBrewery/catalyst/app"
"github.com/SecurityBrewery/catalyst/migrations"
)
func App(t *testing.T) (*pocketbase.PocketBase, *Counter, func()) {
t.Helper()
temp, err := os.MkdirTemp("", "catalyst_test_data")
if err != nil {
t.Fatal(err)
}
baseApp, err := app.App(temp, true)
if err != nil {
t.Fatal(err)
}
if err := baseApp.Bootstrap(); err != nil {
t.Fatal(fmt.Errorf("failed to bootstrap: %w", err))
}
baseApp.Settings().Logs.MaxDays = 0
defaultTestData(t, baseApp)
counter := countEvents(baseApp)
return baseApp, counter, func() { _ = os.RemoveAll(temp) }
}
func generateAdminToken(t *testing.T, baseApp core.App, email string) (string, error) {
t.Helper()
admin, err := baseApp.Dao().FindAdminByEmail(email)
if err != nil {
return "", fmt.Errorf("failed to find admin: %w", err)
}
return tokens.NewAdminAuthToken(baseApp, admin)
}
func generateRecordToken(t *testing.T, baseApp core.App, email string) (string, error) {
t.Helper()
record, err := baseApp.Dao().FindAuthRecordByEmail(migrations.UserCollectionName, email)
if err != nil {
return "", fmt.Errorf("failed to find record: %w", err)
}
return tokens.NewRecordAuthToken(baseApp, record)
}
func countEvents(t *pocketbase.PocketBase) *Counter {
c := NewCounter()
t.OnBeforeApiError().Add(count[*core.ApiErrorEvent](c, "OnBeforeApiError"))
t.OnBeforeApiError().Add(count[*core.ApiErrorEvent](c, "OnBeforeApiError"))
t.OnAfterApiError().Add(count[*core.ApiErrorEvent](c, "OnAfterApiError"))
t.OnModelBeforeCreate().Add(count[*core.ModelEvent](c, "OnModelBeforeCreate"))
t.OnModelAfterCreate().Add(count[*core.ModelEvent](c, "OnModelAfterCreate"))
t.OnModelBeforeUpdate().Add(count[*core.ModelEvent](c, "OnModelBeforeUpdate"))
t.OnModelAfterUpdate().Add(count[*core.ModelEvent](c, "OnModelAfterUpdate"))
t.OnModelBeforeDelete().Add(count[*core.ModelEvent](c, "OnModelBeforeDelete"))
t.OnModelAfterDelete().Add(count[*core.ModelEvent](c, "OnModelAfterDelete"))
t.OnRecordsListRequest().Add(count[*core.RecordsListEvent](c, "OnRecordsListRequest"))
t.OnRecordViewRequest().Add(count[*core.RecordViewEvent](c, "OnRecordViewRequest"))
t.OnRecordBeforeCreateRequest().Add(count[*core.RecordCreateEvent](c, "OnRecordBeforeCreateRequest"))
t.OnRecordAfterCreateRequest().Add(count[*core.RecordCreateEvent](c, "OnRecordAfterCreateRequest"))
t.OnRecordBeforeUpdateRequest().Add(count[*core.RecordUpdateEvent](c, "OnRecordBeforeUpdateRequest"))
t.OnRecordAfterUpdateRequest().Add(count[*core.RecordUpdateEvent](c, "OnRecordAfterUpdateRequest"))
t.OnRecordBeforeDeleteRequest().Add(count[*core.RecordDeleteEvent](c, "OnRecordBeforeDeleteRequest"))
t.OnRecordAfterDeleteRequest().Add(count[*core.RecordDeleteEvent](c, "OnRecordAfterDeleteRequest"))
t.OnRecordAuthRequest().Add(count[*core.RecordAuthEvent](c, "OnRecordAuthRequest"))
t.OnRecordBeforeAuthWithPasswordRequest().Add(count[*core.RecordAuthWithPasswordEvent](c, "OnRecordBeforeAuthWithPasswordRequest"))
t.OnRecordAfterAuthWithPasswordRequest().Add(count[*core.RecordAuthWithPasswordEvent](c, "OnRecordAfterAuthWithPasswordRequest"))
t.OnRecordBeforeAuthWithOAuth2Request().Add(count[*core.RecordAuthWithOAuth2Event](c, "OnRecordBeforeAuthWithOAuth2Request"))
t.OnRecordAfterAuthWithOAuth2Request().Add(count[*core.RecordAuthWithOAuth2Event](c, "OnRecordAfterAuthWithOAuth2Request"))
t.OnRecordBeforeAuthRefreshRequest().Add(count[*core.RecordAuthRefreshEvent](c, "OnRecordBeforeAuthRefreshRequest"))
t.OnRecordAfterAuthRefreshRequest().Add(count[*core.RecordAuthRefreshEvent](c, "OnRecordAfterAuthRefreshRequest"))
t.OnRecordBeforeRequestPasswordResetRequest().Add(count[*core.RecordRequestPasswordResetEvent](c, "OnRecordBeforeRequestPasswordResetRequest"))
t.OnRecordAfterRequestPasswordResetRequest().Add(count[*core.RecordRequestPasswordResetEvent](c, "OnRecordAfterRequestPasswordResetRequest"))
t.OnRecordBeforeConfirmPasswordResetRequest().Add(count[*core.RecordConfirmPasswordResetEvent](c, "OnRecordBeforeConfirmPasswordResetRequest"))
t.OnRecordAfterConfirmPasswordResetRequest().Add(count[*core.RecordConfirmPasswordResetEvent](c, "OnRecordAfterConfirmPasswordResetRequest"))
t.OnRecordBeforeRequestVerificationRequest().Add(count[*core.RecordRequestVerificationEvent](c, "OnRecordBeforeRequestVerificationRequest"))
t.OnRecordAfterRequestVerificationRequest().Add(count[*core.RecordRequestVerificationEvent](c, "OnRecordAfterRequestVerificationRequest"))
t.OnRecordBeforeConfirmVerificationRequest().Add(count[*core.RecordConfirmVerificationEvent](c, "OnRecordBeforeConfirmVerificationRequest"))
t.OnRecordAfterConfirmVerificationRequest().Add(count[*core.RecordConfirmVerificationEvent](c, "OnRecordAfterConfirmVerificationRequest"))
t.OnRecordBeforeRequestEmailChangeRequest().Add(count[*core.RecordRequestEmailChangeEvent](c, "OnRecordBeforeRequestEmailChangeRequest"))
t.OnRecordAfterRequestEmailChangeRequest().Add(count[*core.RecordRequestEmailChangeEvent](c, "OnRecordAfterRequestEmailChangeRequest"))
t.OnRecordBeforeConfirmEmailChangeRequest().Add(count[*core.RecordConfirmEmailChangeEvent](c, "OnRecordBeforeConfirmEmailChangeRequest"))
t.OnRecordAfterConfirmEmailChangeRequest().Add(count[*core.RecordConfirmEmailChangeEvent](c, "OnRecordAfterConfirmEmailChangeRequest"))
t.OnRecordListExternalAuthsRequest().Add(count[*core.RecordListExternalAuthsEvent](c, "OnRecordListExternalAuthsRequest"))
t.OnRecordBeforeUnlinkExternalAuthRequest().Add(count[*core.RecordUnlinkExternalAuthEvent](c, "OnRecordBeforeUnlinkExternalAuthRequest"))
t.OnRecordAfterUnlinkExternalAuthRequest().Add(count[*core.RecordUnlinkExternalAuthEvent](c, "OnRecordAfterUnlinkExternalAuthRequest"))
t.OnMailerBeforeAdminResetPasswordSend().Add(count[*core.MailerAdminEvent](c, "OnMailerBeforeAdminResetPasswordSend"))
t.OnMailerAfterAdminResetPasswordSend().Add(count[*core.MailerAdminEvent](c, "OnMailerAfterAdminResetPasswordSend"))
t.OnMailerBeforeRecordResetPasswordSend().Add(count[*core.MailerRecordEvent](c, "OnMailerBeforeRecordResetPasswordSend"))
t.OnMailerAfterRecordResetPasswordSend().Add(count[*core.MailerRecordEvent](c, "OnMailerAfterRecordResetPasswordSend"))
t.OnMailerBeforeRecordVerificationSend().Add(count[*core.MailerRecordEvent](c, "OnMailerBeforeRecordVerificationSend"))
t.OnMailerAfterRecordVerificationSend().Add(count[*core.MailerRecordEvent](c, "OnMailerAfterRecordVerificationSend"))
t.OnMailerBeforeRecordChangeEmailSend().Add(count[*core.MailerRecordEvent](c, "OnMailerBeforeRecordChangeEmailSend"))
t.OnMailerAfterRecordChangeEmailSend().Add(count[*core.MailerRecordEvent](c, "OnMailerAfterRecordChangeEmailSend"))
t.OnRealtimeConnectRequest().Add(count[*core.RealtimeConnectEvent](c, "OnRealtimeConnectRequest"))
t.OnRealtimeDisconnectRequest().Add(count[*core.RealtimeDisconnectEvent](c, "OnRealtimeDisconnectRequest"))
t.OnRealtimeBeforeMessageSend().Add(count[*core.RealtimeMessageEvent](c, "OnRealtimeBeforeMessageSend"))
t.OnRealtimeAfterMessageSend().Add(count[*core.RealtimeMessageEvent](c, "OnRealtimeAfterMessageSend"))
t.OnRealtimeBeforeSubscribeRequest().Add(count[*core.RealtimeSubscribeEvent](c, "OnRealtimeBeforeSubscribeRequest"))
t.OnRealtimeAfterSubscribeRequest().Add(count[*core.RealtimeSubscribeEvent](c, "OnRealtimeAfterSubscribeRequest"))
t.OnSettingsListRequest().Add(count[*core.SettingsListEvent](c, "OnSettingsListRequest"))
t.OnSettingsBeforeUpdateRequest().Add(count[*core.SettingsUpdateEvent](c, "OnSettingsBeforeUpdateRequest"))
t.OnSettingsAfterUpdateRequest().Add(count[*core.SettingsUpdateEvent](c, "OnSettingsAfterUpdateRequest"))
t.OnCollectionsListRequest().Add(count[*core.CollectionsListEvent](c, "OnCollectionsListRequest"))
t.OnCollectionViewRequest().Add(count[*core.CollectionViewEvent](c, "OnCollectionViewRequest"))
t.OnCollectionBeforeCreateRequest().Add(count[*core.CollectionCreateEvent](c, "OnCollectionBeforeCreateRequest"))
t.OnCollectionAfterCreateRequest().Add(count[*core.CollectionCreateEvent](c, "OnCollectionAfterCreateRequest"))
t.OnCollectionBeforeUpdateRequest().Add(count[*core.CollectionUpdateEvent](c, "OnCollectionBeforeUpdateRequest"))
t.OnCollectionAfterUpdateRequest().Add(count[*core.CollectionUpdateEvent](c, "OnCollectionAfterUpdateRequest"))
t.OnCollectionBeforeDeleteRequest().Add(count[*core.CollectionDeleteEvent](c, "OnCollectionBeforeDeleteRequest"))
t.OnCollectionAfterDeleteRequest().Add(count[*core.CollectionDeleteEvent](c, "OnCollectionAfterDeleteRequest"))
t.OnCollectionsBeforeImportRequest().Add(count[*core.CollectionsImportEvent](c, "OnCollectionsBeforeImportRequest"))
t.OnCollectionsAfterImportRequest().Add(count[*core.CollectionsImportEvent](c, "OnCollectionsAfterImportRequest"))
t.OnAdminsListRequest().Add(count[*core.AdminsListEvent](c, "OnAdminsListRequest"))
t.OnAdminViewRequest().Add(count[*core.AdminViewEvent](c, "OnAdminViewRequest"))
t.OnAdminBeforeCreateRequest().Add(count[*core.AdminCreateEvent](c, "OnAdminBeforeCreateRequest"))
t.OnAdminAfterCreateRequest().Add(count[*core.AdminCreateEvent](c, "OnAdminAfterCreateRequest"))
t.OnAdminBeforeUpdateRequest().Add(count[*core.AdminUpdateEvent](c, "OnAdminBeforeUpdateRequest"))
t.OnAdminAfterUpdateRequest().Add(count[*core.AdminUpdateEvent](c, "OnAdminAfterUpdateRequest"))
t.OnAdminBeforeDeleteRequest().Add(count[*core.AdminDeleteEvent](c, "OnAdminBeforeDeleteRequest"))
t.OnAdminAfterDeleteRequest().Add(count[*core.AdminDeleteEvent](c, "OnAdminAfterDeleteRequest"))
t.OnAdminAuthRequest().Add(count[*core.AdminAuthEvent](c, "OnAdminAuthRequest"))
t.OnAdminBeforeAuthWithPasswordRequest().Add(count[*core.AdminAuthWithPasswordEvent](c, "OnAdminBeforeAuthWithPasswordRequest"))
t.OnAdminAfterAuthWithPasswordRequest().Add(count[*core.AdminAuthWithPasswordEvent](c, "OnAdminAfterAuthWithPasswordRequest"))
t.OnAdminBeforeAuthRefreshRequest().Add(count[*core.AdminAuthRefreshEvent](c, "OnAdminBeforeAuthRefreshRequest"))
t.OnAdminAfterAuthRefreshRequest().Add(count[*core.AdminAuthRefreshEvent](c, "OnAdminAfterAuthRefreshRequest"))
t.OnAdminBeforeRequestPasswordResetRequest().Add(count[*core.AdminRequestPasswordResetEvent](c, "OnAdminBeforeRequestPasswordResetRequest"))
t.OnAdminAfterRequestPasswordResetRequest().Add(count[*core.AdminRequestPasswordResetEvent](c, "OnAdminAfterRequestPasswordResetRequest"))
t.OnAdminBeforeConfirmPasswordResetRequest().Add(count[*core.AdminConfirmPasswordResetEvent](c, "OnAdminBeforeConfirmPasswordResetRequest"))
t.OnAdminAfterConfirmPasswordResetRequest().Add(count[*core.AdminConfirmPasswordResetEvent](c, "OnAdminAfterConfirmPasswordResetRequest"))
t.OnFileDownloadRequest().Add(count[*core.FileDownloadEvent](c, "OnFileDownloadRequest"))
t.OnFileBeforeTokenRequest().Add(count[*core.FileTokenEvent](c, "OnFileBeforeTokenRequest"))
t.OnFileAfterTokenRequest().Add(count[*core.FileTokenEvent](c, "OnFileAfterTokenRequest"))
t.OnFileAfterTokenRequest().Add(count[*core.FileTokenEvent](c, "OnFileAfterTokenRequest"))
return c
}
func count[T any](c *Counter, name string) func(_ T) error {
return func(_ T) error {
c.Increment(name)
return nil
}
}

128
testing/testdata.go Normal file
View File

@@ -0,0 +1,128 @@
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)
ticketTestData(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 ticketTestData(t *testing.T, app core.App) {
t.Helper()
collection, err := app.Dao().FindCollectionByNameOrId(migrations.TicketCollectionName)
if err != nil {
t.Fatal(err)
}
record := models.NewRecord(collection)
record.SetId("t_test")
record.Set("name", "Test Ticket")
record.Set("type", "incident")
record.Set("description", "This is a test ticket.")
record.Set("open", true)
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`)
record.Set("state", `{"tlp":"AMBER"}`)
record.Set("owner", "u_bob_analyst")
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", `{"token":"1234567890","path":"test"}`)
record.Set("action", "python")
record.Set("actiondata", `{"requirements":"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", `{"requirements":"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)
}
}

105
testing/testing.go Normal file
View File

@@ -0,0 +1,105 @@
package testing
import (
"bytes"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type BaseTest struct {
Name string
Method string
RequestHeaders map[string]string
URL string
Body string
}
type UserTest struct {
Name string
AuthRecord string
Admin string
ExpectedStatus int
ExpectedContent []string
NotExpectedContent []string
ExpectedEvents map[string]int
}
type catalystTest struct {
baseTest BaseTest
userTests []UserTest
}
func runMatrixTest(t *testing.T, baseTest BaseTest, userTest UserTest) {
t.Helper()
baseApp, counter, baseAppCleanup := App(t)
defer baseAppCleanup()
server, err := apis.InitApi(baseApp)
require.NoError(t, err)
if err := baseApp.OnBeforeServe().Trigger(&core.ServeEvent{
App: baseApp,
Router: server,
}); err != nil {
t.Fatal(fmt.Errorf("failed to trigger OnBeforeServe: %w", err))
}
recorder := httptest.NewRecorder()
body := bytes.NewBufferString(baseTest.Body)
req := httptest.NewRequest(baseTest.Method, baseTest.URL, body)
for k, v := range baseTest.RequestHeaders {
req.Header.Set(k, v)
}
if userTest.AuthRecord != "" {
token, err := generateRecordToken(t, baseApp, userTest.AuthRecord)
require.NoError(t, err)
req.Header.Set("Authorization", token)
}
if userTest.Admin != "" {
token, err := generateAdminToken(t, baseApp, userTest.Admin)
require.NoError(t, err)
req.Header.Set("Authorization", token)
}
server.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, userTest.ExpectedStatus, res.StatusCode)
for _, expectedContent := range userTest.ExpectedContent {
assert.Contains(t, recorder.Body.String(), expectedContent)
}
for _, notExpectedContent := range userTest.NotExpectedContent {
assert.NotContains(t, recorder.Body.String(), notExpectedContent)
}
for event, count := range userTest.ExpectedEvents {
assert.Equal(t, count, counter.Count(event))
}
}
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))
}

Binary file not shown.

View File

@@ -7,7 +7,7 @@
<title>Catalyst</title>
</head>
<body>
<div id="app" class="h-screen w-screen"></div>
<div id="app" class="h-screen w-screen overflow-hidden"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -9,7 +9,7 @@
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path ../.gitignore",
"format": "prettier --write src/"
},
"dependencies": {
@@ -28,6 +28,7 @@
"date-fns": "^3.6.0",
"easymde": "^2.18.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lucide-vue-next": "^0.365.0",
"marked": "^12.0.2",
"pinia": "^2.1.7",
@@ -48,6 +49,7 @@
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@tsconfig/node20": "^20.1.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.11.28",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^8.0.0",

View File

@@ -5,32 +5,32 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%; /* zinc-950 */
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--primary: 47.9 95.8% 53.1%;
--primary-foreground: 26 83.3% 14.1%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%; /* zinc-900 */
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 346.8 77.2% 49.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
--vis-tooltip-background-color: none !important;
@@ -49,38 +49,39 @@
@media (prefers-color-scheme: dark) {
:root {
--background: 20 14.3% 4.1%;
--foreground: 0 0% 95%;
--foreground: 60 9.1% 97.8%;
--card: 24 9.8% 10%;
--card-foreground: 0 0% 95%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--primary: 47.9 95.8% 53.1%;
--primary-foreground: 26 83.3% 14.1%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 346.8 77.2% 49.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 35.5 91.7% 32.9%;
}
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:

View File

@@ -7,17 +7,16 @@ defineProps<{
isPending: boolean
isError: boolean
error: Error | null
value: any
}>()
</script>
<template>
<div v-if="isPending" class="flex justify-center">
<LoaderCircle class="h-16 w-16 animate-spin text-primary" />
<div v-if="isPending" class="flex h-full w-full">
<LoaderCircle class="m-auto h-16 w-16 animate-spin text-primary" />
</div>
<Alert v-else-if="isError" variant="destructive" class="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<slot v-else-if="value" />
<slot v-else />
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<img src="@/assets/flask.svg" alt="Catalyst Logo" :class="cn('dark:hidden', props.class)" />
<img
src="@/assets/flask_white.svg"
alt="Catalyst Logo"
:class="cn('hidden dark:flex', props.class)"
/>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { Trash2 } from 'lucide-vue-next'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { type RouteLocationRaw, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import { handleError } from '@/lib/utils'
const queryClient = useQueryClient()
const router = useRouter()
const props = defineProps<{
collection: string
id: string
name: string
singular: string
queryKey: string[]
to?: RouteLocationRaw
}>()
const isOpen = ref(false)
const deleteMutation = useMutation({
mutationFn: () => pb.collection(props.collection).delete(props.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: props.queryKey })
if (props.to) router.push(props.to)
},
onError: handleError
})
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogTrigger as-child>
<slot>
<Button variant="outline">
<Trash2 class="mr-2 h-4 w-4" />
Delete {{ props.singular }}
</Button>
</slot>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> Delete {{ props.singular }} "{{ props.name }}"</DialogTitle>
<DialogDescription>
Are you sure you want to delete this {{ props.singular }}?</DialogDescription
>
</DialogHeader>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -41,7 +41,10 @@ const {
const searchUserDebounced = debounce(() => refetch(), 300)
watch(searchTerm, () => searchUserDebounced())
watch(
() => searchTerm.value,
() => searchUserDebounced()
)
</script>
<template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue'
import TanView from '@/components/TanView.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import { buttonVariants } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
@@ -32,24 +33,31 @@ const {
<template>
<div class="flex flex-col gap-2">
<Card>
<div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500">
No open tasks
</div>
<PanelListElement v-else v-for="task in tasks" :key="task.id" class="pr-1">
<span>{{ task.name }}</span>
<RouterLink
:to="{
name: 'tickets',
params: { type: task.expand.ticket.type, id: task.expand.ticket.id }
}"
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')"
>
<span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ task.expand.ticket.name }}
<ChevronRight class="ml-2 h-4 w-4" />
</span>
</RouterLink>
</PanelListElement>
<TanView :isError="isError" :isPending="isPending" :error="error">
<div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500">
No open tasks
</div>
<PanelListElement v-else v-for="task in tasks" :key="task.id" class="pr-1">
<span>{{ task.name }}</span>
<RouterLink
:to="{
name: 'tickets',
params: { type: task.expand.ticket.type, id: task.expand.ticket.id }
}"
:class="
cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'h-8 w-full sm:ml-auto sm:w-auto'
)
"
>
<span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ task.expand.ticket.name }}
<ChevronRight class="ml-2 h-4 w-4" />
</span>
</RouterLink>
</PanelListElement>
</TanView>
</Card>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import { buttonVariants } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
@@ -8,8 +8,6 @@ import { ChevronRight } from 'lucide-vue-next'
import { useQuery } from '@tanstack/vue-query'
import { intervalToDuration } from 'date-fns'
import format from 'date-fns/format'
import { computed } from 'vue'
import { pb } from '@/lib/pocketbase'
import type { Ticket } from '@/lib/types'
@@ -44,16 +42,21 @@ const age = (ticket: Ticket) =>
</div>
<PanelListElement v-else v-for="ticket in tickets" :key="ticket.id" class="gap-2 pr-1">
<span>{{ ticket.name }}</span>
<Separator orientation="vertical" class="h-4" />
<Separator orientation="vertical" class="hidden h-4 sm:block" />
<span class="text-sm text-muted-foreground">{{ ticket.expand.type.singular }}</span>
<Separator orientation="vertical" class="h-4" />
<Separator orientation="vertical" class="hidden h-4 sm:block" />
<span class="text-sm text-muted-foreground">Open since {{ age(ticket) }} days</span>
<RouterLink
:to="{
name: 'tickets',
params: { type: ticket.type, id: ticket.id }
}"
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')"
:class="
cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'h-8 w-full sm:ml-auto sm:w-auto'
)
"
>
<span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ ticket.name }}

View File

@@ -43,7 +43,7 @@ const ticketsPerWeek = computed(() => {
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="tickets">
<TanView :isError="isError" :isPending="isPending" :error="error">
<LineChart class="h-40" :data="ticketsPerWeek" index="week" :categories="['count']" />
</TanView>
</template>

View File

@@ -30,7 +30,7 @@ const namedTypes = computed(() => {
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="namedTypes">
<TanView :isError="isError" :isPending="isPending" :error="error">
<div v-if="namedTypes" class="flex flex-1 items-center">
<DonutChart index="plural" type="donut" category="count" :data="namedTypes" />
</div>

View File

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

View File

@@ -2,7 +2,16 @@
import { Checkbox } from '@/components/ui/checkbox'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import isEqual from 'lodash.isequal'
import { onMounted, ref, watch } from 'vue'
import type { JSONSchema } from '@/lib/types'
@@ -24,9 +33,14 @@ onMounted(() => {
})
watch(
formdata,
(newVal) => {
model.value = { ...newVal }
() => formdata.value,
() => {
const normFormdata = JSON.parse(JSON.stringify(formdata.value))
const normModel = JSON.parse(JSON.stringify(model.value))
if (isEqual(normFormdata, normModel)) return
model.value = { ...formdata.value }
},
{ deep: true }
)
@@ -34,6 +48,26 @@ watch(
<template>
<div v-for="(property, key) in schema.properties" :key="key">
<FormField v-if="property.enum" :name="key" v-slot="{ componentField }" v-model="formdata[key]">
<FormItem>
<FormLabel :for="key" class="text-right">
{{ property.title }}
</FormLabel>
<Select :id="key" class="col-span-3" v-bind="componentField">
<SelectTrigger class="font-medium">
<SelectValue :placeholder="'Select a ' + property.title" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="option in property.enum" :key="option" :value="option">
{{ option }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-if="property.type === 'string'"
:name="key"

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,6 @@ import ShortCut from '@/components/ShortCut.vue'
import { ref } from 'vue'
// import { Textarea } from '@/components/ui/textarea'
// import { Input } from '@/components/ui/input'
const model = defineModel({
type: String
})

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex flex-1 items-start justify-start overflow-y-auto overflow-x-hidden">
<slot />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
defineProps<{
small?: boolean
}>()
</script>
<template>
<div :class="cn('mx-auto flex w-full max-w-[72rem] gap-4 p-4', small && 'max-w-[47rem]')">
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
defineProps<{
title?: string
nowrap?: boolean
hideSeparator?: boolean
}>()
</script>
<template>
<div
:class="
cn('flex min-h-14 flex-wrap items-center gap-2 bg-background p-2', nowrap && 'flex-nowrap')
"
>
<h1 v-if="title" class="text-xl font-bold">
{{ title }}
</h1>
<slot />
</div>
<Separator v-if="!hideSeparator" />
</template>

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
<div
:class="
cn(
'flex w-full items-center border-t px-2 py-1 first:rounded-t first:border-none last:rounded-b',
'flex w-full flex-col items-start border-t px-2 py-1 first:rounded-t first:border-none last:rounded-b sm:flex-row sm:items-center',
props.class
)
"

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import IncidentNav from '@/components/sidebar/IncidentNav.vue'
import NavList from '@/components/sidebar/NavList.vue'
import UserDropDown from '@/components/sidebar/UserDropDown.vue'
@@ -7,56 +8,78 @@ import { Separator } from '@/components/ui/separator'
import { Menu } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore()
</script>
<template>
<div class="flex h-[57px] items-center border-b bg-background">
<img
src="@/assets/flask.svg"
alt="Catalyst"
class="h-8 w-8 dark:hidden"
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
/>
<img
src="@/assets/flask_white.svg"
alt="Catalyst"
class="hidden h-8 w-8 dark:flex"
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
/>
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div>
<NavList
class="mt-auto"
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Dashboard',
icon: 'PanelsTopLeft',
variant: 'ghost',
to: '/dashboard'
}
]"
/>
<Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<div class="flex-1" />
<Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<Button
variant="ghost"
@click="catalystStore.toggleSidebar()"
size="sm"
class="m-2 justify-start px-3.5"
<div
:class="
cn(
'flex min-w-48 shrink-0 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
)
"
>
<Menu class="size-4" />
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span>
</Button>
<div class="flex h-[57px] items-center border-b bg-background">
<CatalystLogo
class="size-8"
:class="{
'flex-1': catalystStore.sidebarCollapsed,
'mx-3': !catalystStore.sidebarCollapsed
}"
/>
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div>
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Dashboard',
icon: 'PanelsTopLeft',
variant: 'ghost',
to: '/dashboard'
}
]"
/>
<Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<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 />
<div :class="cn('flex h-14 items-center px-3', !catalystStore.sidebarCollapsed && 'px-2')">
<Button
variant="ghost"
@click="catalystStore.toggleSidebar()"
size="default"
:class="
cn(
'p-0',
catalystStore.sidebarCollapsed && 'w-9',
!catalystStore.sidebarCollapsed && 'w-full justify-start px-3'
)
"
>
<Menu class="size-4" />
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span>
</Button>
</div>
</div>
</template>

View File

@@ -3,29 +3,37 @@ import SideBar from '@/components/layout/SideBar.vue'
import { TooltipProvider } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore()
defineProps<{
showDetails?: boolean
}>()
</script>
<template>
<TooltipProvider :delay-duration="0">
<div class="flex h-full flex-row items-stretch bg-muted/40">
<SideBar />
<div
:class="
cn(
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
'w-full flex-initial border-r sm:w-72',
!showDetails && 'flex',
showDetails && 'hidden sm:flex'
)
"
>
<SideBar />
<div class="flex h-full w-full flex-col">
<slot name="list" />
</div>
</div>
<div class="w-72 flex-initial border-r">
<slot name="list" />
</div>
<div class="flex-1">
<slot name="single" />
<div
:class="
cn('flex-1 overflow-hidden', !showDetails && 'hidden sm:flex', showDetails && 'flex')
"
>
<div class="flex h-full w-full flex-1 flex-col">
<slot name="single" />
</div>
</div>
</div>
</TooltipProvider>

View File

@@ -1,27 +1,15 @@
<script lang="ts" setup>
import SideBar from '@/components/layout/SideBar.vue'
import { TooltipProvider } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore()
</script>
<template>
<TooltipProvider :delay-duration="0">
<div class="flex h-full flex-row items-stretch bg-muted/40">
<div
:class="
cn(
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
)
"
>
<SideBar />
<SideBar />
<div class="flex h-full w-full flex-col">
<slot />
</div>
<slot />
</div>
</TooltipProvider>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Reaction } from '@/lib/types'
import { handleError } from '@/lib/utils'
const router = useRouter()
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
})
onMounted(() => {
if (props.id) {
pb.collection('reactions').subscribe(props.id, (data) => {
if (data.action === 'delete') {
toast({
title: 'Reaction deleted',
description: 'The reaction has been deleted.',
variant: 'destructive'
})
router.push({ name: 'reactions' })
return
}
if (data.action === 'update') {
toast({
title: 'Reaction updated',
description: 'The reaction has been updated.'
})
queryClient.invalidateQueries({ queryKey: ['reactions', props.id] })
}
})
}
})
onUnmounted(() => {
if (props.id) {
pb.collection('reactions').unsubscribe(props.id)
}
})
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader>
<Button @click="router.push({ name: 'reactions' })" variant="outline" class="sm:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
<div class="ml-auto">
<DeleteDialog
v-if="reaction"
collection="reactions"
:id="reaction.id"
:name="reaction.name"
:singular="'Reaction'"
:to="{ name: 'reactions' }"
:queryKey="['reactions']"
/>
</div>
</ColumnHeader>
<ColumnBody v-if="reaction">
<ColumnBodyContainer small>
<ReactionForm :reaction="reaction" @submit="updateReactionMutation.mutate" />
</ColumnBodyContainer>
</ColumnBody>
</TanView>
</template>

View File

@@ -0,0 +1,375 @@
<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 TriggerScheduleFormFields from '@/components/reaction/TriggerScheduleFormFields.vue'
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
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 { useQuery } from '@tanstack/vue-query'
import { defineRule, useForm } from 'vee-validate'
import { computed, ref, watch } from 'vue'
import { pb } from '@/lib/pocketbase'
import type { Reaction } from '@/lib/types'
const submitDisabledReason = ref<string>('')
const props = defineProps<{
reaction?: Reaction
}>()
const emit = defineEmits(['submit'])
const isDemo = ref(false)
const { data: config } = useQuery({
queryKey: ['config'],
queryFn: (): Promise<Record<string, Array<String>>> => pb.send('/api/config', {})
})
watch(
() => config.value,
() => {
if (!config.value) return
if (config.value['flags'].includes('demo')) {
isDemo.value = true
}
},
{ immediate: true }
)
defineRule('required', (value: string) => {
if (!value || !value.length) {
return 'This field is required'
}
return true
})
defineRule('triggerdata.expression', (value: string) => {
if (values.trigger !== 'schedule') {
return true
}
if (!value) {
return 'This field is required'
}
const macros = ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly']
if (macros.includes(value)) {
return true
}
const expression =
/^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/
if (value.match(expression)) {
return true
}
return 'Invalid cron expression'
})
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.expression': 'triggerdata.expression',
'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)
)
}
const updateSubmitDisabledReason = () => {
if (isDemo.value) {
submitDisabledReason.value = 'Reactions cannot be created or edited in demo mode'
return
}
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'
}
})
}
watch(
() => isDemo.value,
() => updateSubmitDisabledReason()
)
watch(
() => props.reaction,
() => updateSubmitDisabledReason(),
{ immediate: true }
)
watch(
() => values,
() => updateSubmitDisabledReason(),
{ deep: true, immediate: true }
)
const onSubmit = handleSubmit((values) => emit('submit', values))
const curlExample = computed(() => {
let cmd = `curl`
if (values.triggerdata.token) {
cmd += ` -H "Authorization: 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 w-full 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="schedule">Schedule</SelectItem>
<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>
<TriggerScheduleFormFields v-if="values.trigger === 'schedule'" />
<TriggerWebhookFormFields v-else-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>
<Alert v-if="isDemo" variant="destructive">
<AlertTitle>Cannot save</AlertTitle>
<AlertDescription>{{ submitDisabledReason }}</AlertDescription>
</Alert>
<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"></slot>
</div>
</form>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ResourceListElement from '@/components/layout/ResourceListElement.vue'
import { Button } from '@/components/ui/button'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Reaction } from '@/lib/types'
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
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 === 'schedule') {
return 'Schedule'
} else 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'
}
}
const openNew = () => {
router.push({ name: 'reactions', params: { id: 'new' } })
}
onMounted(() => {
pb.collection('reactions').subscribe('*', () => {
queryClient.invalidateQueries({ queryKey: ['reactions'] })
})
})
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader title="Reactions">
<div class="ml-auto">
<Button variant="ghost" @click="openNew">New Reaction</Button>
</div>
</ColumnHeader>
<div class="mt-2 flex flex-1 flex-col gap-2 p-2 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>
</TanView>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { Button } from '@/components/ui/button'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
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 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'] })
},
onError: handleError
})
</script>
<template>
<ColumnHeader>
<Button @click="router.push({ name: 'reactions' })" variant="outline" class="sm:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
</ColumnHeader>
<ColumnBody>
<ColumnBodyContainer small>
<ReactionForm @submit="addReactionMutation.mutate" />
</ColumnBodyContainer>
</ColumnBody>
</template>

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More