Compare commits
35 Commits
v0.12.0
...
v0.13.8-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8c92f1f6 | ||
|
|
9285aec468 | ||
|
|
97d0cd3428 | ||
|
|
baba5b7a45 | ||
|
|
d1cf75ab79 | ||
|
|
38a89f2c94 | ||
|
|
8c36ea5243 | ||
|
|
a2bdeecb0d | ||
|
|
42797509f7 | ||
|
|
70ba16a6bd | ||
|
|
f42de34780 | ||
|
|
88f56a2bdb | ||
|
|
88cc02b350 | ||
|
|
46f7815699 | ||
|
|
ea03a3ed23 | ||
|
|
6346140de5 | ||
|
|
d7bdf1d276 | ||
|
|
1e1022ab15 | ||
|
|
a2dd6c05e6 | ||
|
|
96b7a9604c | ||
|
|
21f1c3d328 | ||
|
|
84ae933cfb | ||
|
|
b929100d30 | ||
|
|
aba3dfaaa4 | ||
|
|
c491f4e810 | ||
|
|
4db718660a | ||
|
|
83251af565 | ||
|
|
a9e885598c | ||
|
|
91429effe2 | ||
|
|
81bfbb2072 | ||
|
|
e9583a29fa | ||
|
|
e2c8f1d223 | ||
|
|
82ad50d228 | ||
|
|
00b7ab585c | ||
|
|
a700791f43 |
11
.github/codecov.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
threshold: 5%
|
||||
patch: off
|
||||
comment:
|
||||
layout: diff
|
||||
parsers:
|
||||
go:
|
||||
partials_as_hits: true
|
||||
49
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||
|
||||
7
.github/workflows/goreleaser.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/semantic-pull-request.yml
vendored
@@ -20,6 +20,6 @@ jobs:
|
||||
with:
|
||||
scopes: |
|
||||
deps
|
||||
subjectPattern: "^(?!deps$).+"
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
@@ -34,3 +34,5 @@ dist
|
||||
pb_data
|
||||
catalyst
|
||||
catalyst_data
|
||||
|
||||
coverage.out
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
43
Makefile
@@ -1,9 +1,9 @@
|
||||
.PHONY: install
|
||||
install:
|
||||
@echo "Installing..."
|
||||
go install github.com/bombsimon/wsl/v4/cmd...@master
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
go install github.com/daixiang0/gci@latest
|
||||
go install github.com/bombsimon/wsl/v4/cmd...@v4.4.1
|
||||
go install mvdan.cc/gofumpt@v0.6.0
|
||||
go install github.com/daixiang0/gci@v0.13.4
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
@@ -26,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
|
||||
|
||||
73
README.md
@@ -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.
|
||||
51
app/app.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/migrations"
|
||||
"github.com/SecurityBrewery/catalyst/reaction"
|
||||
"github.com/SecurityBrewery/catalyst/webhook"
|
||||
)
|
||||
|
||||
func init() { //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 {
|
||||
return MigrateDBs(e.App)
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(setupServer(appURL, flags))
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func dev() bool {
|
||||
return strings.HasPrefix(os.Args[0], os.TempDir())
|
||||
}
|
||||
25
app/fakedata.go
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
39
app/migrate_internal_test.go
Normal 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
@@ -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))
|
||||
}
|
||||
88
app/routes.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/ui"
|
||||
)
|
||||
|
||||
func setupServer(appURL string, flags []string) func(e *core.ServeEvent) error {
|
||||
return func(e *core.ServeEvent) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"flags": flags,
|
||||
})
|
||||
})
|
||||
|
||||
return e.App.RefreshSettings()
|
||||
}
|
||||
}
|
||||
|
||||
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 staticFiles() func(echo.Context) error {
|
||||
return func(c echo.Context) error {
|
||||
if dev() {
|
||||
u, _ := url.Parse("http://localhost:3000/")
|
||||
|
||||
c.Request().Host = c.Request().URL.Host
|
||||
|
||||
httputil.NewSingleHostReverseProxy(u).ServeHTTP(c.Response(), c.Request())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return apis.StaticDirectoryHandler(ui.UI(), true)(c)
|
||||
}
|
||||
}
|
||||
21
app/routes_test.go
Normal 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))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
18
docker/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
15
docker/entrypoint.sh
Normal 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"
|
||||
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 262 KiB |
BIN
docs/screenshots/reactions.png
Normal file
|
After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 286 KiB |
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
22
flags.go
@@ -1,22 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/migrations"
|
||||
)
|
||||
|
||||
func flags(app core.App) ([]string, error) {
|
||||
records, err := app.Dao().FindRecordsByExpr(migrations.FeatureCollectionName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var flags []string
|
||||
|
||||
for _, r := range records {
|
||||
flags = append(flags, r.GetString("name"))
|
||||
}
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
14
go.mod
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
37
migrations/6_systemuser.go
Normal 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)
|
||||
}
|
||||
49
migrations/7_search_view.go
Normal 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)
|
||||
}
|
||||
43
migrations/8_dashboardview.go
Normal 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)
|
||||
}
|
||||
28
migrations/9_reactions_update.go
Normal 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)
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
117
reaction/action/python/python.go
Normal 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")
|
||||
}
|
||||
104
reaction/action/python/python_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
20
reaction/action/webhook/payload.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func EncodeBody(requestBody io.Reader) (string, bool) {
|
||||
body, err := io.ReadAll(requestBody)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if utf8.Valid(body) {
|
||||
return string(body), false
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(body), true
|
||||
}
|
||||
55
reaction/action/webhook/payload_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
12
reaction/action/webhook/response.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
IsBase64Encoded bool `json:"isBase64Encoded"`
|
||||
}
|
||||
39
reaction/action/webhook/webhook.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
88
reaction/action/webhook/webhook_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
101
reaction/schedule/schedule.go
Normal 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
@@ -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)
|
||||
}
|
||||
119
reaction/trigger/hook/hook.go
Normal 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
|
||||
}
|
||||
23
reaction/trigger/webhook/request.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Query url.Values `json:"query"`
|
||||
Body string `json:"body"`
|
||||
IsBase64Encoded bool `json:"isBase64Encoded"`
|
||||
}
|
||||
|
||||
// IsJSON checks if the data is JSON.
|
||||
func IsJSON(data []byte) bool {
|
||||
var msg json.RawMessage
|
||||
|
||||
return json.Unmarshal(data, &msg) == nil
|
||||
}
|
||||
45
reaction/trigger/webhook/request_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
147
reaction/trigger/webhook/webhook.go
Normal 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))
|
||||
}
|
||||
62
routes.go
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
//go:embed ui/dist/*
|
||||
var ui embed.FS
|
||||
|
||||
func dev() bool {
|
||||
return strings.HasPrefix(os.Args[0], os.TempDir())
|
||||
}
|
||||
|
||||
func addRoutes() func(*core.ServeEvent) error {
|
||||
return func(e *core.ServeEvent) error {
|
||||
e.Router.GET("/", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/ui/")
|
||||
})
|
||||
e.Router.GET("/ui/*", staticFiles())
|
||||
e.Router.GET("/api/config", func(c echo.Context) error {
|
||||
flags, err := flags(e.App)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"flags": flags,
|
||||
})
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fsys, _ := fs.Sub(ui, "ui/dist")
|
||||
|
||||
return apis.StaticDirectoryHandler(fsys, true)(c)
|
||||
}
|
||||
}
|
||||
249
testing/collection_reaction_test.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
testing/recordingserver.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
BIN
ui/bun.lockb
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
ui/src/components/common/CatalystLogo.vue
Normal 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>
|
||||
74
ui/src/components/common/DeleteDialog.vue
Normal 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>
|
||||
@@ -41,7 +41,10 @@ const {
|
||||
|
||||
const searchUserDebounced = debounce(() => refetch(), 300)
|
||||
|
||||
watch(searchTerm, () => searchUserDebounced())
|
||||
watch(
|
||||
() => searchTerm.value,
|
||||
() => searchUserDebounced()
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
ui/src/components/form/GrowTextarea.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
70
ui/src/components/form/ListInput.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/components/form/TextInput.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string[]
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
placeholder: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const newItem = ref('')
|
||||
|
||||
const updateModelValue = (value: string, index: number) => {
|
||||
const newValue = props.modelValue
|
||||
newValue[index] = value
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
|
||||
const addModelValue = () => {
|
||||
emit('update:modelValue', [...props.modelValue, newItem.value])
|
||||
newItem.value = ''
|
||||
}
|
||||
|
||||
const removeModelValue = (index: number) =>
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((_, i) => i !== index)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="(item, index) in modelValue" :key="item" class="flex flex-row items-center gap-2">
|
||||
<TextInput
|
||||
:modelValue="item"
|
||||
@update:modelValue="updateModelValue($event, index)"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<Button variant="outline" size="icon" @click="removeModelValue(index)" class="shrink-0">
|
||||
<Trash2 class="size-4" />
|
||||
<span class="sr-only">Remove item</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Input v-model="newItem" :placeholder="placeholder" @keydown.enter.prevent="addModelValue" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@click="addModelValue"
|
||||
:disabled="newItem === ''"
|
||||
class="shrink-0"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
<span class="sr-only">Add item</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
102
ui/src/components/form/MultiSelect.vue
Normal 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>
|
||||
75
ui/src/components/form/TextInput.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Save, X } from 'lucide-vue-next'
|
||||
|
||||
import { type HTMLAttributes, ref, watchEffect } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const text = ref(props.modelValue)
|
||||
const editMode = ref(false)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const setValue = () => emit('update:modelValue', text.value)
|
||||
|
||||
watchEffect(() => {
|
||||
if (editMode.value && input.value) {
|
||||
input.value.$el.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const edit = () => {
|
||||
text.value = props.modelValue
|
||||
editMode.value = true
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
text.value = props.modelValue
|
||||
editMode.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button v-if="!editMode" variant="outline" size="icon" @click="edit" class="flex-1">
|
||||
<div class="ml-3 w-full text-start font-normal">
|
||||
{{ text }}
|
||||
</div>
|
||||
</Button>
|
||||
<div v-else class="flex w-full flex-row gap-2">
|
||||
<Input
|
||||
ref="input"
|
||||
v-model="text"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter="setValue"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon" @click="cancel" class="shrink-0">
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Cancel</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@click="setValue"
|
||||
:disabled="text === modelValue"
|
||||
class="shrink-0"
|
||||
>
|
||||
<Save class="size-4" />
|
||||
<span class="sr-only">Save</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
5
ui/src/components/layout/ColumnBody.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-1 items-start justify-start overflow-y-auto overflow-x-hidden">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
13
ui/src/components/layout/ColumnBodyContainer.vue
Normal 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>
|
||||
25
ui/src/components/layout/ColumnHeader.vue
Normal 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>
|
||||
@@ -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
|
||||
)
|
||||
"
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
ui/src/components/reaction/ActionPythonFormFields.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import GrowTextarea from '@/components/form/GrowTextarea.vue'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField name="actiondata.requirements" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel for="requirements" class="text-right">requirements.txt</FormLabel>
|
||||
<FormControl>
|
||||
<GrowTextarea id="requirements" class="col-span-3" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> Specify the Python packages required to run the script. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField name="actiondata.script" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="script" class="text-right">Script</FormLabel>
|
||||
<FormControl>
|
||||
<GrowTextarea id="script" class="col-span-3" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Write a Python script to run when the reaction is triggered.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
40
ui/src/components/reaction/ActionWebhookFormFields.vue
Normal file
@@ -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>
|
||||
104
ui/src/components/reaction/ReactionDisplay.vue
Normal 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>
|
||||
375
ui/src/components/reaction/ReactionForm.vue
Normal 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>
|
||||
92
ui/src/components/reaction/ReactionList.vue
Normal 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>
|
||||
43
ui/src/components/reaction/ReactionNew.vue
Normal 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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import MultiSelect from '@/components/form/MultiSelect.vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const modelValue = defineModel<string[]>({
|
||||
default: []
|
||||
})
|
||||
|
||||
const items = ['Tickets', 'Tasks', 'Comments', 'Timeline', 'Links', 'Files']
|
||||
|
||||
const mapping: Record<string, string> = {
|
||||
tickets: 'Tickets',
|
||||
tasks: 'Tasks',
|
||||
comments: 'Comments',
|
||||
timeline: 'Timeline',
|
||||
links: 'Links',
|
||||
files: 'Files'
|
||||
}
|
||||
|
||||
const niceNames = computed(() => modelValue.value.map((collection) => mapping[collection]))
|
||||
|
||||
const updateModelValue = (values: string[]) => {
|
||||
modelValue.value = values.map(
|
||||
(value) => Object.keys(mapping).find((key) => mapping[key] === value)!
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiSelect
|
||||
:modelValue="niceNames"
|
||||
@update:modelValue="updateModelValue"
|
||||
:items="items"
|
||||
placeholder="Select collections..."
|
||||
/>
|
||||
</template>
|
||||
36
ui/src/components/reaction/TriggerHookFormFieldEvents.vue
Normal file
@@ -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>
|
||||