Compare commits
27 Commits
v0.12.0-rc
...
v0.13.7-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f56a2bdb | ||
|
|
88cc02b350 | ||
|
|
46f7815699 | ||
|
|
ea03a3ed23 | ||
|
|
6346140de5 | ||
|
|
d7bdf1d276 | ||
|
|
1e1022ab15 | ||
|
|
a2dd6c05e6 | ||
|
|
96b7a9604c | ||
|
|
21f1c3d328 | ||
|
|
84ae933cfb | ||
|
|
b929100d30 | ||
|
|
aba3dfaaa4 | ||
|
|
c491f4e810 | ||
|
|
4db718660a | ||
|
|
83251af565 | ||
|
|
a9e885598c | ||
|
|
91429effe2 | ||
|
|
81bfbb2072 | ||
|
|
e9583a29fa | ||
|
|
e2c8f1d223 | ||
|
|
82ad50d228 | ||
|
|
00b7ab585c | ||
|
|
a700791f43 | ||
|
|
1106533892 | ||
|
|
484beacead | ||
|
|
1bf41747c6 |
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
|
||||
|
||||
7
.github/workflows/semantic-pull-request.yml
vendored
@@ -18,7 +18,8 @@ jobs:
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
with:
|
||||
disallowScopes: ".*"
|
||||
subjectPattern: "^(?![A-Z]).+$"
|
||||
scopes: |
|
||||
deps
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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,15 @@ builds:
|
||||
- linux
|
||||
- darwin
|
||||
|
||||
dockers:
|
||||
- ids: [ catalyst ]
|
||||
dockerfile: docker/goreleaser.Dockerfile
|
||||
image_templates:
|
||||
- "ghcr.io/securitybrewery/catalyst:latest"
|
||||
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
|
||||
- "ghcr.io/securitybrewery/catalyst:v{{.Major}}"
|
||||
- "ghcr.io/securitybrewery/catalyst:v{{.Major}}.{{.Minor}}"
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
|
||||
25
Makefile
@@ -1,9 +1,9 @@
|
||||
.PHONY: install
|
||||
install:
|
||||
@echo "Installing..."
|
||||
go install github.com/bombsimon/wsl/v4/cmd...@master
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
go install github.com/daixiang0/gci@latest
|
||||
go install github.com/bombsimon/wsl/v4/cmd...@v4.4.1
|
||||
go install mvdan.cc/gofumpt@v0.6.0
|
||||
go install github.com/daixiang0/gci@v0.13.4
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
@@ -26,6 +26,13 @@ test:
|
||||
go test -v ./...
|
||||
cd ui && bun test
|
||||
|
||||
.PHONY: test-coverage
|
||||
test-coverage:
|
||||
@echo "Testing with coverage..."
|
||||
go test -coverpkg=./... -coverprofile=coverage.out -count 1 ./...
|
||||
go tool cover -func=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
.PHONY: build-ui
|
||||
build-ui:
|
||||
@echo "Building..."
|
||||
@@ -36,12 +43,20 @@ build-ui:
|
||||
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
|
||||
|
||||
.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 . set-feature-flags dev
|
||||
go run . fake-data --users 100 --tickets 10000
|
||||
go run . serve
|
||||
|
||||
.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.
|
||||
69
app/app.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/migrations"
|
||||
"github.com/SecurityBrewery/catalyst/reaction"
|
||||
"github.com/SecurityBrewery/catalyst/webhook"
|
||||
)
|
||||
|
||||
func init() { //nolint:gochecknoinits
|
||||
migrations.Register()
|
||||
}
|
||||
|
||||
func App(dir string, test bool) (*pocketbase.PocketBase, error) {
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
DefaultDev: test || dev(),
|
||||
DefaultDataDir: dir,
|
||||
})
|
||||
|
||||
webhook.BindHooks(app)
|
||||
reaction.BindHooks(app, test)
|
||||
|
||||
app.OnBeforeServe().Add(addRoutes())
|
||||
|
||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
if HasFlag(e.App, "demo") {
|
||||
bindDemoHooks(e.App)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register additional commands
|
||||
app.RootCmd.AddCommand(fakeDataCmd(app))
|
||||
app.RootCmd.AddCommand(setFeatureFlagsCmd(app))
|
||||
app.RootCmd.AddCommand(setAppURL(app))
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := MigrateDBs(app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func bindDemoHooks(app core.App) {
|
||||
app.OnRecordBeforeCreateRequest("files", "reactions").Add(func(e *core.RecordCreateEvent) error {
|
||||
return fmt.Errorf("cannot create %s in demo mode", e.Record.Collection().Name)
|
||||
})
|
||||
app.OnRecordBeforeUpdateRequest("files", "reactions").Add(func(e *core.RecordUpdateEvent) error {
|
||||
return fmt.Errorf("cannot update %s in demo mode", e.Record.Collection().Name)
|
||||
})
|
||||
app.OnRecordBeforeDeleteRequest("files", "reactions").Add(func(e *core.RecordDeleteEvent) error {
|
||||
return fmt.Errorf("cannot delete %s in demo mode", e.Record.Collection().Name)
|
||||
})
|
||||
}
|
||||
|
||||
func dev() bool {
|
||||
return strings.HasPrefix(os.Args[0], os.TempDir())
|
||||
}
|
||||
27
app/fakedata.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/fakedata"
|
||||
)
|
||||
|
||||
func fakeDataCmd(app core.App) *cobra.Command {
|
||||
var userCount, ticketCount int
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "fake-data",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
if err := fakedata.Generate(app, userCount, ticketCount); err != nil {
|
||||
app.Logger().Error(err.Error())
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().IntVar(&userCount, "users", 10, "Number of users to generate")
|
||||
|
||||
cmd.PersistentFlags().IntVar(&ticketCount, "tickets", 100, "Number of tickets to generate")
|
||||
|
||||
return cmd
|
||||
}
|
||||
124
app/flags.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
func setFeatureFlagsCmd(app core.App) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set-feature-flags",
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
if err := SetFlags(app, args); err != nil {
|
||||
app.Logger().Error(err.Error())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setAppURL(app core.App) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set-app-url",
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
if len(args) != 1 {
|
||||
app.Logger().Error("missing app url")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := app.Settings().Clone()
|
||||
if err != nil {
|
||||
app.Logger().Error(err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
settings.Meta.AppUrl = args[0]
|
||||
|
||||
if err := app.Dao().SaveSettings(settings); err != nil {
|
||||
app.Logger().Error(err.Error())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
package main
|
||||
package app
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/ui"
|
||||
)
|
||||
|
||||
//go:embed ui/dist/*
|
||||
var ui embed.FS
|
||||
|
||||
func dev() bool {
|
||||
return strings.HasPrefix(os.Args[0], os.TempDir())
|
||||
}
|
||||
|
||||
func addRoutes() func(*core.ServeEvent) error {
|
||||
return func(e *core.ServeEvent) error {
|
||||
e.Router.GET("/", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/ui/")
|
||||
})
|
||||
e.Router.GET("/ui/*", staticFiles())
|
||||
|
||||
e.Router.GET("/api/config", func(c echo.Context) error {
|
||||
flags, err := flags(e.App)
|
||||
flags, err := Flags(e.App)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -46,17 +38,14 @@ func staticFiles() func(echo.Context) error {
|
||||
return func(c echo.Context) error {
|
||||
if dev() {
|
||||
u, _ := url.Parse("http://localhost:3000/")
|
||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||
|
||||
c.Request().Host = c.Request().URL.Host
|
||||
|
||||
proxy.ServeHTTP(c.Response(), c.Request())
|
||||
httputil.NewSingleHostReverseProxy(u).ServeHTTP(c.Response(), c.Request())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fsys, _ := fs.Sub(ui, "ui/dist")
|
||||
|
||||
return apis.StaticDirectoryHandler(fsys, true)(c)
|
||||
return apis.StaticDirectoryHandler(ui.UI(), true)(c)
|
||||
}
|
||||
}
|
||||
21
app/routes_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
24
docker/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM oven/bun:debian
|
||||
RUN apt-get update && apt-get install -y make
|
||||
COPY .. /tmp/catalyst
|
||||
|
||||
WORKDIR /tmp/catalyst
|
||||
|
||||
RUN make build-ui
|
||||
|
||||
FROM golang:1.23
|
||||
COPY --from=0 /tmp/catalyst /tmp/catalyst
|
||||
|
||||
WORKDIR /tmp/catalyst
|
||||
|
||||
RUN go build -o /usr/local/bin/catalyst
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
COPY --from=1 /usr/local/bin/catalyst /usr/local/bin/catalyst
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME /usr/local/bin/catalyst_data
|
||||
|
||||
CMD ["/usr/local/bin/catalyst", "serve", "--http", "0.0.0.0:8080"]
|
||||
9
docker/goreleaser.Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
COPY catalyst /usr/local/bin/catalyst
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME /usr/local/bin/catalyst_data
|
||||
|
||||
CMD ["/usr/local/bin/catalyst", "serve", "--http", "0.0.0.0:8080"]
|
||||
|
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,219 @@ 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 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 (
|
||||
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)
|
||||
}
|
||||
|
||||
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", "Test Webhook")
|
||||
record.Set("collection", "tickets")
|
||||
record.Set("destination", "http://localhost:8080/webhook")
|
||||
record.Set("name", "Alert Ingest Webhook")
|
||||
record.Set("trigger", "webhook")
|
||||
record.Set("triggerdata", triggerWebhook)
|
||||
record.Set("action", "python")
|
||||
record.Set("actiondata", string(alertIngestActionData))
|
||||
|
||||
return []*models.Record{record}
|
||||
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
|
||||
}
|
||||
90
go.mod
@@ -4,49 +4,53 @@ 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.10
|
||||
github.com/spf13/cobra v1.8.0
|
||||
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 (
|
||||
cloud.google.com/go v0.112.2 // indirect
|
||||
cloud.google.com/go/iam v1.1.7 // indirect
|
||||
cloud.google.com/go/iam v1.1.8 // indirect
|
||||
cloud.google.com/go/storage v1.40.0 // indirect
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.28.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.55.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 // indirect
|
||||
github.com/aws/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
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.4.0 // indirect
|
||||
github.com/fatih/color v1.17.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
|
||||
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.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // 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.3 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.4 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
@@ -56,40 +60,42 @@ 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
|
||||
go.opentelemetry.io/otel v1.25.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.25.0 // indirect
|
||||
gocloud.dev v0.37.0 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/oauth2 v0.19.0 // indirect
|
||||
golang.org/x/crypto v0.24.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/term v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/term v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.20.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/api v0.176.1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240325203815-454cdb8f5daa // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect
|
||||
google.golang.org/grpc v1.63.2 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
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.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.50.2 // indirect
|
||||
modernc.org/libc v1.53.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.29.8 // indirect
|
||||
modernc.org/sqlite v1.30.1 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
189
go.sum
@@ -1,17 +1,17 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw=
|
||||
cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms=
|
||||
cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs=
|
||||
cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w=
|
||||
cloud.google.com/go v0.114.0 h1:OIPFAdfrFDFO2ve2U7r/H5SwSbBzEdrBdE7xkgwc+kY=
|
||||
cloud.google.com/go v0.114.0/go.mod h1:ZV9La5YYxctro1HTPug5lXH/GefROyW8PPD4T8n9J8E=
|
||||
cloud.google.com/go/auth v0.5.1 h1:0QNO7VThG54LUzKiQxv8C6x1YX7lUrzlAa1nVLF8CIw=
|
||||
cloud.google.com/go/auth v0.5.1/go.mod h1:vbZT8GjzDf3AVqCcQmqeeM32U9HBFc32vVVAbwDsa6s=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
|
||||
cloud.google.com/go/compute v1.25.1 h1:ZRpHJedLtTpKgr3RV1Fx23NuaAEN1Zfx9hw1u4aJdjU=
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM=
|
||||
cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA=
|
||||
cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY=
|
||||
cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o=
|
||||
cloud.google.com/go/iam v1.1.8 h1:r7umDwhj+BQyz0ScZMp4QrGXjSTI3ZINnpgU2nlB/K0=
|
||||
cloud.google.com/go/iam v1.1.8/go.mod h1:GvE6lyMmfxXauzNq8NbgJbeVQNspG+tcdL/W8QO1+zE=
|
||||
cloud.google.com/go/storage v1.40.0 h1:VEpDQV5CJxFmJ6ueWNsKxcr1QAYOXEgxDa+sBbJahPw=
|
||||
cloud.google.com/go/storage v1.40.0/go.mod h1:Rrj7/hKlG87BLqDJYtwR0fbPld8uJPbQ2ucUMY7Ir0g=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
||||
@@ -24,42 +24,42 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go v1.51.11 h1:El5VypsMIz7sFwAAj/j06JX9UGs4KAbAIEaZ57bNY4s=
|
||||
github.com/aws/aws-sdk-go v1.51.11/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.28.0 h1:ne6ftNhY0lUvlazMUQF15FF6NH80wKmPRFG7g2q6TCw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.28.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 h1:7Zwtt/lP3KNRkeZre7soMELMGNoBrutx8nobg1jKWmo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15/go.mod h1:436h2adoHb57yd+8W+gYPrrA9U/R/SuAuOO42Ushzhw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.19 h1:+DBS8gJP6VsxYkZ6UEV0/VsRM2rYpbQCYsosW9RRmeQ=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.19/go.mod h1:KzZcioJWzy9oV+oS5CobYXlDtU9+eW7bPG1g7gizTW4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.19 h1:R18G7nBBGLby51CFEqUBFF2IVl7LUdCtYj6iosUwh/0=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.19/go.mod h1:xr9kUMnaLTB866HItT6pg58JgiBP77fSQLBwIa//zk8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6 h1:vVOuhRyslJ6T/HteG71ZWCTas1q2w6f0NKsNbkXHs/A=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6/go.mod h1:jimWaqLiT0sJGLh51dKCLLtExRYPtMU7MpxuCgtbkxg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.25 h1:TnXk6yKqOX25odABhxEnb2fk+92GTbx+VukGDHHu1m0=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.25/go.mod h1:SkT6IPj8n2Na2mZTnVt6d41rGrXMCNaMJwuRpQURaWc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10 h1:LZIUb8sQG2cb89QaVFtMSnER10gyKkqU1k3hP3g9das=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10/go.mod h1:BRIqay//vnIOCZjoXWSLffL2uzbtxEmnSlfbvVh7Z/4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10 h1:HY7CXLA0GiQUo3WYxOP7WYkLcwvRX4cLPf5joUcrQGk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10/go.mod h1:kfRBSxRa+I+VyON7el3wLZdrO91oxUxEwdAaWgFqN90=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5 h1:81KE7vaZzrl7yHBYHVEzYB8sypz11NMOZ40YlWvPxsU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5/go.mod h1:LIt2rg7Mcgn09Ygbdh/RdIm0rQ+3BNkbP1gyVMFtRK0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.10 h1:KPPEosyvs2q6sGbRj/LIGMpqPStDZKtEy/CEbBl+tps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.10/go.mod h1:6pZBDPNlCwrpj79TpGfjgaliXrC3lvoFGMCg7Rtc7p8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7 h1:ZMeFZ5yk+Ek+jNr1+uwCd2tG89t6oTS5yVWpa6yy2es=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7/go.mod h1:mxV05U+4JiHqIpGqqYXOHLPKUC6bDXC44bsUhNjOEwY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5 h1:f9RyWNtS8oH7cZlbn+/JNPpjUk5+5fLd5lM9M0i49Ys=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5/go.mod h1:h5CoMZV2VF297/VLhRhO1WF+XYWOzXo+4HsObA4HjBQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1 h1:6cnno47Me9bRykw9AEv9zkXE+5or7jz8TsskTTccbgc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1/go.mod h1:qmdkIIAC+GCLASF7R2whgNrJADz0QZPX+Seiw/i4S3o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.12 h1:77ORAasgQRiNRi1du4UVmttQg2Wf41WSe7TvpmpmDg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.12/go.mod h1:PsApornkaurUc1DIGUdiBzC19GfF1fy2ZH93O2JWigc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12 h1:kO2J7WMroF/OTHN9WTcUtMjPhJ7ZoNxx0dwv6UCXQgY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12/go.mod h1:mrNxrjYvXaSjZe5fkKaWgDnOQ6BExLn/7Ru9OpRsMPY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.10 h1:1Hmy47QP13NjScoCMOr9kJo/hqKqf+tskyGpxVgNBxU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.10/go.mod h1:8jZvhEt+MemeoHm9P4WFk/AVfIa9sCWL80OAKNDNTCM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.55.2 h1:9UkFXpS7uU7ipUlj2sSkLtIo3Sa+LtbnObBJdx8yjd0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.55.2/go.mod h1:Cijxa/K9vFQ9RPd16rq3cE+0Sg5hvmpEkTo+LThg43E=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.12 h1:FsYii6U+2k8ynYBo+pywlCBY9HNAFRh+iICRHbn+Qyw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.12/go.mod h1:j9Rps+Lcs2A0tYypWsNBeJOjgsIYUf1Styppo9Es0Wo=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 h1:lEE+xEcq3lh9bk362tgErP1+n689q5ERdmTwmF1XT3M=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6/go.mod h1:2tR0x1DCL5IgnVZ1NQNFDNg5/XL/kiQgWI5l7I/N5Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 h1:TSzmuUeruVJ4XWYp3bYzKCXue70ECpJWmbP3UfEvhYY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.13/go.mod h1:FppRtFjBA9mSWTj2cIAWCP66+bbBPMuPpBfWRXC5Yi0=
|
||||
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
|
||||
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||
github.com/brianvoe/gofakeit/v7 v7.0.3 h1:tGCt+eYfhTMWE1ko5G2EO1f/yE44yNpIwUb4h32O0wo=
|
||||
@@ -67,7 +67,7 @@ github.com/brianvoe/gofakeit/v7 v7.0.3/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49j
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -83,16 +83,16 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/ganigeorgiev/fexpr v0.4.0 h1:ojitI+VMNZX/odeNL1x3RzTTE8qAIVvnSSYPNAnQFDI=
|
||||
github.com/ganigeorgiev/fexpr v0.4.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
||||
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -102,8 +102,8 @@ github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRi
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -142,8 +142,8 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA=
|
||||
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
|
||||
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
|
||||
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
@@ -180,8 +180,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.22.10 h1:5iRTl2wGdH/l/IrJKi/gwzMB4t7pF/oLaGX86BQIy4o=
|
||||
github.com/pocketbase/pocketbase v0.22.10/go.mod h1:rk8bn2ywGEC6+bQRfduM8xy0weLVqjDULiMEkgvbpYs=
|
||||
github.com/pocketbase/pocketbase v0.22.14 h1:EI4J1gIxbRgpBWIGokUIhXoeacvehUOZaKeioIMtWxw=
|
||||
github.com/pocketbase/pocketbase v0.22.14/go.mod h1:EXxVz8id6wzqQvNaq3o6doeJqlz8DfLhl//NH0XYLqw=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@@ -190,8 +190,8 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -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,17 +229,19 @@ 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=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
@@ -248,11 +258,11 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
|
||||
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -270,19 +280,19 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -292,33 +302,33 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
|
||||
golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/api v0.176.1 h1:DJSXnV6An+NhJ1J+GWtoF2nHEuqB1VNoTfnIbjNvwD4=
|
||||
google.golang.org/api v0.176.1/go.mod h1:j2MaSDYcvYV1lkZ1+SMW4IeF90SrEyFA+tluDYWRrFg=
|
||||
google.golang.org/api v0.184.0 h1:dmEdk6ZkJNXy1JcDhn/ou0ZUq7n9zropG2/tR4z+RDg=
|
||||
google.golang.org/api v0.184.0/go.mod h1:CeDTtUEiYENAf8PPG5VZW2yNp2VM3VWbCeTioAZBTBA=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20240325203815-454cdb8f5daa h1:ePqxpG3LVx+feAUOx8YmR5T7rc0rdzK8DyxM8cQ9zq0=
|
||||
google.golang.org/genproto v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:CnZenrTdRJb7jc+jOm0Rkywq+9wh0QC4U8tyiRbEPPM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa h1:Jt1XW5PaLXF1/ePZrznsh/aAUvI7Adfc3LY1dAKlzRs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:K4kfzHtI0kqWA79gecJarFtDn/Mls+GxQcg3Zox91Ac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
google.golang.org/genproto v0.0.0-20240604185151-ef581f913117 h1:HCZ6DlkKtCDAtD8ForECsY3tKuaR+p4R3grlK80uCCc=
|
||||
google.golang.org/genproto v0.0.0-20240604185151-ef581f913117/go.mod h1:lesfX/+9iA+3OdqeCpoDddJaNxVB1AB6tD7EfqMmprc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
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.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
|
||||
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
|
||||
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=
|
||||
@@ -328,8 +338,9 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
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=
|
||||
@@ -340,18 +351,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/cc/v4 v4.21.0 h1:D/gLKtcztomvWbsbvBKo3leKQv+86f+DdqEZBBXhnag=
|
||||
modernc.org/cc/v4 v4.21.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.17.0 h1:cX97L5Bv/7PEmyk1oEAD890fQu5/yUQRYeYBsCSnzww=
|
||||
modernc.org/ccgo/v4 v4.17.0/go.mod h1:keES1eiOIBJhbA5qKrV7ADG3w8DsX8G7jfHAT76riOg=
|
||||
modernc.org/cc/v4 v4.21.3 h1:2mhBdWKtivdFlLR1ecKXTljPG1mfvbByX7QKztAIJl8=
|
||||
modernc.org/cc/v4 v4.21.3/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.18.1 h1:1zF5kPBFq/ZVTulBOKgQPQITdOzzyBUfC51gVYP62E4=
|
||||
modernc.org/ccgo/v4 v4.18.1/go.mod h1:ao1fAxf9a2KEOL15WY8+yP3wnpaOpP/QuyFOZ9HJolM=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.50.2 h1:I0+3wlRvXmAEjAJvD7BhP1kmKHwkzV0rOcqFcD85u+0=
|
||||
modernc.org/libc v1.50.2/go.mod h1:Fd8TZdfRorOd1vB0QCtYSHYAuzobS4xS3mhMGUkeVcA=
|
||||
modernc.org/libc v1.53.3 h1:9O0aSLZuHPgp49we24NoFFteRgXNLGBAQ3TODrW3XLg=
|
||||
modernc.org/libc v1.53.3/go.mod h1:kb+Erju4FfHNE59xd2fNpv5CBeAeej6fHbx8p8xaiyI=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
@@ -360,8 +371,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8=
|
||||
modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk=
|
||||
modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk=
|
||||
modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -9,4 +9,8 @@ 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")
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
13
reaction/trigger.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package reaction
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/reaction/trigger/hook"
|
||||
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
|
||||
)
|
||||
|
||||
func BindHooks(pb *pocketbase.PocketBase, test bool) {
|
||||
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))
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
160
testing/testapp.go
Normal file
@@ -0,0 +1,160 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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>
|
||||
352
ui/src/components/reaction/ReactionForm.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue'
|
||||
import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue'
|
||||
import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue'
|
||||
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue'
|
||||
import { 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.token', (value: string) => {
|
||||
return true
|
||||
})
|
||||
|
||||
defineRule('triggerdata.path', (value: string) => {
|
||||
if (values.trigger !== 'webhook') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return 'This field is required'
|
||||
}
|
||||
|
||||
const expression = /^[a-zA-Z0-9-_]+$/
|
||||
|
||||
if (!value.match(expression)) {
|
||||
return 'Invalid path, only letters, numbers, dashes, and underscores are allowed'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
defineRule('triggerdata.collections', (value: string[]) => {
|
||||
if (values.trigger !== 'hook') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return 'This field is required'
|
||||
}
|
||||
|
||||
if (value.length === 0) {
|
||||
return 'At least one collection is required'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
defineRule('triggerdata.events', (value: string[]) => {
|
||||
if (values.trigger !== 'hook') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return 'This field is required'
|
||||
}
|
||||
|
||||
if (value.length === 0) {
|
||||
return 'At least one event is required'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
defineRule('actiondata.script', (value: string) => {
|
||||
if (values.action !== 'python') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return 'This field is required'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
defineRule('actiondata.url', (value: string) => {
|
||||
if (values.action !== 'webhook') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return 'This field is required'
|
||||
}
|
||||
|
||||
if (!(value.startsWith('http://') || value.startsWith('https://'))) {
|
||||
return 'Invalid URL, must start with http:// or https://'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const { handleSubmit, validate, values } = useForm({
|
||||
initialValues: props.reaction || {
|
||||
name: '',
|
||||
trigger: '',
|
||||
triggerdata: {},
|
||||
action: '',
|
||||
actiondata: {}
|
||||
},
|
||||
validationSchema: {
|
||||
name: 'required',
|
||||
trigger: 'required',
|
||||
'triggerdata.token': 'triggerdata.token',
|
||||
'triggerdata.path': 'triggerdata.path',
|
||||
'triggerdata.collections': 'triggerdata.collections',
|
||||
'triggerdata.events': 'triggerdata.events',
|
||||
'actiondata.script': 'actiondata.script',
|
||||
'actiondata.url': 'actiondata.url',
|
||||
action: 'required'
|
||||
}
|
||||
})
|
||||
|
||||
const equalReaction = (values: Reaction, reaction?: Reaction): boolean => {
|
||||
if (!reaction) return false
|
||||
|
||||
return (
|
||||
reaction.name === values.name &&
|
||||
reaction.trigger === values.trigger &&
|
||||
JSON.stringify(reaction.triggerdata) === JSON.stringify(values.triggerdata) &&
|
||||
reaction.action === values.action &&
|
||||
JSON.stringify(reaction.actiondata) === JSON.stringify(values.actiondata)
|
||||
)
|
||||
}
|
||||
|
||||
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="webhook">HTTP / Webhook</SelectItem>
|
||||
<SelectItem value="hook">Collection Hook</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<p>HTTP / Webhook: Receive a HTTP request.</p>
|
||||
<p>Collection Hook: Triggered by a collection and event.</p>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<TriggerWebhookFormFields v-if="values.trigger === 'webhook'" />
|
||||
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
|
||||
|
||||
<div v-if="values.trigger === 'webhook'">
|
||||
<Label for="url">Usage</Label>
|
||||
<Input id="url" readonly :modelValue="curlExample" class="bg-accent" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Action</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<FormField name="action" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="action" class="text-right">Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select id="action" class="col-span-3" v-bind="componentField">
|
||||
<SelectTrigger class="font-medium">
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<p>Python: Execute a Python script.</p>
|
||||
<p>HTTP / Webhook: Send an HTTP request.</p>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<ActionPythonFormFields v-if="values.action === 'python'" />
|
||||
<ActionWebhookFormFields v-else-if="values.action === 'webhook'" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
90
ui/src/components/reaction/ReactionList.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<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 === '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>
|
||||
36
ui/src/components/reaction/TriggerHookFormFields.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import TriggerHookFormFieldCollections from '@/components/reaction/TriggerHookFormFieldCollections.vue'
|
||||
import TriggerHookFormFieldEvents from '@/components/reaction/TriggerHookFormFieldEvents.vue'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField name="triggerdata.collections" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="collections" class="text-right">Collections</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerHookFormFieldCollections id="collections" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> Specify the collections to trigger the reaction. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField name="triggerdata.events" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="events" class="text-right">Events</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerHookFormFieldEvents id="events" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> Specify the events to trigger the reaction. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
47
ui/src/components/reaction/TriggerWebhookFormFields.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField name="triggerdata.token" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="token" class="text-right">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="token"
|
||||
class="col-span-3"
|
||||
v-bind="componentField"
|
||||
placeholder="Enter a token (e.g. 'xyz...')"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Include an authorization token in the request headers.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField name="triggerdata.path" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="path" class="text-right">Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="path"
|
||||
class="col-span-3"
|
||||
v-bind="componentField"
|
||||
placeholder="Enter a path (e.g. 'action1')"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription> Specify the path to trigger the reaction. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import TanView from '@/components/TanView.vue'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -13,7 +12,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
|
||||
import { CircleUser } from 'lucide-vue-next'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { AuthModel } from 'pocketbase'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -24,69 +24,71 @@ defineProps<{
|
||||
|
||||
const variant = 'secondary'
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: user,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => pb.authStore.model
|
||||
})
|
||||
interface User {
|
||||
name: string
|
||||
}
|
||||
|
||||
const user = ref<AuthModel | User>(pb.authStore.model)
|
||||
|
||||
const logout = () => {
|
||||
pb.authStore.clear()
|
||||
window.location.href = '/ui/login'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pb.collection('users')
|
||||
.authRefresh()
|
||||
.catch(() => {
|
||||
pb.authStore.clear()
|
||||
window.location.href = '/ui/login'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TanView :is-error="isError" :is-pending="isPending" :error="error" :value="user">
|
||||
<div class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2">
|
||||
<nav
|
||||
class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div>
|
||||
<Tooltip v-if="isCollapsed" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="
|
||||
cn(buttonVariants({ variant: variant, size: 'icon' }), 'mx-1 h-9 w-9 px-0')
|
||||
"
|
||||
>
|
||||
<CircleUser class="size-4" />
|
||||
<span class="sr-only">{{ user.name }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ user.name }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-else
|
||||
:class="
|
||||
cn(buttonVariants({ variant: variant, size: 'sm' }), 'w-full justify-start')
|
||||
"
|
||||
>
|
||||
<CircleUser class="mr-2 size-4" />
|
||||
<div class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2">
|
||||
<nav
|
||||
v-if="user"
|
||||
class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div>
|
||||
<Tooltip v-if="isCollapsed" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="
|
||||
cn(buttonVariants({ variant: variant, size: 'icon' }), 'mx-1 h-9 w-9 px-0')
|
||||
"
|
||||
>
|
||||
<CircleUser class="size-4" />
|
||||
<span class="sr-only">{{ user.name }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ user.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click="logout"
|
||||
class="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-else
|
||||
:class="cn(buttonVariants({ variant: variant, size: 'sm' }), 'w-full justify-start')"
|
||||
>
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</nav>
|
||||
</div>
|
||||
</TanView>
|
||||
<CircleUser class="mr-2 size-4" />
|
||||
{{ user.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click="logout"
|
||||
class="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||