mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-06 15:22:47 +01:00
Compare commits
32 Commits
v0.13.8-rc
...
a4a4baf88a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a4baf88a | ||
|
|
148f625b00 | ||
|
|
97ebe9f01a | ||
|
|
770390c4ea | ||
|
|
4e03a52b71 | ||
|
|
e475b38ea4 | ||
|
|
e07afd0f3a | ||
|
|
fedda9daaf | ||
|
|
4d844c567c | ||
|
|
9da90e7cc8 | ||
|
|
d9f759c879 | ||
|
|
1e3f2f24dc | ||
|
|
3cb097126c | ||
|
|
df96362c3c | ||
|
|
377d2dad5f | ||
|
|
87fc0e6567 | ||
|
|
06fdae4ab9 | ||
|
|
27129f24d5 | ||
|
|
de105f19c1 | ||
|
|
eba2615ec0 | ||
|
|
f28c238135 | ||
|
|
7de89a752c | ||
|
|
b31f90c3ea | ||
|
|
87175f80a2 | ||
|
|
9a8125635b | ||
|
|
86f4aa1d28 | ||
|
|
7b92d59dff | ||
|
|
6a8c92f1f6 | ||
|
|
9285aec468 | ||
|
|
97d0cd3428 | ||
|
|
baba5b7a45 | ||
|
|
d1cf75ab79 |
141
.github/workflows/ci.yml
vendored
141
.github/workflows/ci.yml
vendored
@@ -5,68 +5,125 @@ on:
|
|||||||
release: { types: [ published ] }
|
release: { types: [ published ] }
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
fmt:
|
generate-go:
|
||||||
name: Fmt
|
name: Generate Go
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with: { go-version: '1.22' }
|
with: { go-version: '1.25' }
|
||||||
- uses: oven-sh/setup-bun@v1
|
- run: make install-golangci-lint generate-go
|
||||||
|
|
||||||
- run: |
|
|
||||||
bun install
|
|
||||||
mkdir -p dist
|
|
||||||
touch dist/index.html
|
|
||||||
working-directory: ui
|
|
||||||
|
|
||||||
- run: make install
|
|
||||||
- run: make fmt
|
|
||||||
|
|
||||||
- run: git diff --exit-code
|
- run: git diff --exit-code
|
||||||
|
|
||||||
lint:
|
generate-ui:
|
||||||
name: Lint
|
name: Generate UI
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
|
||||||
with: { go-version: '1.22' }
|
|
||||||
- uses: oven-sh/setup-bun@v1
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: make install-ui generate-ui
|
||||||
|
- run: git diff --exit-code
|
||||||
|
|
||||||
- run: make build-ui
|
fmt-go:
|
||||||
|
name: Fmt Go
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with: { go-version: '1.22' }
|
with: { go-version: '1.25' }
|
||||||
|
- run: make install-golangci-lint fmt-go
|
||||||
|
- run: git diff --exit-code
|
||||||
|
|
||||||
|
fmt-ui:
|
||||||
|
name: Fmt UI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: oven-sh/setup-bun@v1
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: make install-ui fmt-ui
|
||||||
|
|
||||||
- run: |
|
lint-go:
|
||||||
mkdir -p ui/dist
|
name: Lint Go
|
||||||
touch ui/dist/index.html
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: '1.25' }
|
||||||
|
- run: make install-golangci-lint lint-go
|
||||||
|
|
||||||
|
lint-ui:
|
||||||
|
name: Lint UI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: make install-ui lint-ui
|
||||||
|
|
||||||
|
build-ui:
|
||||||
|
name: Build UI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: make install-ui build-ui
|
||||||
|
|
||||||
|
test-go:
|
||||||
|
name: Test Go
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: '1.25' }
|
||||||
- run: make test-coverage
|
- run: make test-coverage
|
||||||
|
|
||||||
- uses: codecov/codecov-action@v4
|
- uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
files: ./coverage.out
|
files: ./coverage.out
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
test-ui:
|
||||||
|
name: Test UI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: '1.25' }
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: make install-ui test-ui
|
||||||
|
|
||||||
|
test-playwright:
|
||||||
|
name: Test Playwright
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: '1.25' }
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: make install-ui build-ui install-playwright test-playwright
|
||||||
|
|
||||||
|
list-upgrade-test-folders:
|
||||||
|
name: List Upgrade Test Folders
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
folders: ${{ steps.set-dirs.outputs.matrix }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- id: set-dirs
|
||||||
|
run: |
|
||||||
|
FOLDERS=$(ls -d ./testing/data/*/ | xargs -n 1 basename | jq -R . | jq -c -s .)
|
||||||
|
echo "matrix=$FOLDERS" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
test-upgrade-playwright:
|
||||||
|
name: Test Playwright Upgrade
|
||||||
|
needs: list-upgrade-test-folders
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
folder: ${{ fromJson(needs.list-upgrade-test-folders.outputs.folders) }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: '1.25' }
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: mkdir -p catalyst_data
|
||||||
|
- run: cp testing/data/${{ matrix.folder }}/data.db catalyst_data/data.db
|
||||||
|
- run: make install-ui build-ui install-playwright test-playwright
|
||||||
21
.github/workflows/goreleaser.yml
vendored
21
.github/workflows/goreleaser.yml
vendored
@@ -18,20 +18,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with: { go-version: '1.22' }
|
with: { go-version: '1.25' }
|
||||||
- uses: oven-sh/setup-bun@v1
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
|
||||||
- run: make build-ui
|
- run: make install-ui build-ui
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: "securitybrewery"
|
username: "securitybrewery"
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: goreleaser/goreleaser-action@v6
|
- name: Run GoReleaser
|
||||||
with:
|
run: |
|
||||||
distribution: goreleaser
|
docker run --rm --privileged \
|
||||||
version: '~> v2'
|
-v `pwd`:/go/src/github.com/SecurityBrewery/catalyst \
|
||||||
args: release --clean
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
env:
|
-w /go/src/github.com/SecurityBrewery/catalyst \
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
-e CGO_ENABLED=1 \
|
||||||
|
-e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \
|
||||||
|
ghcr.io/goreleaser/goreleaser-cross:v1.25.1 \
|
||||||
|
release --clean
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -35,4 +35,13 @@ pb_data
|
|||||||
catalyst
|
catalyst
|
||||||
catalyst_data
|
catalyst_data
|
||||||
|
|
||||||
|
# ignore changes, needs to be disabled when adding new upgrade tests
|
||||||
|
testing/**/*.db
|
||||||
|
testing/**/*.db-shm
|
||||||
|
testing/**/*.db-wal
|
||||||
|
|
||||||
coverage.out
|
coverage.out
|
||||||
|
|
||||||
|
test-results
|
||||||
|
playwright/playwright-report
|
||||||
|
openapitools.json
|
||||||
@@ -1,27 +1,59 @@
|
|||||||
|
version: "2"
|
||||||
run:
|
run:
|
||||||
go: "1.22"
|
go: "1.22"
|
||||||
timeout: 5m
|
|
||||||
linters:
|
linters:
|
||||||
enable-all: true
|
default: all
|
||||||
disable:
|
disable:
|
||||||
# complexity
|
|
||||||
- maintidx
|
|
||||||
- funlen
|
|
||||||
|
|
||||||
# disable
|
|
||||||
- depguard
|
- depguard
|
||||||
|
- dupl
|
||||||
- err113
|
- err113
|
||||||
- exhaustruct
|
- exhaustruct
|
||||||
- gomnd
|
- funcorder
|
||||||
|
- funlen
|
||||||
|
- gochecknoglobals
|
||||||
|
- godox
|
||||||
|
- gomoddirectives
|
||||||
- ireturn
|
- ireturn
|
||||||
- lll
|
- lll
|
||||||
|
- maintidx
|
||||||
- mnd
|
- mnd
|
||||||
|
- noinlineerr
|
||||||
|
- nonamedreturns
|
||||||
|
- perfsprint
|
||||||
|
- prealloc
|
||||||
|
- tagalign
|
||||||
|
- tagliatelle
|
||||||
- testpackage
|
- testpackage
|
||||||
|
- unparam
|
||||||
- varnamelen
|
- varnamelen
|
||||||
- wrapcheck
|
- wrapcheck
|
||||||
linters-settings:
|
- wsl
|
||||||
gci:
|
exclusions:
|
||||||
sections:
|
generated: lax
|
||||||
- standard
|
presets:
|
||||||
- default
|
- comments
|
||||||
- prefix(github.com/SecurityBrewery/catalyst)
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
|
- std-error-handling
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gci
|
||||||
|
- gofmt
|
||||||
|
- gofumpt
|
||||||
|
- goimports
|
||||||
|
settings:
|
||||||
|
gci:
|
||||||
|
sections:
|
||||||
|
- standard
|
||||||
|
- default
|
||||||
|
- prefix(github.com/SecurityBrewery/catalyst)
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
|||||||
@@ -5,22 +5,94 @@ before:
|
|||||||
- go mod tidy
|
- go mod tidy
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- env:
|
- id: darwin-amd64
|
||||||
- CGO_ENABLED=0
|
main: ./
|
||||||
|
binary: catalyst
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=o64-clang
|
||||||
|
- CXX=o64-clang++
|
||||||
|
flags:
|
||||||
|
- -mod=readonly
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
|
- id: linux-arm64
|
||||||
|
main: ./
|
||||||
|
binary: catalyst
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
goarch:
|
||||||
|
- arm64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=aarch64-linux-gnu-gcc
|
||||||
|
- CXX=aarch64-linux-gnu-g++
|
||||||
|
flags:
|
||||||
|
- -mod=readonly
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
|
- id: linux-amd64
|
||||||
|
main: ./
|
||||||
|
binary: catalyst
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=x86_64-linux-gnu-gcc
|
||||||
|
- CXX=x86_64-linux-gnu-g++
|
||||||
|
flags:
|
||||||
|
- -mod=readonly
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
|
- id: windows-amd64
|
||||||
|
main: ./
|
||||||
|
binary: catalyst
|
||||||
|
goos:
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=x86_64-w64-mingw32-gcc
|
||||||
|
- CXX=x86_64-w64-mingw32-g++
|
||||||
|
flags:
|
||||||
|
- -mod=readonly
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
|
- id: windows-arm64
|
||||||
|
main: ./
|
||||||
|
binary: catalyst
|
||||||
|
goos:
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- arm64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=/llvm-mingw/bin/aarch64-w64-mingw32-gcc
|
||||||
|
- CXX=/llvm-mingw/bin/aarch64-w64-mingw32-g++
|
||||||
|
flags:
|
||||||
|
- -mod=readonly
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
|
|
||||||
dockers:
|
dockers:
|
||||||
- ids: [ catalyst ]
|
- ids: [ linux-amd64 ]
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
image_templates:
|
image_templates:
|
||||||
- "ghcr.io/securitybrewery/catalyst:main"
|
- "ghcr.io/securitybrewery/catalyst:main"
|
||||||
- "{{if not .Prerelease}}ghcr.io/securitybrewery/catalyst:latest{{end}}"
|
- "{{if not .Prerelease}}ghcr.io/securitybrewery/catalyst:latest{{end}}"
|
||||||
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
|
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
|
||||||
|
extra_files:
|
||||||
|
- docker/entrypoint.sh
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- formats: tar.gz
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .ProjectName }}_
|
{{ .ProjectName }}_
|
||||||
@@ -32,7 +104,7 @@ archives:
|
|||||||
# use zip for windows archives
|
# use zip for windows archives
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: zip
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|||||||
226
Makefile
226
Makefile
@@ -1,78 +1,228 @@
|
|||||||
.PHONY: install
|
#########
|
||||||
install:
|
## install
|
||||||
@echo "Installing..."
|
#########
|
||||||
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
|
.PHONY: install-golangci-lint
|
||||||
fmt:
|
install-golangci-lint:
|
||||||
@echo "Formatting..."
|
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.4.0
|
||||||
|
|
||||||
|
.PHONY: install-ui
|
||||||
|
install-ui:
|
||||||
|
cd ui && bun install
|
||||||
|
|
||||||
|
.PHONY: install-playwright
|
||||||
|
install-playwright:
|
||||||
|
cd ui && bun install && bun install:e2e
|
||||||
|
|
||||||
|
#########
|
||||||
|
## fmt
|
||||||
|
#########
|
||||||
|
|
||||||
|
.PHONY: fmt-go
|
||||||
|
fmt-go:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go fmt ./...
|
gofmt -r 'interface{} -> any' -w **/*.go
|
||||||
gci write -s standard -s default -s "prefix(github.com/SecurityBrewery/catalyst)" .
|
golangci-lint fmt ./...
|
||||||
gofumpt -l -w .
|
|
||||||
wsl -fix ./... || true
|
.PHONY: fmt-ui
|
||||||
|
fmt-ui:
|
||||||
cd ui && bun format
|
cd ui && bun format
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: fmt
|
||||||
lint:
|
fmt: fmt-go fmt-ui
|
||||||
|
|
||||||
|
#########
|
||||||
|
## fix
|
||||||
|
#########
|
||||||
|
|
||||||
|
.PHONY: fix-go
|
||||||
|
fix-go:
|
||||||
|
golangci-lint run --fix ./...
|
||||||
|
|
||||||
|
.PHONY: fix-ui
|
||||||
|
fix-ui:
|
||||||
|
cd ui && bun lint --fix
|
||||||
|
|
||||||
|
.PHONY: fix
|
||||||
|
fix: fix-go fix-ui
|
||||||
|
|
||||||
|
#########
|
||||||
|
## lint
|
||||||
|
#########
|
||||||
|
|
||||||
|
.PHONY: lint-go
|
||||||
|
lint-go:
|
||||||
golangci-lint version
|
golangci-lint version
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
.PHONY: lint-ui
|
||||||
|
lint-ui:
|
||||||
|
cd ui && bun lint --max-warnings 0
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint: lint-go lint-ui
|
||||||
|
|
||||||
|
#########
|
||||||
|
## test
|
||||||
|
#########
|
||||||
|
|
||||||
|
.PHONY: test-go
|
||||||
|
test-go:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
.PHONY: test-ui
|
||||||
|
test-ui:
|
||||||
|
cd ui && bun test src
|
||||||
|
|
||||||
|
.PHONY: test-short
|
||||||
|
test-short: test-go test-ui
|
||||||
|
|
||||||
|
.PHONY: test-playwright
|
||||||
|
test-playwright:
|
||||||
|
cd ui && bun test:e2e
|
||||||
|
.PHONY: test-demo-playwright
|
||||||
|
|
||||||
|
test-demo-playwright:
|
||||||
|
cd ui && bun test:e2e:demo
|
||||||
|
|
||||||
|
.PHONY: test-playwright-ui
|
||||||
|
test-playwright-ui:
|
||||||
|
cd ui && bun test:e2e:ui
|
||||||
|
|
||||||
|
.PHONY: test-upgrade-playwright
|
||||||
|
test-upgrade-playwright:
|
||||||
|
./testing/test_all.sh
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test: test-short test-playwright test-upgrade-playwright
|
||||||
@echo "Testing..."
|
|
||||||
go test -v ./...
|
|
||||||
cd ui && bun test
|
|
||||||
|
|
||||||
.PHONY: test-coverage
|
.PHONY: test-coverage
|
||||||
test-coverage:
|
test-coverage:
|
||||||
@echo "Testing with coverage..."
|
|
||||||
go test -coverpkg=./... -coverprofile=coverage.out -count 1 ./...
|
go test -coverpkg=./... -coverprofile=coverage.out -count 1 ./...
|
||||||
go tool cover -func=coverage.out
|
go tool cover -func=coverage.out
|
||||||
go tool cover -html=coverage.out
|
go tool cover -html=coverage.out
|
||||||
|
|
||||||
|
##########
|
||||||
|
## build
|
||||||
|
##########
|
||||||
|
|
||||||
.PHONY: build-ui
|
.PHONY: build-ui
|
||||||
build-ui:
|
build-ui:
|
||||||
@echo "Building..."
|
|
||||||
cd ui && bun install
|
|
||||||
cd ui && bun build-only
|
cd ui && bun build-only
|
||||||
|
touch ui/dist/.keep
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: build-ui
|
build: build-ui
|
||||||
@echo "Building..."
|
|
||||||
go build -o catalyst .
|
go build -o catalyst .
|
||||||
|
|
||||||
|
|
||||||
.PHONY: build-linux
|
.PHONY: build-linux
|
||||||
build-linux: build-ui
|
build-linux: build-ui
|
||||||
@echo "Building..."
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o catalyst .
|
GOOS=linux GOARCH=amd64 go build -o catalyst .
|
||||||
|
|
||||||
.PHONY: docker
|
.PHONY: docker
|
||||||
docker: build-linux
|
docker: build-linux
|
||||||
@echo "Building Docker image..."
|
|
||||||
docker build -f docker/Dockerfile -t catalyst .
|
docker build -f docker/Dockerfile -t catalyst .
|
||||||
|
|
||||||
.PHONY: dev
|
############
|
||||||
dev:
|
## run
|
||||||
@echo "Running..."
|
############
|
||||||
|
|
||||||
|
.PHONY: reset_data
|
||||||
|
reset_data:
|
||||||
rm -rf catalyst_data
|
rm -rf catalyst_data
|
||||||
|
|
||||||
|
.PHONY: copy_existing_data
|
||||||
|
copy_existing_data: reset_data
|
||||||
|
mkdir -p catalyst_data
|
||||||
|
cp testing/data/v0.14.1/data.db catalyst_data/data.db
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev: reset_data
|
||||||
go run . admin create admin@catalyst-soar.com 1234567890
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
go run . set-feature-flags dev
|
|
||||||
go run . fake-data
|
go run . fake-data
|
||||||
go run . serve
|
go run . serve --app-url http://localhost:8090 --flags dev
|
||||||
|
|
||||||
|
.PHONY: dev-proxy-ui
|
||||||
|
dev-proxy-ui: reset_data
|
||||||
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
|
go run . fake-data
|
||||||
|
UI_DEVSERVER=http://localhost:3000 go run . serve --app-url http://localhost:8090 --flags dev --flags demo
|
||||||
|
|
||||||
|
.PHONY: dev-upgrade-proxy-ui
|
||||||
|
dev-upgrade-proxy-ui: copy_existing_data
|
||||||
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
|
UI_DEVSERVER=http://localhost:3000 go run . serve --app-url http://localhost:8090 --flags dev
|
||||||
|
|
||||||
|
.PHONY: dev-10000-proxy-ui
|
||||||
|
dev-10000-proxy-ui: reset_data
|
||||||
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
|
go run . fake-data --users 87 --tickets 12425
|
||||||
|
UI_DEVSERVER=http://localhost:3000 go run . serve --app-url http://localhost:8090 --flags dev
|
||||||
|
|
||||||
|
.PHONY: dev-upgrade
|
||||||
|
dev-upgrade: copy_existing_data
|
||||||
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
|
go run . serve --app-url http://localhost:8090 --flags dev
|
||||||
|
.PHONY: dev-demo
|
||||||
|
dev-demo: copy_existing_data
|
||||||
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
|
go run . serve --app-url http://localhost:8090 --flags demo
|
||||||
|
|
||||||
|
|
||||||
.PHONY: dev-10000
|
.PHONY: dev-10000
|
||||||
dev-10000:
|
dev-10000: reset_data
|
||||||
@echo "Running..."
|
|
||||||
rm -rf catalyst_data
|
|
||||||
go run . admin create admin@catalyst-soar.com 1234567890
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
go run . set-feature-flags dev
|
go run . fake-data --users 87 --tickets 12425
|
||||||
go run . fake-data --users 100 --tickets 10000
|
go run . serve --app-url http://localhost:8090 --flags dev
|
||||||
go run . serve
|
|
||||||
|
.PHONY: default-data
|
||||||
|
default-data:
|
||||||
|
rm -rf catalyst_data
|
||||||
|
go run . default-data
|
||||||
|
|
||||||
.PHONY: serve-ui
|
.PHONY: serve-ui
|
||||||
serve-ui:
|
serve-ui:
|
||||||
cd ui && bun dev --port 3000
|
cd ui && bun dev --port 3000
|
||||||
|
|
||||||
|
#########
|
||||||
|
## generate
|
||||||
|
#########
|
||||||
|
|
||||||
|
.PHONY: sqlc
|
||||||
|
sqlc:
|
||||||
|
rm -rf app/database/sqlc
|
||||||
|
cd app/database && go tool sqlc generate
|
||||||
|
sed -i.bak 's/Queries/ReadQueries/g' app/database/sqlc/read.sql.go
|
||||||
|
rm -f app/database/sqlc/read.sql.go.bak
|
||||||
|
sed -i.bak 's/Queries/WriteQueries/g' app/database/sqlc/write.sql.go
|
||||||
|
rm -f app/database/sqlc/write.sql.go.bak
|
||||||
|
cp app/database/sqlc.db.go.tmpl app/database/sqlc/db.go
|
||||||
|
|
||||||
|
.PHONY: openapi-go
|
||||||
|
openapi-go:
|
||||||
|
go tool oapi-codegen --config=app/openapi/config.yml openapi.yml
|
||||||
|
|
||||||
|
.PHONY: openapi-ui
|
||||||
|
openapi-ui:
|
||||||
|
rm -rf ui/src/client
|
||||||
|
cd ui && bun generate
|
||||||
|
|
||||||
|
.PHONY: openapi
|
||||||
|
openapi: openapi-go openapi-ui
|
||||||
|
|
||||||
|
.PHONY: generate-go
|
||||||
|
generate-go: openapi-go sqlc fmt-go
|
||||||
|
|
||||||
|
.PHONY: generate-ui
|
||||||
|
generate-ui: openapi-ui fmt-ui
|
||||||
|
|
||||||
|
.PHONY: generate
|
||||||
|
generate: generate-go generate-ui
|
||||||
|
|
||||||
|
#########
|
||||||
|
## screenshots
|
||||||
|
#########
|
||||||
|
|
||||||
|
.PHONY: screenshots
|
||||||
|
screenshots:
|
||||||
|
bash ui/screenshot.sh
|
||||||
122
admin.go
Normal file
122
admin.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth/password"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func adminCreate(ctx context.Context, command *cli.Command) error {
|
||||||
|
catalyst, cleanup, err := setup(ctx, command)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to setup catalyst: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if command.Args().Len() != 2 {
|
||||||
|
return errors.New("usage: catalyst admin create <email> <password>")
|
||||||
|
}
|
||||||
|
|
||||||
|
name, email := command.Args().Get(0), command.Args().Get(0)
|
||||||
|
pw := command.Args().Get(1)
|
||||||
|
|
||||||
|
passwordHash, tokenKey, err := password.Hash(pw)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to hash password: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := catalyst.Queries.CreateUser(ctx, sqlc.CreateUserParams{
|
||||||
|
Name: &name,
|
||||||
|
Email: &email,
|
||||||
|
Username: "admin",
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
TokenKey: tokenKey,
|
||||||
|
Active: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := catalyst.Queries.AssignGroupToUser(ctx, sqlc.AssignGroupToUserParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
GroupID: "admin",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Creating admin", "id", user.ID, "email", user.Email)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminSetPassword(ctx context.Context, command *cli.Command) error {
|
||||||
|
catalyst, cleanup, err := setup(ctx, command)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to setup catalyst: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if command.Args().Len() != 2 {
|
||||||
|
return errors.New("usage: catalyst admin set-password <email> <password>")
|
||||||
|
}
|
||||||
|
|
||||||
|
mail := command.Args().Get(0)
|
||||||
|
|
||||||
|
user, err := catalyst.Queries.UserByEmail(ctx, &mail)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, tokenKey, err := password.Hash(command.Args().Get(1))
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to hash password: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := catalyst.Queries.UpdateUser(ctx, sqlc.UpdateUserParams{
|
||||||
|
ID: user.ID,
|
||||||
|
PasswordHash: &passwordHash,
|
||||||
|
TokenKey: &tokenKey,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Setting password for admin", "id", user.ID, "email", user.Email)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminDelete(ctx context.Context, command *cli.Command) error {
|
||||||
|
catalyst, cleanup, err := setup(ctx, command)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to setup catalyst: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if command.Args().Len() != 1 {
|
||||||
|
return errors.New("usage: catalyst admin delete <email>")
|
||||||
|
}
|
||||||
|
|
||||||
|
mail := command.Args().Get(0)
|
||||||
|
|
||||||
|
user, err := catalyst.Queries.UserByEmail(ctx, &mail)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := catalyst.Queries.DeleteUser(ctx, user.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Deleted admin", "id", user.ID, "email", mail)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
112
app/app.go
112
app/app.go
@@ -1,69 +1,75 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/hook"
|
||||||
"github.com/SecurityBrewery/catalyst/migrations"
|
"github.com/SecurityBrewery/catalyst/app/mail"
|
||||||
"github.com/SecurityBrewery/catalyst/reaction"
|
"github.com/SecurityBrewery/catalyst/app/migration"
|
||||||
"github.com/SecurityBrewery/catalyst/webhook"
|
"github.com/SecurityBrewery/catalyst/app/reaction"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/schedule"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/router"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/service"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { //nolint:gochecknoinits
|
type App struct {
|
||||||
migrations.Register()
|
Queries *sqlc.Queries
|
||||||
|
Hooks *hook.Hooks
|
||||||
|
router http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func App(dir string, test bool) (*pocketbase.PocketBase, error) {
|
func New(ctx context.Context, dir string) (*App, func(), error) {
|
||||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
uploader, err := upload.New(dir)
|
||||||
DefaultDev: test || dev(),
|
if err != nil {
|
||||||
DefaultDataDir: dir,
|
return nil, nil, fmt.Errorf("failed to create uploader: %w", err)
|
||||||
})
|
|
||||||
|
|
||||||
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 {
|
queries, cleanup, err := database.DB(ctx, dir)
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app, nil
|
if err := migration.Apply(ctx, queries, dir, uploader); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mailer := mail.New(queries)
|
||||||
|
|
||||||
|
scheduler, err := schedule.New(ctx, queries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cleanup, fmt.Errorf("failed to create scheduler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks := hook.NewHooks()
|
||||||
|
|
||||||
|
service := service.New(queries, hooks, uploader, scheduler)
|
||||||
|
|
||||||
|
router, err := router.New(service, queries, uploader, mailer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reaction.BindHooks(hooks, router, queries, false); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
webhook.BindHooks(hooks, queries)
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
Queries: queries,
|
||||||
|
Hooks: hooks,
|
||||||
|
router: router,
|
||||||
|
}
|
||||||
|
|
||||||
|
return app, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindDemoHooks(app core.App) {
|
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
app.OnRecordBeforeCreateRequest("files", "reactions").Add(func(e *core.RecordCreateEvent) error {
|
a.router.ServeHTTP(w, r)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
16
app/auth/errorjson.go
Normal file
16
app/auth/errorjson.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func unauthorizedJSON(w http.ResponseWriter, msg string) {
|
||||||
|
errorJSON(w, http.StatusUnauthorized, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status": %d, "error": %q, "message": %q}`, status, http.StatusText(status), msg)
|
||||||
|
}
|
||||||
158
app/auth/middleware.go
Normal file
158
app/auth/middleware.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth/usercontext"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bearerPrefix = "Bearer "
|
||||||
|
|
||||||
|
func Middleware(queries *sqlc.Queries) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/config" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizationHeader := r.Header.Get("Authorization")
|
||||||
|
bearerToken := strings.TrimPrefix(authorizationHeader, bearerPrefix)
|
||||||
|
|
||||||
|
user, claims, err := verifyAccessToken(r.Context(), bearerToken, queries)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(r.Context(), "invalid bearer token", "error", err)
|
||||||
|
|
||||||
|
unauthorizedJSON(w, "invalid bearer token")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes, err := scopes(claims)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(r.Context(), "failed to get scopes from token", "error", err)
|
||||||
|
|
||||||
|
unauthorizedJSON(w, "failed to get scopes")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the user in the context
|
||||||
|
r = usercontext.UserRequest(r, user)
|
||||||
|
r = usercontext.PermissionRequest(r, scopes)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateFileScopes(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requiredScopes := []string{"file:read"}
|
||||||
|
if slices.Contains([]string{http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete}, r.Method) {
|
||||||
|
requiredScopes = []string{"file:write"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateScopes(r.Context(), requiredScopes); err != nil {
|
||||||
|
slog.ErrorContext(r.Context(), "failed to validate scopes", "error", err)
|
||||||
|
unauthorizedJSON(w, "missing required scopes")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateScopesStrict(next strictnethttp.StrictHTTPHandlerFunc, _ string) strictnethttp.StrictHTTPHandlerFunc {
|
||||||
|
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (response any, err error) {
|
||||||
|
requiredScopes, err := requiredScopes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get required scopes", "error", err)
|
||||||
|
unauthorizedJSON(w, "failed to get required scopes")
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to get required scopes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateScopes(ctx, requiredScopes); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to validate scopes", "error", err)
|
||||||
|
unauthorizedJSON(w, "missing required scopes")
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("missing required scopes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(ctx, w, r, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogError(next strictnethttp.StrictHTTPHandlerFunc, _ string) strictnethttp.StrictHTTPHandlerFunc {
|
||||||
|
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (response any, err error) {
|
||||||
|
re, err := next(ctx, w, r, request)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "context canceled" {
|
||||||
|
// This is a common error when the request is canceled, e.g., by the client.
|
||||||
|
// We can ignore this error as it does not indicate a problem with the handler.
|
||||||
|
return re, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.ErrorContext(ctx, "handler error", "error", err, "method", r.Method, "path", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return re, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateScopes(ctx context.Context, requiredScopes []string) error {
|
||||||
|
if len(requiredScopes) > 0 {
|
||||||
|
permissions, ok := usercontext.PermissionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("missing permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasScope(permissions, requiredScopes) {
|
||||||
|
return fmt.Errorf("missing required scopes: %v", requiredScopes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredScopes(ctx context.Context) ([]string, error) {
|
||||||
|
requiredScopesValue := ctx.Value(openapi.OAuth2Scopes)
|
||||||
|
if requiredScopesValue == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredScopes, ok := requiredScopesValue.([]string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid required scopes type: %T", requiredScopesValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return requiredScopes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasScope(scopes []string, requiredScopes []string) bool {
|
||||||
|
if slices.Contains(scopes, "admin") {
|
||||||
|
// If the user has admin scope, they can access everything
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range requiredScopes {
|
||||||
|
if !slices.Contains(scopes, s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
188
app/auth/middleware_test.go
Normal file
188
app/auth/middleware_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth/usercontext"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mockHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"message":"OK"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_ValidateScopes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
requiredScopes []string
|
||||||
|
permissions []string
|
||||||
|
next http.HandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
want httptest.ResponseRecorder
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no scopes",
|
||||||
|
args: args{
|
||||||
|
requiredScopes: []string{"user:read"},
|
||||||
|
permissions: []string{},
|
||||||
|
next: mockHandler,
|
||||||
|
},
|
||||||
|
want: httptest.ResponseRecorder{
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
Body: bytes.NewBufferString(`{"error": "Unauthorized", "message": "missing required scopes", "status": 401}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "insufficient scopes",
|
||||||
|
args: args{
|
||||||
|
requiredScopes: []string{"user:write"},
|
||||||
|
permissions: []string{"user:read"},
|
||||||
|
next: mockHandler,
|
||||||
|
},
|
||||||
|
want: httptest.ResponseRecorder{
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
Body: bytes.NewBufferString(`{"error": "Unauthorized", "message": "missing required scopes", "status": 401}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sufficient scopes",
|
||||||
|
args: args{
|
||||||
|
requiredScopes: []string{"user:read"},
|
||||||
|
permissions: []string{"user:read", "user:write"},
|
||||||
|
next: mockHandler,
|
||||||
|
},
|
||||||
|
want: httptest.ResponseRecorder{
|
||||||
|
Code: http.StatusOK,
|
||||||
|
Body: bytes.NewBufferString(`{"message":"OK"}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := ValidateScopesStrict(func(_ context.Context, w http.ResponseWriter, r *http.Request, _ any) (response any, err error) {
|
||||||
|
tt.args.next(w, r)
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
|
//nolint: staticcheck
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), openapi.OAuth2Scopes, tt.args.requiredScopes))
|
||||||
|
r = usercontext.PermissionRequest(r, tt.args.permissions)
|
||||||
|
|
||||||
|
if _, err := handler(r.Context(), w, r, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want.Code, w.Code, "response code should match expected value")
|
||||||
|
assert.JSONEq(t, tt.want.Body.String(), w.Body.String(), "response body should match expected value")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_hasScope(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
scopes []string
|
||||||
|
requiredScopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no scopes",
|
||||||
|
args: args{
|
||||||
|
scopes: []string{},
|
||||||
|
requiredScopes: []string{"user:read"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing required scope",
|
||||||
|
args: args{
|
||||||
|
scopes: []string{"user:read"},
|
||||||
|
requiredScopes: []string{"user:write"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has required scope",
|
||||||
|
args: args{
|
||||||
|
scopes: []string{"user:read", "user:write"},
|
||||||
|
requiredScopes: []string{"user:read"},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equalf(t, tt.want, hasScope(tt.args.scopes, tt.args.requiredScopes), "hasScope(%v, %v)", tt.args.scopes, tt.args.requiredScopes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_requiredScopes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
want []string
|
||||||
|
wantErr assert.ErrorAssertionFunc
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no required scopes",
|
||||||
|
args: args{
|
||||||
|
r: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
wantErr: assert.NoError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid required scopes",
|
||||||
|
args: args{
|
||||||
|
//nolint: staticcheck
|
||||||
|
r: httptest.NewRequest(http.MethodGet, "/", nil).WithContext(context.WithValue(t.Context(), openapi.OAuth2Scopes, []string{"user:read", "user:write"})),
|
||||||
|
},
|
||||||
|
want: []string{"user:read", "user:write"},
|
||||||
|
wantErr: assert.NoError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := requiredScopes(tt.args.r.Context())
|
||||||
|
if !tt.wantErr(t, err, fmt.Sprintf("requiredScopes(%v)", tt.args.r)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equalf(t, tt.want, got, "requiredScopes(%v)", tt.args.r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/auth/password/password.go
Normal file
32
app/auth/password/password.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package password
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Hash(password string) (hashedPassword, tokenKey string, err error) {
|
||||||
|
hashedPasswordB, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenKey, err = GenerateTokenKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(hashedPasswordB), tokenKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateTokenKey() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
67
app/auth/password/password_test.go
Normal file
67
app/auth/password/password_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package password
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantErr require.ErrorAssertionFunc
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Hash valid password",
|
||||||
|
args: args{
|
||||||
|
password: "securePassword123!",
|
||||||
|
},
|
||||||
|
wantErr: require.NoError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Long password",
|
||||||
|
args: args{
|
||||||
|
password: strings.Repeat("a", 75),
|
||||||
|
},
|
||||||
|
wantErr: require.Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gotHashedPassword, gotTokenKey, err := Hash(tt.args.password)
|
||||||
|
tt.wantErr(t, err, "Hash() should not return an error")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEmpty(t, gotHashedPassword, "Hash() gotHashedPassword should not be empty")
|
||||||
|
assert.NotEmpty(t, gotTokenKey, "Hash() gotTokenKey should not be empty")
|
||||||
|
|
||||||
|
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(gotHashedPassword), []byte(tt.args.password)), "Hash() hashed password does not match original password")
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, len(gotTokenKey), 43, "Hash() gotTokenKey should be at least 43 characters long")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTokenKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tokenKey, err := GenerateTokenKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, tokenKey, "GenerateTokenKey() tokenKey should not be empty")
|
||||||
|
assert.GreaterOrEqual(t, len(tokenKey), 43, "GenerateTokenKey() tokenKey should be at least 43 characters long")
|
||||||
|
}
|
||||||
73
app/auth/permission.go
Normal file
73
app/auth/permission.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TicketReadPermission = "ticket:read"
|
||||||
|
TicketWritePermission = "ticket:write"
|
||||||
|
FileReadPermission = "file:read"
|
||||||
|
FileWritePermission = "file:write"
|
||||||
|
TypeReadPermission = "type:read"
|
||||||
|
TypeWritePermission = "type:write"
|
||||||
|
UserReadPermission = "user:read"
|
||||||
|
UserWritePermission = "user:write"
|
||||||
|
GroupReadPermission = "group:read"
|
||||||
|
GroupWritePermission = "group:write"
|
||||||
|
ReactionReadPermission = "reaction:read"
|
||||||
|
ReactionWritePermission = "reaction:write"
|
||||||
|
WebhookReadPermission = "webhook:read"
|
||||||
|
WebhookWritePermission = "webhook:write"
|
||||||
|
SettingsReadPermission = "settings:read"
|
||||||
|
SettingsWritePermission = "settings:write"
|
||||||
|
)
|
||||||
|
|
||||||
|
func All() []string {
|
||||||
|
return []string{
|
||||||
|
TicketReadPermission,
|
||||||
|
TicketWritePermission,
|
||||||
|
FileReadPermission,
|
||||||
|
FileWritePermission,
|
||||||
|
TypeReadPermission,
|
||||||
|
TypeWritePermission,
|
||||||
|
UserReadPermission,
|
||||||
|
UserWritePermission,
|
||||||
|
GroupReadPermission,
|
||||||
|
GroupWritePermission,
|
||||||
|
ReactionReadPermission,
|
||||||
|
ReactionWritePermission,
|
||||||
|
WebhookReadPermission,
|
||||||
|
WebhookWritePermission,
|
||||||
|
SettingsReadPermission,
|
||||||
|
SettingsWritePermission,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromJSONArray(ctx context.Context, permissions string) []string {
|
||||||
|
var result []string
|
||||||
|
if err := json.Unmarshal([]byte(permissions), &result); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to unmarshal permissions", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToJSONArray(ctx context.Context, permissions []string) string {
|
||||||
|
if len(permissions) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(permissions)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to marshal permissions", "error", err)
|
||||||
|
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
84
app/auth/permission_test.go
Normal file
84
app/auth/permission_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFromJSONArray(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
shouldError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid JSON array",
|
||||||
|
input: `["ticket:read", "ticket:write"]`,
|
||||||
|
want: []string{"ticket:read", "ticket:write"},
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty array",
|
||||||
|
input: "[]",
|
||||||
|
want: []string{},
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid JSON",
|
||||||
|
input: "not json",
|
||||||
|
want: nil,
|
||||||
|
shouldError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := FromJSONArray(t.Context(), tt.input)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("FromJSONArray() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToJSONArray(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid permissions array",
|
||||||
|
input: []string{"ticket:read", "ticket:write"},
|
||||||
|
want: `["ticket:read","ticket:write"]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty array",
|
||||||
|
input: []string{},
|
||||||
|
want: "[]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nil array",
|
||||||
|
input: nil,
|
||||||
|
want: "[]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ToJSONArray(t.Context(), tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ToJSONArray() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/auth/resetpassword.go
Normal file
178
app/auth/resetpassword.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth/password"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/mail"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleResetPasswordMail(queries *sqlc.Queries, mailer *mail.Mailer) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type passwordResetData struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(map[string]any{
|
||||||
|
"message": "Password reset email sent when the user exists",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to create response: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data passwordResetData
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "Invalid request, missing email field")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := queries.UserByEmail(r.Context(), &data.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// Do not reveal whether the user exists or not
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to get user: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := settings.Load(r.Context(), queries)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to load settings: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToken, err := createResetToken(&user, settings)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to create reset token: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link := settings.Meta.AppURL + "/ui/password-reset?mail=" + data.Email + "&token=" + resetToken
|
||||||
|
|
||||||
|
subject := settings.Meta.ResetPasswordTemplate.Subject
|
||||||
|
subject = strings.ReplaceAll(subject, "{APP_NAME}", settings.Meta.AppName)
|
||||||
|
|
||||||
|
plainTextBody := `Hello,
|
||||||
|
Thank you for joining us at {APP_NAME}.
|
||||||
|
Click on the link below to verify your email address or copy the token into the app:
|
||||||
|
|
||||||
|
{ACTION_URL}
|
||||||
|
|
||||||
|
Thanks, {APP_NAME} team`
|
||||||
|
plainTextBody = strings.ReplaceAll(plainTextBody, "{ACTION_URL}", link)
|
||||||
|
plainTextBody = strings.ReplaceAll(plainTextBody, "{APP_NAME}", settings.Meta.AppName)
|
||||||
|
|
||||||
|
htmlBody := settings.Meta.ResetPasswordTemplate.Body
|
||||||
|
htmlBody = strings.ReplaceAll(htmlBody, "{ACTION_URL}", link)
|
||||||
|
htmlBody = strings.ReplaceAll(htmlBody, "{APP_NAME}", settings.Meta.AppName)
|
||||||
|
|
||||||
|
if err := mailer.Send(r.Context(), data.Email, subject, plainTextBody, htmlBody); err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to send password reset email: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePassword(queries *sqlc.Queries) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type passwordResetData struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
PasswordConfirm string `json:"password_confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var data passwordResetData
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "Invalid request, missing email or password fields")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Password != data.PasswordConfirm {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "Passwords do not match")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := queries.UserByEmail(r.Context(), &data.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "Invalid or expired reset token")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to get user: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := settings.Load(r.Context(), queries)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to load settings: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := verifyResetToken(data.Token, &user, settings.Meta.AppURL, settings.RecordPasswordResetToken.Secret); err != nil {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "Invalid or expired reset token: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, tokenKey, err := password.Hash(data.Password)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to hash password: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
if _, err := queries.UpdateUser(r.Context(), sqlc.UpdateUserParams{
|
||||||
|
ID: user.ID,
|
||||||
|
PasswordHash: &passwordHash,
|
||||||
|
TokenKey: &tokenKey,
|
||||||
|
LastResetSentAt: &now,
|
||||||
|
}); err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to update password: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(map[string]any{
|
||||||
|
"message": "Password reset successfully",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to create response: "+err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/auth/resettoken_test.go
Normal file
94
app/auth/resettoken_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestService_createResetToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
createUser *sqlc.User
|
||||||
|
tokenDuration time.Duration
|
||||||
|
waitDuration time.Duration
|
||||||
|
verifyUser *sqlc.User
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantErr assert.ErrorAssertionFunc
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid token",
|
||||||
|
args: args{
|
||||||
|
createUser: &sqlc.User{ID: "testuser", Tokenkey: "testtoken"},
|
||||||
|
tokenDuration: time.Hour,
|
||||||
|
waitDuration: 0,
|
||||||
|
verifyUser: &sqlc.User{
|
||||||
|
ID: "testuser",
|
||||||
|
Tokenkey: "testtoken",
|
||||||
|
Updated: mustParse(t, "2006-01-02 15:04:05Z", "2025-06-02 19:18:06.292Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: assert.NoError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired token",
|
||||||
|
args: args{
|
||||||
|
createUser: &sqlc.User{ID: "testuser", Tokenkey: "testtoken"},
|
||||||
|
tokenDuration: 0,
|
||||||
|
waitDuration: time.Second,
|
||||||
|
verifyUser: &sqlc.User{
|
||||||
|
ID: "testuser",
|
||||||
|
Tokenkey: "testtoken",
|
||||||
|
Updated: mustParse(t, "2006-01-02 15:04:05Z", "2025-06-02 19:18:06.292Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: assert.Error,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid token",
|
||||||
|
args: args{
|
||||||
|
createUser: &sqlc.User{ID: "testuser", Tokenkey: "testtoken"},
|
||||||
|
tokenDuration: time.Hour,
|
||||||
|
waitDuration: 0,
|
||||||
|
verifyUser: &sqlc.User{
|
||||||
|
ID: "invaliduser",
|
||||||
|
Tokenkey: "invalidtoken",
|
||||||
|
Updated: mustParse(t, "2006-01-02 15:04:05Z", "2025-06-02 19:18:06.292Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: assert.Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := createResetTokenWithDuration(tt.args.createUser, "", "", tt.args.tokenDuration)
|
||||||
|
require.NoError(t, err, "createResetToken()")
|
||||||
|
|
||||||
|
time.Sleep(tt.args.waitDuration)
|
||||||
|
|
||||||
|
err = verifyResetToken(got, tt.args.verifyUser, "", "")
|
||||||
|
tt.wantErr(t, err, "verifyResetToken()")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParse(t *testing.T, layout, value string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
parsed, err := time.Parse(layout, value)
|
||||||
|
require.NoError(t, err, "mustParse()")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
58
app/auth/server.go
Normal file
58
app/auth/server.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Server(queries *sqlc.Queries, mailer *mail.Mailer) http.Handler {
|
||||||
|
router := chi.NewRouter()
|
||||||
|
|
||||||
|
router.Get("/user", handleUser(queries))
|
||||||
|
router.Post("/local/login", handleLogin(queries))
|
||||||
|
router.Post("/local/reset-password-mail", handleResetPasswordMail(queries, mailer))
|
||||||
|
router.Post("/local/reset-password", handlePassword(queries))
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUser(queries *sqlc.Queries) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authorizationHeader := r.Header.Get("Authorization")
|
||||||
|
bearerToken := strings.TrimPrefix(authorizationHeader, bearerPrefix)
|
||||||
|
|
||||||
|
user, _, err := verifyAccessToken(r.Context(), bearerToken, queries)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = w.Write([]byte("null"))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions, err := queries.ListUserPermissions(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(map[string]any{
|
||||||
|
"user": user,
|
||||||
|
"permissions": permissions,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/auth/server_local.go
Normal file
99
app/auth/server_local.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUserInactive = errors.New("user is inactive")
|
||||||
|
|
||||||
|
func handleLogin(queries *sqlc.Queries) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type loginData struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var data loginData
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
|
unauthorizedJSON(w, "Invalid request")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := loginWithMail(r.Context(), data.Email, data.Password, queries)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrUserInactive) {
|
||||||
|
unauthorizedJSON(w, "User is inactive")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unauthorizedJSON(w, "Login failed")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions, err := queries.ListUserPermissions(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to get user permissions")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := settings.Load(r.Context(), queries)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to load settings")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Duration(settings.RecordAuthToken.Duration) * time.Second
|
||||||
|
|
||||||
|
token, err := CreateAccessToken(r.Context(), user, permissions, duration, queries)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to create login token")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
response := map[string]string{
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, "Failed to encode response")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginWithMail(ctx context.Context, mail, password string, queries *sqlc.Queries) (*sqlc.User, error) {
|
||||||
|
user, err := queries.UserByEmail(ctx, &mail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find user by email %q: %w", mail, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Active {
|
||||||
|
return nil, ErrUserInactive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Passwordhash), []byte(password)); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
225
app/auth/token.go
Normal file
225
app/auth/token.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
purposeAccess = "access"
|
||||||
|
purposeReset = "reset"
|
||||||
|
scopeReset = "reset"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAccessToken(ctx context.Context, user *sqlc.User, permissions []string, duration time.Duration, queries *sqlc.Queries) (string, error) {
|
||||||
|
settings, err := settings.Load(ctx, queries)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to load settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createToken(user, duration, purposeAccess, permissions, settings.Meta.AppURL, settings.RecordAuthToken.Secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createResetToken(user *sqlc.User, settings *settings.Settings) (string, error) {
|
||||||
|
duration := time.Duration(settings.RecordPasswordResetToken.Duration) * time.Second
|
||||||
|
|
||||||
|
return createResetTokenWithDuration(user, settings.Meta.AppURL, settings.RecordPasswordResetToken.Secret, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createResetTokenWithDuration(user *sqlc.User, url, appToken string, duration time.Duration) (string, error) {
|
||||||
|
return createToken(user, duration, purposeReset, []string{scopeReset}, url, appToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createToken(user *sqlc.User, duration time.Duration, purpose string, scopes []string, url, appToken string) (string, error) {
|
||||||
|
if scopes == nil {
|
||||||
|
scopes = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": user.ID,
|
||||||
|
"exp": time.Now().Add(duration).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"iss": url,
|
||||||
|
"purpose": purpose,
|
||||||
|
"scopes": scopes,
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
signingKey := user.Tokenkey + appToken
|
||||||
|
|
||||||
|
return token.SignedString([]byte(signingKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyToken(tokenStr string, user *sqlc.User, url, appToken string) (jwt.MapClaims, error) { //nolint:cyclop
|
||||||
|
signingKey := user.Tokenkey + appToken
|
||||||
|
|
||||||
|
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected algorithm: %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(signingKey), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to verify token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, fmt.Errorf("token invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
iss, err := claims.GetIssuer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get issuer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if iss != url {
|
||||||
|
return nil, fmt.Errorf("token issued by a different server")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := claims.GetSubject()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get subject: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sub != user.ID {
|
||||||
|
return nil, fmt.Errorf("token belongs to a different user")
|
||||||
|
}
|
||||||
|
|
||||||
|
iat, err := claims.GetExpirationTime()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get expiration time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if iat.Before(time.Now()) {
|
||||||
|
return nil, fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyAccessToken(ctx context.Context, bearerToken string, queries *sqlc.Queries) (*sqlc.User, jwt.MapClaims, error) {
|
||||||
|
token, _, err := jwt.NewParser().ParseUnverified(bearerToken, jwt.MapClaims{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := claims.GetSubject()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("token invalid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := queries.GetUser(ctx, sub)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to retrieve user for subject %s: %w", sub, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := settings.Load(ctx, queries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to load settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err = verifyToken(bearerToken, &user, settings.Meta.AppURL, settings.RecordAuthToken.Secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to verify token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hasPurpose(claims, purposeAccess); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to check scopes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyResetToken(tokenStr string, user *sqlc.User, url, appToken string) error {
|
||||||
|
claims, err := verifyToken(tokenStr, user, url, appToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
iat, err := claims.GetIssuedAt()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get issued at: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdated := user.Updated // TODO: create a last reset at column
|
||||||
|
|
||||||
|
if iat.Before(lastUpdated) {
|
||||||
|
return fmt.Errorf("token already used")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hasPurpose(claims, purposeReset); err != nil {
|
||||||
|
return fmt.Errorf("failed to check scopes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPurpose(claim jwt.MapClaims, expectedPurpose string) error {
|
||||||
|
purpose, err := purpose(claim)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get purposes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if purpose != expectedPurpose {
|
||||||
|
return fmt.Errorf("token has wrong purpose: %s, expected: %s", purpose, expectedPurpose)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func purpose(claim jwt.MapClaims) (string, error) {
|
||||||
|
purposeClaim, ok := claim["purpose"]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("no purpose found")
|
||||||
|
}
|
||||||
|
|
||||||
|
purpose, ok := purposeClaim.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("invalid purpose type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return purpose, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopes(claim jwt.MapClaims) ([]string, error) {
|
||||||
|
scopesClaim, ok := claim["scopes"]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no scopes found")
|
||||||
|
}
|
||||||
|
|
||||||
|
scopesSlice, ok := scopesClaim.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid scopes claim type: %T", scopesClaim)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes := make([]string, 0, len(scopesSlice))
|
||||||
|
|
||||||
|
for _, scope := range scopesSlice {
|
||||||
|
scopeStr, ok := scope.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid scope claim element type: %T", scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes = append(scopes, scopeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scopes, nil
|
||||||
|
}
|
||||||
46
app/auth/usercontext/usercontext.go
Normal file
46
app/auth/usercontext/usercontext.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package usercontext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userKey struct{}
|
||||||
|
|
||||||
|
func UserRequest(r *http.Request, user *sqlc.User) *http.Request {
|
||||||
|
return r.WithContext(UserContext(r.Context(), user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserContext(ctx context.Context, user *sqlc.User) context.Context {
|
||||||
|
return context.WithValue(ctx, userKey{}, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserFromContext(ctx context.Context) (*sqlc.User, bool) {
|
||||||
|
user, ok := ctx.Value(userKey{}).(*sqlc.User)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type permissionKey struct{}
|
||||||
|
|
||||||
|
func PermissionRequest(r *http.Request, permissions []string) *http.Request {
|
||||||
|
return r.WithContext(PermissionContext(r.Context(), permissions))
|
||||||
|
}
|
||||||
|
|
||||||
|
func PermissionContext(ctx context.Context, permissions []string) context.Context {
|
||||||
|
return context.WithValue(ctx, permissionKey{}, permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PermissionFromContext(ctx context.Context) ([]string, bool) {
|
||||||
|
permissions, ok := ctx.Value(permissionKey{}).([]string)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions, true
|
||||||
|
}
|
||||||
116
app/auth/usercontext/usercontext_test.go
Normal file
116
app/auth/usercontext/usercontext_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package usercontext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPermissionContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
user *sqlc.User
|
||||||
|
permissions []string
|
||||||
|
wantPerms []string
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Set and get permissions",
|
||||||
|
permissions: []string{"ticket:read", "ticket:write"},
|
||||||
|
wantPerms: []string{"ticket:read", "ticket:write"},
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No permissions set",
|
||||||
|
wantPerms: nil,
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test context functions
|
||||||
|
ctx := PermissionContext(t.Context(), tt.permissions)
|
||||||
|
gotPerms, gotOk := PermissionFromContext(ctx)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotPerms, tt.wantPerms) {
|
||||||
|
t.Errorf("PermissionFromContext() got perms = %v, want %v", gotPerms, tt.wantPerms)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotOk != tt.wantOk {
|
||||||
|
t.Errorf("PermissionFromContext() got ok = %v, want %v", gotOk, tt.wantOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test request functions
|
||||||
|
req := &http.Request{}
|
||||||
|
req = PermissionRequest(req, tt.permissions)
|
||||||
|
gotPerms, gotOk = PermissionFromContext(req.Context())
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotPerms, tt.wantPerms) {
|
||||||
|
t.Errorf("PermissionFromContext() got perms = %v, want %v", gotPerms, tt.wantPerms)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotOk != tt.wantOk {
|
||||||
|
t.Errorf("PermissionFromContext() got ok = %v, want %v", gotOk, tt.wantOk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
user *sqlc.User
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Set and get user",
|
||||||
|
user: &sqlc.User{ID: "test-user"},
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No user set",
|
||||||
|
user: nil,
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test context functions
|
||||||
|
ctx := UserContext(t.Context(), tt.user)
|
||||||
|
gotUser, gotOk := UserFromContext(ctx)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotUser, tt.user) {
|
||||||
|
t.Errorf("UserFromContext() got user = %v, want %v", gotUser, tt.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotOk != tt.wantOk {
|
||||||
|
t.Errorf("UserFromContext() got ok = %v, want %v", gotOk, tt.wantOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test request functions
|
||||||
|
req := &http.Request{}
|
||||||
|
req = UserRequest(req, tt.user)
|
||||||
|
gotUser, gotOk = UserFromContext(req.Context())
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotUser, tt.user) {
|
||||||
|
t.Errorf("UserFromContext() got user = %v, want %v", gotUser, tt.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotOk != tt.wantOk {
|
||||||
|
t.Errorf("UserFromContext() got ok = %v, want %v", gotOk, tt.wantOk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package testing
|
package counter
|
||||||
|
|
||||||
import "sync"
|
import "sync"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package testing
|
package counter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
442
app/data/demo.go
Normal file
442
app/data/demo.go
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brianvoe/gofakeit/v7"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth/password"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minimumUserCount = 1
|
||||||
|
minimumTicketCount = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed scripts/createticket.py
|
||||||
|
createTicketPy string
|
||||||
|
//go:embed scripts/alertingest.py
|
||||||
|
alertIngestPy string
|
||||||
|
//go:embed scripts/assigntickets.py
|
||||||
|
assignTicketsPy string
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateDemoData(ctx context.Context, queries *sqlc.Queries, userCount, ticketCount int) error {
|
||||||
|
if userCount < minimumUserCount {
|
||||||
|
userCount = minimumUserCount
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticketCount < minimumTicketCount {
|
||||||
|
ticketCount = minimumTicketCount
|
||||||
|
}
|
||||||
|
|
||||||
|
types, err := database.PaginateItems(ctx, func(ctx context.Context, offset, limit int64) ([]sqlc.ListTypesRow, error) {
|
||||||
|
return queries.ListTypes(ctx, sqlc.ListTypesParams{Limit: limit, Offset: offset})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list types: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := generateDemoUsers(ctx, queries, userCount, ticketCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create user records: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(types) == 0 {
|
||||||
|
return fmt.Errorf("no types found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return fmt.Errorf("no users found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := generateDemoTickets(ctx, queries, users, types, ticketCount); err != nil {
|
||||||
|
return fmt.Errorf("failed to create ticket records: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := generateDemoReactions(ctx, queries, ticketCount); err != nil {
|
||||||
|
return fmt.Errorf("failed to create reaction records: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := generateDemoGroups(ctx, queries, users, ticketCount); err != nil {
|
||||||
|
return fmt.Errorf("failed to create group records: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateDemoUsers(ctx context.Context, queries *sqlc.Queries, count, ticketCount int) ([]sqlc.User, error) {
|
||||||
|
users := make([]sqlc.User, 0, count)
|
||||||
|
|
||||||
|
// create the test user
|
||||||
|
user, err := queries.GetUser(ctx, "u_test")
|
||||||
|
if err != nil {
|
||||||
|
newUser, err := createTestUser(ctx, queries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
users = append(users, newUser)
|
||||||
|
} else {
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range count - 1 {
|
||||||
|
newUser, err := createDemoUser(ctx, queries, ticketCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
users = append(users, newUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDemoUser(ctx context.Context, queries *sqlc.Queries, ticketCount int) (sqlc.User, error) {
|
||||||
|
username := gofakeit.Username()
|
||||||
|
|
||||||
|
passwordHash, tokenKey, err := password.Hash(gofakeit.Password(true, true, true, true, false, 16))
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
return queries.InsertUser(ctx, sqlc.InsertUserParams{
|
||||||
|
ID: database.GenerateID("u"),
|
||||||
|
Name: pointer.Pointer(gofakeit.Name()),
|
||||||
|
Email: pointer.Pointer(username + "@catalyst-soar.com"),
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
TokenKey: tokenKey,
|
||||||
|
Active: gofakeit.Bool(),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticketCreated = time.Date(2025, 2, 1, 11, 29, 35, 0, time.UTC)
|
||||||
|
|
||||||
|
func generateDemoTickets(ctx context.Context, queries *sqlc.Queries, users []sqlc.User, types []sqlc.ListTypesRow, count int) error { //nolint:cyclop
|
||||||
|
for range count {
|
||||||
|
newTicket, err := createDemoTicket(ctx, queries, random(types), random(users).ID, fakeTicketTitle(), fakeTicketDescription(), count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create ticket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range gofakeit.IntRange(1, 5) {
|
||||||
|
_, err := createDemoComment(ctx, queries, newTicket.ID, random(users).ID, fakeTicketComment(), count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create comment for ticket %s: %w", newTicket.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for range gofakeit.IntRange(1, 5) {
|
||||||
|
_, err := createDemoTimeline(ctx, queries, newTicket.ID, fakeTicketTimelineMessage(), count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create timeline for ticket %s: %w", newTicket.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for range gofakeit.IntRange(1, 5) {
|
||||||
|
_, err := createDemoTask(ctx, queries, newTicket.ID, random(users).ID, fakeTicketTask(), count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create task for ticket %s: %w", newTicket.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for range gofakeit.IntRange(1, 5) {
|
||||||
|
_, err := createDemoLink(ctx, queries, newTicket.ID, random([]string{"Blog", "Forum", "Wiki", "Documentation"}), gofakeit.URL(), count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create link for ticket %s: %w", newTicket.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDemoTicket(ctx context.Context, queries *sqlc.Queries, ticketType sqlc.ListTypesRow, userID, name, description string, ticketCount int) (sqlc.Ticket, error) {
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
ticket, err := queries.InsertTicket(
|
||||||
|
ctx,
|
||||||
|
sqlc.InsertTicketParams{
|
||||||
|
ID: database.GenerateID(ticketType.Singular),
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Open: gofakeit.Bool(),
|
||||||
|
Owner: &userID,
|
||||||
|
Schema: marshal(map[string]any{"type": "object", "properties": map[string]any{"tlp": map[string]any{"title": "TLP", "type": "string"}}}),
|
||||||
|
State: marshal(map[string]any{"severity": "Medium"}),
|
||||||
|
Type: ticketType.ID,
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.Ticket{}, fmt.Errorf("failed to create ticket for user %s: %w", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDemoComment(ctx context.Context, queries *sqlc.Queries, ticketID, userID, message string, ticketCount int) (*sqlc.Comment, error) {
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
comment, err := queries.InsertComment(ctx, sqlc.InsertCommentParams{
|
||||||
|
ID: database.GenerateID("c"),
|
||||||
|
Ticket: ticketID,
|
||||||
|
Author: userID,
|
||||||
|
Message: message,
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create comment for ticket %s: %w", ticketID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDemoTimeline(ctx context.Context, queries *sqlc.Queries, ticketID, message string, ticketCount int) (*sqlc.Timeline, error) {
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
timeline, err := queries.InsertTimeline(ctx, sqlc.InsertTimelineParams{
|
||||||
|
ID: database.GenerateID("tl"),
|
||||||
|
Ticket: ticketID,
|
||||||
|
Message: message,
|
||||||
|
Time: ticketCreated,
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create timeline for ticket %s: %w", ticketID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &timeline, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDemoTask(ctx context.Context, queries *sqlc.Queries, ticketID, userID, name string, ticketCount int) (*sqlc.Task, error) {
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
task, err := queries.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||||
|
ID: database.GenerateID("t"),
|
||||||
|
Ticket: ticketID,
|
||||||
|
Owner: &userID,
|
||||||
|
Name: name,
|
||||||
|
Open: gofakeit.Bool(),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create task for ticket %s: %w", ticketID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDemoLink(ctx context.Context, queries *sqlc.Queries, ticketID, name, url string, ticketCount int) (*sqlc.Link, error) {
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
link, err := queries.InsertLink(ctx, sqlc.InsertLinkParams{
|
||||||
|
ID: database.GenerateID("l"),
|
||||||
|
Ticket: ticketID,
|
||||||
|
Name: name,
|
||||||
|
Url: url,
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create link for ticket %s: %w", ticketID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateDemoReactions(ctx context.Context, queries *sqlc.Queries, ticketCount int) error {
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
_, err := queries.InsertReaction(ctx, sqlc.InsertReactionParams{
|
||||||
|
ID: "r-schedule",
|
||||||
|
Name: "Create New Ticket",
|
||||||
|
Trigger: "schedule",
|
||||||
|
Triggerdata: marshal(map[string]any{"expression": "12 * * * *"}),
|
||||||
|
Action: "python",
|
||||||
|
Actiondata: marshal(map[string]any{
|
||||||
|
"requirements": "requests",
|
||||||
|
"script": createTicketPy,
|
||||||
|
}),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create reaction for schedule trigger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, updated = dates(ticketCount)
|
||||||
|
|
||||||
|
_, err = queries.InsertReaction(ctx, sqlc.InsertReactionParams{
|
||||||
|
ID: "r-webhook",
|
||||||
|
Name: "Alert Ingest Webhook",
|
||||||
|
Trigger: "webhook",
|
||||||
|
Triggerdata: marshal(map[string]any{"token": "1234567890", "path": "webhook"}),
|
||||||
|
Action: "python",
|
||||||
|
Actiondata: marshal(map[string]any{
|
||||||
|
"requirements": "requests",
|
||||||
|
"script": alertIngestPy,
|
||||||
|
}),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create reaction for webhook trigger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, updated = dates(ticketCount)
|
||||||
|
|
||||||
|
_, err = queries.InsertReaction(ctx, sqlc.InsertReactionParams{
|
||||||
|
ID: "r-hook",
|
||||||
|
Name: "Assign new Tickets",
|
||||||
|
Trigger: "hook",
|
||||||
|
Triggerdata: marshal(map[string]any{"collections": []any{"tickets"}, "events": []any{"create"}}),
|
||||||
|
Action: "python",
|
||||||
|
Actiondata: marshal(map[string]any{
|
||||||
|
"requirements": "requests",
|
||||||
|
"script": assignTicketsPy,
|
||||||
|
}),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create reaction for hook trigger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateDemoGroups(ctx context.Context, queries *sqlc.Queries, users []sqlc.User, ticketCount int) error { //nolint:cyclop
|
||||||
|
created, updated := dates(ticketCount)
|
||||||
|
|
||||||
|
_, err := queries.InsertGroup(ctx, sqlc.InsertGroupParams{
|
||||||
|
ID: "team-ir",
|
||||||
|
Name: "IR Team",
|
||||||
|
Permissions: auth.ToJSONArray(ctx, []string{}),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create IR team group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, updated = dates(ticketCount)
|
||||||
|
|
||||||
|
_, err = queries.InsertGroup(ctx, sqlc.InsertGroupParams{
|
||||||
|
ID: "team-seceng",
|
||||||
|
Name: "Security Engineering Team",
|
||||||
|
Permissions: auth.ToJSONArray(ctx, []string{}),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create IR team group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, updated = dates(ticketCount)
|
||||||
|
|
||||||
|
_, err = queries.InsertGroup(ctx, sqlc.InsertGroupParams{
|
||||||
|
ID: "team-security",
|
||||||
|
Name: "Security Team",
|
||||||
|
Permissions: auth.ToJSONArray(ctx, []string{}),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create security team group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, updated = dates(ticketCount)
|
||||||
|
|
||||||
|
_, err = queries.InsertGroup(ctx, sqlc.InsertGroupParams{
|
||||||
|
ID: "g-engineer",
|
||||||
|
Name: "Engineer",
|
||||||
|
Permissions: auth.ToJSONArray(ctx, []string{"reaction:read", "reaction:write"}),
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create analyst group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
group := gofakeit.RandomString([]string{"team-seceng", "team-ir"})
|
||||||
|
if user.ID == "u_test" {
|
||||||
|
group = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := queries.AssignGroupToUser(ctx, sqlc.AssignGroupToUserParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
GroupID: group,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to assign group %s to user %s: %w", group, user.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = queries.AssignParentGroup(ctx, sqlc.AssignParentGroupParams{
|
||||||
|
ParentGroupID: "team-ir",
|
||||||
|
ChildGroupID: "analyst",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to assign parent group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = queries.AssignParentGroup(ctx, sqlc.AssignParentGroupParams{
|
||||||
|
ParentGroupID: "team-seceng",
|
||||||
|
ChildGroupID: "g-engineer",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to assign parent group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = queries.AssignParentGroup(ctx, sqlc.AssignParentGroupParams{
|
||||||
|
ParentGroupID: "team-ir",
|
||||||
|
ChildGroupID: "team-security",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to assign parent group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = queries.AssignParentGroup(ctx, sqlc.AssignParentGroupParams{
|
||||||
|
ParentGroupID: "team-seceng",
|
||||||
|
ChildGroupID: "team-security",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to assign parent group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeksAgo(c int) time.Time {
|
||||||
|
return time.Now().UTC().AddDate(0, 0, -7*c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dates(ticketCount int) (time.Time, time.Time) {
|
||||||
|
const ticketsPerWeek = 10
|
||||||
|
|
||||||
|
weeks := ticketCount / ticketsPerWeek
|
||||||
|
|
||||||
|
created := gofakeit.DateRange(weeksAgo(1), weeksAgo(weeks+1)).UTC()
|
||||||
|
updated := gofakeit.DateRange(created, time.Now()).UTC()
|
||||||
|
|
||||||
|
return created, updated
|
||||||
|
}
|
||||||
26
app/data/demo_test.go
Normal file
26
app/data/demo_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package data_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/data"
|
||||||
|
catalystTesting "github.com/SecurityBrewery/catalyst/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app, cleanup, _ := catalystTesting.App(t)
|
||||||
|
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
_ = app.Queries.DeleteUser(t.Context(), "u_admin")
|
||||||
|
_ = app.Queries.DeleteUser(t.Context(), "u_bob_analyst")
|
||||||
|
_ = app.Queries.DeleteGroup(t.Context(), "g_admin")
|
||||||
|
_ = app.Queries.DeleteGroup(t.Context(), "g_analyst")
|
||||||
|
|
||||||
|
err := data.GenerateDemoData(t.Context(), app.Queries, 4, 4)
|
||||||
|
require.NoError(t, err, "failed to generate fake data")
|
||||||
|
}
|
||||||
20
app/data/scripts/alertingest.py
Normal file
20
app/data/scripts/alertingest.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Parse the event from the webhook payload
|
||||||
|
event = json.loads(sys.argv[1])
|
||||||
|
body = json.loads(event["body"])
|
||||||
|
|
||||||
|
url = os.environ["CATALYST_APP_URL"]
|
||||||
|
header = {"Authorization": "Bearer " + os.environ["CATALYST_TOKEN"]}
|
||||||
|
|
||||||
|
# Create a new ticket
|
||||||
|
requests.post(url + "/api/tickets", headers=header, json={
|
||||||
|
"name": body["name"],
|
||||||
|
"type": "alert",
|
||||||
|
"open": True,
|
||||||
|
})
|
||||||
21
app/data/scripts/assigntickets.py
Normal file
21
app/data/scripts/assigntickets.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Parse the ticket from the input
|
||||||
|
ticket = json.loads(sys.argv[1])
|
||||||
|
|
||||||
|
url = os.environ["CATALYST_APP_URL"]
|
||||||
|
header = {"Authorization": "Bearer " + os.environ["CATALYST_TOKEN"]}
|
||||||
|
|
||||||
|
# Get a random user
|
||||||
|
users = requests.get(url + "/api/users", headers=header).json()
|
||||||
|
random_user = random.choice(users)
|
||||||
|
|
||||||
|
# Assign the ticket to the random user
|
||||||
|
requests.patch(url + "/api/tickets/" + ticket["record"]["id"], headers=header, json={
|
||||||
|
"owner": random_user["id"]
|
||||||
|
})
|
||||||
20
app/data/scripts/createticket.py
Normal file
20
app/data/scripts/createticket.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = os.environ["CATALYST_APP_URL"]
|
||||||
|
header = {"Authorization": "Bearer " + os.environ["CATALYST_TOKEN"]}
|
||||||
|
|
||||||
|
newtickets = requests.get(url + "/api/tickets?limit=3", headers=header).json()
|
||||||
|
for ticket in newtickets:
|
||||||
|
requests.delete(url + "/api/tickets/" + ticket["id"], headers=header)
|
||||||
|
|
||||||
|
# Create a new ticket
|
||||||
|
requests.post(url + "/api/tickets", headers=header, json={
|
||||||
|
"name": "New Ticket",
|
||||||
|
"type": "alert",
|
||||||
|
"open": True,
|
||||||
|
})
|
||||||
21
app/data/scripts/upgradetest.py
Normal file
21
app/data/scripts/upgradetest.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pocketbase import PocketBase
|
||||||
|
|
||||||
|
# Connect to the PocketBase server
|
||||||
|
client = PocketBase(os.environ["CATALYST_APP_URL"])
|
||||||
|
client.auth_store.save(token=os.environ["CATALYST_TOKEN"])
|
||||||
|
|
||||||
|
newtickets = client.collection("tickets").get_list(1, 200, {"filter": 'name = "New Ticket"'})
|
||||||
|
for ticket in newtickets.items:
|
||||||
|
client.collection("tickets").delete(ticket.id)
|
||||||
|
|
||||||
|
# Create a new ticket
|
||||||
|
client.collection("tickets").create({
|
||||||
|
"name": "New Ticket",
|
||||||
|
"type": "alert",
|
||||||
|
"open": True,
|
||||||
|
})
|
||||||
227
app/data/testdata.go
Normal file
227
app/data/testdata.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdminEmail = "admin@catalyst-soar.com"
|
||||||
|
AnalystEmail = "analyst@catalyst-soar.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultTestData(t *testing.T, dir string, queries *sqlc.Queries) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
parseTime := func(s string) time.Time {
|
||||||
|
t, _ := time.Parse(time.RFC3339Nano, s)
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// Insert users
|
||||||
|
_, err := queries.InsertUser(ctx, sqlc.InsertUserParams{
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Email: pointer.Pointer("analyst@catalyst-soar.com"),
|
||||||
|
Username: "u_bob_analyst",
|
||||||
|
Name: pointer.Pointer("Bob Analyst"),
|
||||||
|
PasswordHash: "$2a$10$ZEHNh9ZKJ81N717wovDnMuLwZOLa6.g22IRzRr4goG6zGN.57UzJG",
|
||||||
|
TokenKey: "z3Jj8bbzcq_cSZs07XKoGlB0UtvmQiphHgwNkE4akoY=",
|
||||||
|
Active: true,
|
||||||
|
ID: "u_bob_analyst",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert analyst user")
|
||||||
|
|
||||||
|
_, err = queries.InsertUser(ctx, sqlc.InsertUserParams{
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Email: pointer.Pointer("admin@catalyst-soar.com"),
|
||||||
|
Username: "u_admin",
|
||||||
|
Name: pointer.Pointer("Admin User"),
|
||||||
|
PasswordHash: "$2a$10$Z3/0HHWau6oi1t1aRPiI0uiVOWI.IosTAYEL0DJ2XJaalP9kesgBa",
|
||||||
|
TokenKey: "5BWDKLIAn3SQkpQlBUGrS_XEbFf91DsDpuh_Xmt4Nwg=",
|
||||||
|
Active: true,
|
||||||
|
ID: "u_admin",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert admin user")
|
||||||
|
|
||||||
|
// Insert webhooks
|
||||||
|
_, err = queries.InsertWebhook(ctx, sqlc.InsertWebhookParams{
|
||||||
|
ID: "w_test_webhook",
|
||||||
|
Name: "Test Webhook",
|
||||||
|
Collection: "tickets",
|
||||||
|
Destination: "https://example.com",
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert webhook")
|
||||||
|
|
||||||
|
// Insert types
|
||||||
|
_, err = queries.InsertType(ctx, sqlc.InsertTypeParams{
|
||||||
|
ID: "test-type",
|
||||||
|
Singular: "Test",
|
||||||
|
Plural: "Tests",
|
||||||
|
Schema: []byte(`{}`),
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert type")
|
||||||
|
|
||||||
|
// Insert tickets
|
||||||
|
_, err = queries.InsertTicket(ctx, sqlc.InsertTicketParams{
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Description: "This is a test ticket.",
|
||||||
|
ID: "test-ticket",
|
||||||
|
Name: "Test Ticket",
|
||||||
|
Open: true,
|
||||||
|
Owner: pointer.Pointer("u_bob_analyst"),
|
||||||
|
Schema: json.RawMessage(`{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`),
|
||||||
|
State: json.RawMessage(`{"tlp":"AMBER"}`),
|
||||||
|
Type: "incident",
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert ticket")
|
||||||
|
|
||||||
|
// Insert tasks
|
||||||
|
_, err = queries.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
ID: "k_test_task",
|
||||||
|
Name: "Test Task",
|
||||||
|
Open: true,
|
||||||
|
Owner: pointer.Pointer("u_bob_analyst"),
|
||||||
|
Ticket: "test-ticket",
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert task")
|
||||||
|
|
||||||
|
// Insert comments
|
||||||
|
_, err = queries.InsertComment(ctx, sqlc.InsertCommentParams{
|
||||||
|
Author: "u_bob_analyst",
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
ID: "c_test_comment",
|
||||||
|
Message: "Initial comment on the test ticket.",
|
||||||
|
Ticket: "test-ticket",
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert comment")
|
||||||
|
|
||||||
|
// Insert timeline
|
||||||
|
_, err = queries.InsertTimeline(ctx, sqlc.InsertTimelineParams{
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
ID: "h_test_timeline",
|
||||||
|
Message: "Initial timeline entry.",
|
||||||
|
Ticket: "test-ticket",
|
||||||
|
Time: parseTime("2023-01-01T00:00:00Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert timeline entry")
|
||||||
|
|
||||||
|
// Insert links
|
||||||
|
_, err = queries.InsertLink(ctx, sqlc.InsertLinkParams{
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
ID: "l_test_link",
|
||||||
|
Name: "Catalyst",
|
||||||
|
Ticket: "test-ticket",
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Url: "https://example.com",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert link")
|
||||||
|
|
||||||
|
// Insert files
|
||||||
|
_, err = queries.InsertFile(ctx, sqlc.InsertFileParams{
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
ID: "b_test_file",
|
||||||
|
Name: "hello.txt",
|
||||||
|
Size: 5,
|
||||||
|
Ticket: "test-ticket",
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Blob: "hello_a20DUE9c77rj.txt",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert file")
|
||||||
|
|
||||||
|
// Insert features
|
||||||
|
_, err = queries.CreateFeature(ctx, "dev")
|
||||||
|
require.NoError(t, err, "failed to insert feature 'dev'")
|
||||||
|
|
||||||
|
// Insert reactions
|
||||||
|
_, err = queries.InsertReaction(ctx, sqlc.InsertReactionParams{
|
||||||
|
ID: "r-test-webhook",
|
||||||
|
Name: "Reaction",
|
||||||
|
Action: "python",
|
||||||
|
Actiondata: []byte(`{"requirements":"requests","script":"print('Hello, World!')"}`),
|
||||||
|
Trigger: "webhook",
|
||||||
|
Triggerdata: []byte(`{"token":"1234567890","path":"test"}`),
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert reaction")
|
||||||
|
|
||||||
|
_, err = queries.InsertReaction(ctx, sqlc.InsertReactionParams{
|
||||||
|
ID: "r-test-proxy",
|
||||||
|
Action: "webhook",
|
||||||
|
Name: "Reaction",
|
||||||
|
Actiondata: []byte(`{"headers":{"Content-Type":"application/json"},"url":"http://127.0.0.1:12345/webhook"}`),
|
||||||
|
Trigger: "webhook",
|
||||||
|
Triggerdata: []byte(`{"path":"test2"}`),
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert reaction")
|
||||||
|
|
||||||
|
_, err = queries.InsertReaction(ctx, sqlc.InsertReactionParams{
|
||||||
|
ID: "r-test-hook",
|
||||||
|
Name: "Hook",
|
||||||
|
Action: "python",
|
||||||
|
Actiondata: []byte(`{"requirements":"requests","script":"import requests\nrequests.post('http://127.0.0.1:12346/test', json={'test':True})"}`),
|
||||||
|
Trigger: "hook",
|
||||||
|
Triggerdata: json.RawMessage(`{"collections":["tickets"],"events":["create"]}`),
|
||||||
|
Created: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
Updated: parseTime("2025-06-21T22:21:26.271Z"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to insert reaction")
|
||||||
|
|
||||||
|
// Insert user_groups
|
||||||
|
err = queries.AssignGroupToUser(ctx, sqlc.AssignGroupToUserParams{
|
||||||
|
UserID: "u_bob_analyst",
|
||||||
|
GroupID: "analyst",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to assign analyst group to user")
|
||||||
|
|
||||||
|
err = queries.AssignGroupToUser(ctx, sqlc.AssignGroupToUserParams{
|
||||||
|
UserID: "u_admin",
|
||||||
|
GroupID: "admin",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to assign admin group to user")
|
||||||
|
|
||||||
|
files, err := database.PaginateItems(ctx, func(ctx context.Context, offset, limit int64) ([]sqlc.ListFilesRow, error) {
|
||||||
|
return queries.ListFiles(ctx, sqlc.ListFilesParams{Limit: limit, Offset: offset})
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to list files")
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
_ = os.MkdirAll(path.Join(dir, "uploads", file.ID), 0o755)
|
||||||
|
|
||||||
|
infoFilePath := path.Join(dir, "uploads", file.ID+".info")
|
||||||
|
slog.InfoContext(t.Context(), "Creating file info", "path", infoFilePath)
|
||||||
|
|
||||||
|
err = os.WriteFile(infoFilePath, []byte(`{"MetaData":{"filetype":"text/plain"}}`), 0o600)
|
||||||
|
require.NoError(t, err, "failed to write file info")
|
||||||
|
|
||||||
|
err = os.WriteFile(path.Join(dir, "uploads", file.ID, file.Blob), []byte("hello"), 0o600)
|
||||||
|
require.NoError(t, err, "failed to write file blob")
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/data/testdata.sql
Normal file
16
app/data/testdata.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
INSERT INTO users VALUES('2025-06-21 22:21:26.271Z','2025-06-21 22:21:26.271Z','analyst@catalyst-soar.com','u_bob_analyst','','','Bob Analyst','$2a$10$ZEHNh9ZKJ81N717wovDnMuLwZOLa6.g22IRzRr4goG6zGN.57UzJG','z3Jj8bbzcq_cSZs07XKoGlB0UtvmQiphHgwNkE4akoY=','2025-06-21 22:21:26.271Z','u_bob_analyst',1);
|
||||||
|
INSERT INTO users VALUES('2025-06-21 22:21:26.271Z','2025-06-21 22:21:26.271Z','admin@catalyst-soar.com','u_admin','','','Admin User','$2a$10$Z3/0HHWau6oi1t1aRPiI0uiVOWI.IosTAYEL0DJ2XJaalP9kesgBa','5BWDKLIAn3SQkpQlBUGrS_XEbFf91DsDpuh_Xmt4Nwg=','2025-06-21 22:21:26.271Z','u_admin',1);
|
||||||
|
INSERT INTO webhooks VALUES('tickets','2025-06-21 22:21:26.271Z','https://example.com','w_test_webhook','Test Webhook','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO types VALUES('2025-06-21 22:21:26.271Z','Bug','test-type','Tests','{}','Test','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO tickets VALUES('2025-06-21 22:21:26.271Z','This is a test ticket.','test-ticket','Test Ticket',1,'u_bob_analyst','','{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}','{"tlp":"AMBER"}','incident','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO tasks VALUES('2025-06-21 22:21:26.271Z','k_test_task','Test Task',1,'u_bob_analyst','test-ticket','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO comments VALUES('u_bob_analyst','2025-06-21 22:21:26.271Z','c_test_comment','Initial comment on the test ticket.','test-ticket','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO timeline VALUES('2025-06-21 22:21:26.271Z','h_test_timeline','Initial timeline entry.','test-ticket','2023-01-01T00:00:00Z','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO links VALUES('2025-06-21 22:21:26.271Z','l_test_link','Catalyst','test-ticket','2025-06-21 22:21:26.271Z','https://example.com');
|
||||||
|
INSERT INTO files VALUES('hello_a20DUE9c77rj.txt','2025-06-21 22:21:26.271Z','b_test_file','hello.txt',5,'test-ticket','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO features VALUES('2025-06-21 22:21:26.271Z','rce91818107f46a','dev','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO reactions VALUES('python','{"requirements":"requests","script":"print(''Hello, World!'')"}','','r-test-webhook','Reaction','webhook','{"token":"1234567890","path":"test"}','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO reactions VALUES('webhook','{"headers":{"Content-Type":"application/json"},"url":"http://127.0.0.1:12345/webhook"}','','r-test-proxy','Reaction','webhook','{"path":"test2"}','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO reactions VALUES('python','{"requirements":"requests","script":"import requests\nrequests.post(''http://127.0.0.1:12346/test'', json={''test'':True})"}','','r-test-hook','Hook','hook','{"collections":["tickets"],"events":["create"]}','2025-06-21 22:21:26.271Z');
|
||||||
|
INSERT INTO user_groups VALUES('u_bob_analyst','analyst');
|
||||||
|
INSERT INTO user_groups VALUES('u_admin','admin');
|
||||||
84
app/data/testdata_test.go
Normal file
84
app/data/testdata_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBInitialization(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := NewTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
user, err := queries.SystemUser(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "system", user.ID)
|
||||||
|
|
||||||
|
types, err := queries.ListTypes(t.Context(), sqlc.ListTypesParams{Offset: 0, Limit: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(types), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTestDBDefaultData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := NewTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
user, err := queries.UserByEmail(t.Context(), pointer.Pointer(AdminEmail))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, AdminEmail, *user.Email)
|
||||||
|
|
||||||
|
ticket, err := queries.Ticket(t.Context(), "test-ticket")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "test-ticket", ticket.ID)
|
||||||
|
|
||||||
|
comment, err := queries.GetComment(t.Context(), "c_test_comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "c_test_comment", comment.ID)
|
||||||
|
|
||||||
|
timeline, err := queries.GetTimeline(t.Context(), "h_test_timeline")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "h_test_timeline", timeline.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := NewTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
y, err := queries.CreateType(t.Context(), sqlc.CreateTypeParams{
|
||||||
|
Singular: "Foo",
|
||||||
|
Plural: "Foos",
|
||||||
|
Icon: pointer.Pointer("Bug"),
|
||||||
|
Schema: json.RawMessage("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = queries.GetType(t.Context(), y.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = queries.DeleteType(t.Context(), y.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := NewTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
// read from a table
|
||||||
|
_, err := queries.GetUser(t.Context(), "u_bob_analyst")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// read from a view
|
||||||
|
_, err = queries.GetSidebar(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
27
app/data/testing.go
Normal file
27
app/data/testing.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/migration"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewTestDB(t *testing.T, dir string) *sqlc.Queries {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
queries := database.TestDB(t, dir)
|
||||||
|
uploader, err := upload.New(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = migration.Apply(t.Context(), queries, dir, uploader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
DefaultTestData(t, dir, queries)
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
40
app/data/testing_test.go
Normal file
40
app/data/testing_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewTestDB(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
queries := NewTestDB(t, dir)
|
||||||
|
|
||||||
|
user, err := queries.GetUser(t.Context(), "u_bob_analyst")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "u_bob_analyst", user.ID)
|
||||||
|
assert.Equal(t, "Bob Analyst", *user.Name)
|
||||||
|
assert.Equal(t, time.Date(2025, time.June, 21, 22, 21, 26, 271000000, time.UTC), user.Created)
|
||||||
|
|
||||||
|
alice, err := queries.InsertUser(t.Context(), sqlc.InsertUserParams{
|
||||||
|
ID: "u_alice_admin",
|
||||||
|
Name: pointer.Pointer("Alice Admin"),
|
||||||
|
Username: "alice_admin",
|
||||||
|
PasswordHash: "",
|
||||||
|
TokenKey: "",
|
||||||
|
Created: time.Date(2025, time.June, 21, 22, 21, 26, 0, time.UTC),
|
||||||
|
Updated: time.Date(2025, time.June, 21, 22, 21, 26, 0, time.UTC),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, time.Date(2025, time.June, 21, 22, 21, 26, 0, time.UTC), alice.Created)
|
||||||
|
}
|
||||||
@@ -1,7 +1,28 @@
|
|||||||
package fakedata
|
package data
|
||||||
|
|
||||||
import "github.com/brianvoe/gofakeit/v7"
|
import "github.com/brianvoe/gofakeit/v7"
|
||||||
|
|
||||||
|
func fakeTicketTitle() string {
|
||||||
|
return random([]string{
|
||||||
|
"Unauthorized Access Attempt",
|
||||||
|
"Multiple Failed Login Attempts",
|
||||||
|
"Suspicious File Download",
|
||||||
|
"User Account Locked",
|
||||||
|
"Unusual Network Activity",
|
||||||
|
"Phishing Email Reported",
|
||||||
|
"Sensitive Data Transfer Detected",
|
||||||
|
"Malware Infection Found",
|
||||||
|
"Unauthorized Device Connected",
|
||||||
|
"Brute-Force Attack Attempt",
|
||||||
|
"Security Patch Required",
|
||||||
|
"External IP Address Probing Network",
|
||||||
|
"Suspicious Behavior Detected",
|
||||||
|
"Unauthorized Software Installation",
|
||||||
|
"Access Control System Malfunction",
|
||||||
|
"DDoS Attack Detected",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func fakeTicketDescription() string {
|
func fakeTicketDescription() string {
|
||||||
return random([]string{
|
return random([]string{
|
||||||
"Unauthorized access attempt detected in the main server room.",
|
"Unauthorized access attempt detected in the main server room.",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package fakedata
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
216
app/data/upgradetest.go
Normal file
216
app/data/upgradetest.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed scripts/upgradetest.py
|
||||||
|
var Script string
|
||||||
|
|
||||||
|
func GenerateUpgradeTestData(ctx context.Context, queries *sqlc.Queries) error { //nolint:cyclop
|
||||||
|
if _, err := createTestUser(ctx, queries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ticket := range CreateUpgradeTestDataTickets() {
|
||||||
|
_, err := queries.InsertTicket(ctx, sqlc.InsertTicketParams{
|
||||||
|
ID: ticket.ID,
|
||||||
|
Name: ticket.Name,
|
||||||
|
Type: ticket.Type,
|
||||||
|
Description: ticket.Description,
|
||||||
|
Open: ticket.Open,
|
||||||
|
Schema: ticket.Schema,
|
||||||
|
State: ticket.State,
|
||||||
|
Owner: ticket.Owner,
|
||||||
|
Resolution: ticket.Resolution,
|
||||||
|
Created: ticket.Created,
|
||||||
|
Updated: ticket.Updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create ticket: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, comment := range CreateUpgradeTestDataComments() {
|
||||||
|
_, err := queries.InsertComment(ctx, sqlc.InsertCommentParams{
|
||||||
|
ID: comment.ID,
|
||||||
|
Ticket: comment.Ticket,
|
||||||
|
Author: comment.Author,
|
||||||
|
Message: comment.Message,
|
||||||
|
Created: comment.Created,
|
||||||
|
Updated: comment.Updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create comment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, timeline := range CreateUpgradeTestDataTimeline() {
|
||||||
|
_, err := queries.InsertTimeline(ctx, sqlc.InsertTimelineParams{
|
||||||
|
ID: timeline.ID,
|
||||||
|
Ticket: timeline.Ticket,
|
||||||
|
Time: timeline.Time,
|
||||||
|
Message: timeline.Message,
|
||||||
|
Created: timeline.Created,
|
||||||
|
Updated: timeline.Updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create timeline: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, task := range CreateUpgradeTestDataTasks() {
|
||||||
|
_, err := queries.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||||
|
ID: task.ID,
|
||||||
|
Ticket: task.Ticket,
|
||||||
|
Name: task.Name,
|
||||||
|
Open: task.Open,
|
||||||
|
Owner: task.Owner,
|
||||||
|
Created: task.Created,
|
||||||
|
Updated: task.Updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create task: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range CreateUpgradeTestDataLinks() {
|
||||||
|
_, err := queries.InsertLink(ctx, sqlc.InsertLinkParams{
|
||||||
|
ID: link.ID,
|
||||||
|
Ticket: link.Ticket,
|
||||||
|
Url: link.Url,
|
||||||
|
Name: link.Name,
|
||||||
|
Created: link.Created,
|
||||||
|
Updated: link.Updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create link: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reaction := range CreateUpgradeTestDataReaction() {
|
||||||
|
_, err := queries.InsertReaction(ctx, sqlc.InsertReactionParams{ //nolint: staticcheck
|
||||||
|
ID: reaction.ID,
|
||||||
|
Name: reaction.Name,
|
||||||
|
Trigger: reaction.Trigger,
|
||||||
|
Triggerdata: reaction.Triggerdata,
|
||||||
|
Action: reaction.Action,
|
||||||
|
Actiondata: reaction.Actiondata,
|
||||||
|
Created: reaction.Created,
|
||||||
|
Updated: reaction.Updated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create reaction: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUpgradeTestDataTickets() map[string]sqlc.Ticket {
|
||||||
|
return map[string]sqlc.Ticket{
|
||||||
|
"t_0": {
|
||||||
|
ID: "t_0",
|
||||||
|
Created: ticketCreated,
|
||||||
|
Updated: ticketCreated.Add(time.Minute * 5),
|
||||||
|
Name: "phishing-123",
|
||||||
|
Type: "alert",
|
||||||
|
Description: "Phishing email reported by several employees.",
|
||||||
|
Open: true,
|
||||||
|
Schema: json.RawMessage(`{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`),
|
||||||
|
State: json.RawMessage(`{"severity":"Medium"}`),
|
||||||
|
Owner: pointer.Pointer("u_test"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUpgradeTestDataComments() map[string]sqlc.Comment {
|
||||||
|
return map[string]sqlc.Comment{
|
||||||
|
"c_0": {
|
||||||
|
ID: "c_0",
|
||||||
|
Created: ticketCreated.Add(time.Minute * 10),
|
||||||
|
Updated: ticketCreated.Add(time.Minute * 15),
|
||||||
|
Ticket: "t_0",
|
||||||
|
Author: "u_test",
|
||||||
|
Message: "This is a test comment.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUpgradeTestDataTimeline() map[string]sqlc.Timeline {
|
||||||
|
return map[string]sqlc.Timeline{
|
||||||
|
"tl_0": {
|
||||||
|
ID: "tl_0",
|
||||||
|
Created: ticketCreated.Add(time.Minute * 15),
|
||||||
|
Updated: ticketCreated.Add(time.Minute * 20),
|
||||||
|
Ticket: "t_0",
|
||||||
|
Time: ticketCreated.Add(time.Minute * 15),
|
||||||
|
Message: "This is a test timeline message.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUpgradeTestDataTasks() map[string]sqlc.Task {
|
||||||
|
return map[string]sqlc.Task{
|
||||||
|
"ts_0": {
|
||||||
|
ID: "ts_0",
|
||||||
|
Created: ticketCreated.Add(time.Minute * 20),
|
||||||
|
Updated: ticketCreated.Add(time.Minute * 25),
|
||||||
|
Ticket: "t_0",
|
||||||
|
Name: "This is a test task.",
|
||||||
|
Open: true,
|
||||||
|
Owner: pointer.Pointer("u_test"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUpgradeTestDataLinks() map[string]sqlc.Link {
|
||||||
|
return map[string]sqlc.Link{
|
||||||
|
"l_0": {
|
||||||
|
ID: "l_0",
|
||||||
|
Created: ticketCreated.Add(time.Minute * 25),
|
||||||
|
Updated: ticketCreated.Add(time.Minute * 30),
|
||||||
|
Ticket: "t_0",
|
||||||
|
Url: "https://www.example.com",
|
||||||
|
Name: "This is a test link.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUpgradeTestDataReaction() map[string]sqlc.Reaction {
|
||||||
|
var (
|
||||||
|
reactionCreated = time.Date(2025, 2, 1, 11, 30, 0, 0, time.UTC)
|
||||||
|
reactionUpdated = reactionCreated.Add(time.Minute * 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
createTicketActionData := marshal(map[string]any{
|
||||||
|
"requirements": "pocketbase",
|
||||||
|
"script": Script,
|
||||||
|
})
|
||||||
|
|
||||||
|
return map[string]sqlc.Reaction{
|
||||||
|
"w_0": {
|
||||||
|
ID: "w_0",
|
||||||
|
Created: reactionCreated,
|
||||||
|
Updated: reactionUpdated,
|
||||||
|
Name: "Create New Ticket",
|
||||||
|
Trigger: "schedule",
|
||||||
|
Triggerdata: json.RawMessage(`{"expression":"12 * * * *"}`),
|
||||||
|
Action: "python",
|
||||||
|
Actiondata: createTicketActionData,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshal(m map[string]any) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(m) //nolint:errchkjson
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
30
app/data/user.go
Normal file
30
app/data/user.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth/password"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createTestUser(ctx context.Context, queries *sqlc.Queries) (sqlc.User, error) {
|
||||||
|
passwordHash, tokenKey, err := password.Hash("1234567890")
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries.InsertUser(ctx, sqlc.InsertUserParams{
|
||||||
|
ID: "u_test",
|
||||||
|
Username: "u_test",
|
||||||
|
Name: pointer.Pointer("Test User"),
|
||||||
|
Email: pointer.Pointer("user@catalyst-soar.com"),
|
||||||
|
Active: true,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
TokenKey: tokenKey,
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
112
app/database/db.go
Normal file
112
app/database/db.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3" // import sqlite driver
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sqliteDriver = "sqlite3"
|
||||||
|
|
||||||
|
func DB(ctx context.Context, dir string) (*sqlc.Queries, func(), error) {
|
||||||
|
filename := filepath.Join(dir, "data.db")
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Connecting to database", "path", filename)
|
||||||
|
|
||||||
|
// see https://briandouglas.ie/sqlite-defaults/ for more details
|
||||||
|
pragmas := []string{
|
||||||
|
// Enable WAL mode for better concurrency
|
||||||
|
"journal_mode=WAL",
|
||||||
|
// Enable synchronous mode for better data integrity
|
||||||
|
"synchronous=NORMAL",
|
||||||
|
// Set busy timeout to 5 seconds
|
||||||
|
"busy_timeout=5000",
|
||||||
|
// Set cache size to 20MB
|
||||||
|
"cache_size=-20000",
|
||||||
|
// Enable foreign key checks
|
||||||
|
"foreign_keys=ON",
|
||||||
|
// Enable incremental vacuuming
|
||||||
|
"auto_vacuum=INCREMENTAL",
|
||||||
|
// Set temp store to memory
|
||||||
|
"temp_store=MEMORY",
|
||||||
|
// Set mmap size to 2GB
|
||||||
|
"mmap_size=2147483648",
|
||||||
|
// Set page size to 8192
|
||||||
|
"page_size=8192",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.MkdirAll(filepath.Dir(filename), 0o755)
|
||||||
|
|
||||||
|
write, err := sql.Open(sqliteDriver, fmt.Sprintf("file:%s", filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
write.SetMaxOpenConns(1)
|
||||||
|
write.SetConnMaxIdleTime(time.Minute)
|
||||||
|
|
||||||
|
for _, pragma := range pragmas {
|
||||||
|
if _, err := write.ExecContext(ctx, fmt.Sprintf("PRAGMA %s", pragma)); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to set pragma %s: %w", pragma, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
read, err := sql.Open(sqliteDriver, fmt.Sprintf("file:%s?mode=ro", filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read.SetMaxOpenConns(100)
|
||||||
|
read.SetConnMaxIdleTime(time.Minute)
|
||||||
|
|
||||||
|
queries := sqlc.New(read, write)
|
||||||
|
|
||||||
|
return queries, func() {
|
||||||
|
if err := read.Close(); err != nil {
|
||||||
|
slog.Error("failed to close read connection", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := write.Close(); err != nil {
|
||||||
|
slog.Error("failed to close write connection", "error", err)
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDB(t *testing.T, dir string) *sqlc.Queries {
|
||||||
|
queries, cleanup, err := DB(t.Context(), filepath.Join(dir, "data.db"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateID(prefix string) string {
|
||||||
|
return strings.ToLower(prefix) + randomstring(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base32alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
func randomstring(l int) string {
|
||||||
|
rand.Text()
|
||||||
|
|
||||||
|
src := make([]byte, l)
|
||||||
|
_, _ = rand.Read(src)
|
||||||
|
|
||||||
|
for i := range src {
|
||||||
|
src[i] = base32alphabet[int(src[i])%len(base32alphabet)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(src)
|
||||||
|
}
|
||||||
21
app/database/db_test.go
Normal file
21
app/database/db_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBForeignKeyConstraints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := database.TestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
assert.Error(t, queries.AssignGroupToUser(t.Context(), sqlc.AssignGroupToUserParams{
|
||||||
|
UserID: "does_not_exist",
|
||||||
|
GroupID: "also_missing",
|
||||||
|
}))
|
||||||
|
}
|
||||||
236
app/database/migrations/000_create_pocketbase_tables.up.sql
Normal file
236
app/database/migrations/000_create_pocketbase_tables.up.sql
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS _migrations
|
||||||
|
(
|
||||||
|
file VARCHAR(255) PRIMARY KEY NOT NULL,
|
||||||
|
applied INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS _admins
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
avatar INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
tokenKey TEXT UNIQUE NOT NULL,
|
||||||
|
passwordHash TEXT NOT NULL,
|
||||||
|
lastResetSentAt TEXT DEFAULT "" NOT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS _collections
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
system BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
type TEXT DEFAULT "base" NOT NULL,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
schema JSON DEFAULT "[]" NOT NULL,
|
||||||
|
indexes JSON DEFAULT "[]" NOT NULL,
|
||||||
|
listRule TEXT DEFAULT NULL,
|
||||||
|
viewRule TEXT DEFAULT NULL,
|
||||||
|
createRule TEXT DEFAULT NULL,
|
||||||
|
updateRule TEXT DEFAULT NULL,
|
||||||
|
deleteRule TEXT DEFAULT NULL,
|
||||||
|
options JSON DEFAULT "{}" NOT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS _params
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
value JSON DEFAULT NULL,
|
||||||
|
created TEXT DEFAULT "" NOT NULL,
|
||||||
|
updated TEXT DEFAULT "" NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS _externalAuths
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
collectionId TEXT NOT NULL,
|
||||||
|
recordId TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
providerId TEXT NOT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
---
|
||||||
|
FOREIGN KEY (collectionId) REFERENCES _collections (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS _externalAuths_record_provider_idx on _externalAuths (collectionId, recordId, provider);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS _externalAuths_collection_provider_idx on _externalAuths (collectionId, provider, providerId);
|
||||||
|
CREATE TABLE IF NOT EXISTS users
|
||||||
|
(
|
||||||
|
avatar TEXT DEFAULT '' NOT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
email TEXT DEFAULT '' NOT NULL,
|
||||||
|
emailVisibility BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
lastLoginAlertSentAt TEXT DEFAULT '' NOT NULL,
|
||||||
|
lastResetSentAt TEXT DEFAULT '' NOT NULL,
|
||||||
|
lastVerificationSentAt TEXT DEFAULT '' NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
passwordHash TEXT NOT NULL,
|
||||||
|
tokenKey TEXT NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
verified BOOLEAN DEFAULT FALSE NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS __pb_users_auth__username_idx ON users (username);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS __pb_users_auth__email_idx ON users (email) WHERE email != '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS __pb_users_auth__tokenKey_idx ON users (tokenKey);
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks
|
||||||
|
(
|
||||||
|
collection TEXT DEFAULT '' NOT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
destination TEXT DEFAULT '' NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS types
|
||||||
|
(
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
icon TEXT DEFAULT '' NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
plural TEXT DEFAULT '' NOT NULL,
|
||||||
|
schema JSON DEFAULT NULL,
|
||||||
|
singular TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets
|
||||||
|
(
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
description TEXT DEFAULT '' NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
open BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
owner TEXT DEFAULT '' NOT NULL,
|
||||||
|
resolution TEXT DEFAULT '' NOT NULL,
|
||||||
|
schema JSON DEFAULT NULL,
|
||||||
|
state JSON DEFAULT NULL,
|
||||||
|
type TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks
|
||||||
|
(
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
open BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
owner TEXT DEFAULT '' NOT NULL,
|
||||||
|
ticket TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS comments
|
||||||
|
(
|
||||||
|
author TEXT DEFAULT '' NOT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
message TEXT DEFAULT '' NOT NULL,
|
||||||
|
ticket TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS timeline
|
||||||
|
(
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
message TEXT DEFAULT '' NOT NULL,
|
||||||
|
ticket TEXT DEFAULT '' NOT NULL,
|
||||||
|
time TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS links
|
||||||
|
(
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
ticket TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
url TEXT DEFAULT '' NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS files
|
||||||
|
(
|
||||||
|
blob TEXT DEFAULT '' NOT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
size NUMERIC DEFAULT 0 NOT NULL,
|
||||||
|
ticket TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS features
|
||||||
|
(
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS unique_name ON features (name);
|
||||||
|
|
||||||
|
CREATE VIEW IF NOT EXISTS sidebar AS
|
||||||
|
SELECT types.id as id,
|
||||||
|
types.singular as singular,
|
||||||
|
types.plural as plural,
|
||||||
|
types.icon as icon,
|
||||||
|
(SELECT COUNT(tickets.id) FROM tickets WHERE tickets.type = types.id AND tickets.open = true) as count
|
||||||
|
FROM types
|
||||||
|
ORDER BY types.plural;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions
|
||||||
|
(
|
||||||
|
action TEXT DEFAULT '' NOT NULL,
|
||||||
|
actiondata JSON DEFAULT NULL,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT DEFAULT '' NOT NULL,
|
||||||
|
trigger TEXT DEFAULT '' NOT NULL,
|
||||||
|
triggerdata JSON DEFAULT NULL,
|
||||||
|
updated TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIEW IF NOT EXISTS ticket_search AS
|
||||||
|
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;
|
||||||
|
|
||||||
|
CREATE VIEW IF NOT EXISTS dashboard_counts AS
|
||||||
|
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;
|
||||||
390
app/database/migrations/001_create_tables.up.sql
Normal file
390
app/database/migrations/001_create_tables.up.sql
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
DROP TABLE _migrations;
|
||||||
|
DROP TABLE _collections;
|
||||||
|
DROP TABLE _externalauths;
|
||||||
|
DROP VIEW sidebar;
|
||||||
|
DROP VIEW ticket_search;
|
||||||
|
DROP VIEW dashboard_counts;
|
||||||
|
|
||||||
|
--- _params
|
||||||
|
|
||||||
|
CREATE TABLE new_params
|
||||||
|
(
|
||||||
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value JSON
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_params
|
||||||
|
(key, value)
|
||||||
|
SELECT key, value
|
||||||
|
FROM _params;
|
||||||
|
|
||||||
|
DROP TABLE _params;
|
||||||
|
ALTER TABLE new_params
|
||||||
|
RENAME TO _params;
|
||||||
|
|
||||||
|
--- users
|
||||||
|
|
||||||
|
CREATE TABLE new_users
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('u' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
passwordHash TEXT NOT NULL,
|
||||||
|
tokenKey TEXT NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
lastresetsentat DATETIME,
|
||||||
|
lastverificationsentat DATETIME,
|
||||||
|
admin BOOLEAN NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_users
|
||||||
|
(avatar, email, id, lastresetsentat, lastverificationsentat, name, passwordHash, tokenKey, username, active, admin,
|
||||||
|
created,
|
||||||
|
updated)
|
||||||
|
SELECT avatar,
|
||||||
|
email,
|
||||||
|
id,
|
||||||
|
lastResetSentAt,
|
||||||
|
lastVerificationSentAt,
|
||||||
|
name,
|
||||||
|
passwordHash,
|
||||||
|
tokenKey,
|
||||||
|
username,
|
||||||
|
verified,
|
||||||
|
false,
|
||||||
|
created,
|
||||||
|
updated
|
||||||
|
FROM users;
|
||||||
|
|
||||||
|
INSERT INTO new_users
|
||||||
|
(avatar, email, id, lastresetsentat, lastverificationsentat, name, passwordHash, tokenKey, username, active, admin,
|
||||||
|
created,
|
||||||
|
updated)
|
||||||
|
SELECT avatar,
|
||||||
|
email,
|
||||||
|
id,
|
||||||
|
lastResetSentAt,
|
||||||
|
'',
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
tokenKey,
|
||||||
|
id,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
created,
|
||||||
|
updated
|
||||||
|
FROM _admins;
|
||||||
|
|
||||||
|
DROP TABLE users;
|
||||||
|
DROP TABLE _admins;
|
||||||
|
ALTER TABLE new_users
|
||||||
|
RENAME TO users;
|
||||||
|
|
||||||
|
--- webhooks
|
||||||
|
|
||||||
|
CREATE TABLE new_webhooks
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('w' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
destination TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_webhooks
|
||||||
|
(collection, destination, id, name, created, updated)
|
||||||
|
SELECT collection, destination, id, name, datetime(created), datetime(updated)
|
||||||
|
FROM webhooks;
|
||||||
|
|
||||||
|
DROP TABLE webhooks;
|
||||||
|
ALTER TABLE new_webhooks
|
||||||
|
RENAME TO webhooks;
|
||||||
|
|
||||||
|
--- types
|
||||||
|
|
||||||
|
CREATE TABLE new_types
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('y' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
singular TEXT NOT NULL,
|
||||||
|
plural TEXT NOT NULL,
|
||||||
|
schema JSON,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_types
|
||||||
|
(id, icon, singular, plural, schema, created, updated)
|
||||||
|
SELECT id, icon, singular, plural, schema, created, updated
|
||||||
|
FROM types;
|
||||||
|
|
||||||
|
DROP TABLE types;
|
||||||
|
ALTER TABLE new_types
|
||||||
|
RENAME TO types;
|
||||||
|
|
||||||
|
--- ticket
|
||||||
|
|
||||||
|
CREATE TABLE new_tickets
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('t' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
owner TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
open BOOLEAN NOT NULL,
|
||||||
|
resolution TEXT,
|
||||||
|
schema JSON,
|
||||||
|
state JSON,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (type) REFERENCES types (id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (owner) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_tickets
|
||||||
|
(id, name, description, open, owner, resolution, schema, state, type, created, updated)
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
open,
|
||||||
|
owner,
|
||||||
|
resolution,
|
||||||
|
schema,
|
||||||
|
state,
|
||||||
|
type,
|
||||||
|
created,
|
||||||
|
updated
|
||||||
|
FROM tickets;
|
||||||
|
|
||||||
|
DROP TABLE tickets;
|
||||||
|
ALTER TABLE new_tickets
|
||||||
|
RENAME TO tickets;
|
||||||
|
|
||||||
|
--- tasks
|
||||||
|
|
||||||
|
CREATE TABLE new_tasks
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('t' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
owner TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
open BOOLEAN NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (ticket) REFERENCES tickets (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_tasks
|
||||||
|
(id, ticket, owner, name, open, created, updated)
|
||||||
|
SELECT id, ticket, owner, name, open, created, updated
|
||||||
|
FROM tasks;
|
||||||
|
DROP TABLE tasks;
|
||||||
|
ALTER TABLE new_tasks
|
||||||
|
RENAME TO tasks;
|
||||||
|
|
||||||
|
--- comments
|
||||||
|
|
||||||
|
CREATE TABLE new_comments
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('c' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (ticket) REFERENCES tickets (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (author) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_comments
|
||||||
|
(id, ticket, author, message, created, updated)
|
||||||
|
SELECT id, ticket, author, message, created, updated
|
||||||
|
FROM comments;
|
||||||
|
DROP TABLE comments;
|
||||||
|
ALTER TABLE new_comments
|
||||||
|
RENAME TO comments;
|
||||||
|
|
||||||
|
--- timeline
|
||||||
|
|
||||||
|
CREATE TABLE new_timeline
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('h' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
time DATETIME NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (ticket) REFERENCES tickets (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_timeline
|
||||||
|
(id, ticket, message, time, created, updated)
|
||||||
|
SELECT id, ticket, message, time, created, updated
|
||||||
|
FROM timeline;
|
||||||
|
|
||||||
|
DROP TABLE timeline;
|
||||||
|
ALTER TABLE new_timeline
|
||||||
|
RENAME TO timeline;
|
||||||
|
|
||||||
|
--- links
|
||||||
|
|
||||||
|
CREATE TABLE new_links
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('l' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (ticket) REFERENCES tickets (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_links
|
||||||
|
(id, ticket, name, url, created, updated)
|
||||||
|
SELECT id, ticket, name, url, datetime(created), datetime(updated)
|
||||||
|
FROM links;
|
||||||
|
DROP TABLE links;
|
||||||
|
ALTER TABLE new_links
|
||||||
|
RENAME TO links;
|
||||||
|
|
||||||
|
--- files
|
||||||
|
|
||||||
|
CREATE TABLE new_files
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('b' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
blob TEXT NOT NULL,
|
||||||
|
size NUMERIC NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (ticket) REFERENCES tickets (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_files
|
||||||
|
(id, name, blob, size, ticket, created, updated)
|
||||||
|
SELECT id, name, blob, size, ticket, created, updated
|
||||||
|
FROM files;
|
||||||
|
DROP TABLE files;
|
||||||
|
ALTER TABLE new_files
|
||||||
|
RENAME TO files;
|
||||||
|
|
||||||
|
--- features
|
||||||
|
|
||||||
|
CREATE TABLE new_features
|
||||||
|
(
|
||||||
|
key TEXT PRIMARY KEY NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_features
|
||||||
|
(key)
|
||||||
|
SELECT name
|
||||||
|
FROM features;
|
||||||
|
|
||||||
|
DROP TABLE features;
|
||||||
|
ALTER TABLE new_features
|
||||||
|
RENAME TO features;
|
||||||
|
|
||||||
|
--- reactions
|
||||||
|
|
||||||
|
CREATE TABLE new_reactions
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('r' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
actiondata JSON NOT NULL,
|
||||||
|
trigger TEXT NOT NULL,
|
||||||
|
triggerdata JSON NOT NULL,
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_reactions
|
||||||
|
(id, name, action, actiondata, trigger, triggerdata, created, updated)
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
action,
|
||||||
|
actionData,
|
||||||
|
trigger,
|
||||||
|
triggerData,
|
||||||
|
created,
|
||||||
|
updated
|
||||||
|
FROM reactions;
|
||||||
|
DROP TABLE reactions;
|
||||||
|
ALTER TABLE new_reactions
|
||||||
|
RENAME TO reactions;
|
||||||
|
|
||||||
|
--- views
|
||||||
|
|
||||||
|
CREATE VIEW sidebar AS
|
||||||
|
SELECT types.id as id,
|
||||||
|
types.singular as singular,
|
||||||
|
types.plural as plural,
|
||||||
|
types.icon as icon,
|
||||||
|
(SELECT COUNT(tickets.id) FROM tickets WHERE tickets.type = types.id AND tickets.open = true) as count
|
||||||
|
FROM types
|
||||||
|
ORDER BY types.plural;
|
||||||
|
|
||||||
|
CREATE VIEW ticket_search AS
|
||||||
|
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;
|
||||||
|
|
||||||
|
CREATE VIEW dashboard_counts AS
|
||||||
|
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;
|
||||||
5
app/database/migrations/002_create_defaultdata.up.sql
Normal file
5
app/database/migrations/002_create_defaultdata.up.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
INSERT OR IGNORE INTO types (id, singular, plural, icon, schema) VALUES ('alert', 'Alert', 'Alerts', 'AlertTriangle', '{"type": "object", "properties": { "severity": { "title": "Severity", "enum": ["Low", "Medium", "High"]}}, "required": ["severity"]}');
|
||||||
|
INSERT OR IGNORE INTO types (id, singular, plural, icon, schema) VALUES ('incident', 'Incident', 'Incidents', 'Flame', '{"type": "object", "properties": { "severity": { "title": "Severity", "enum": ["Low", "Medium", "High"]}}, "required": ["severity"]}');
|
||||||
|
INSERT OR IGNORE INTO types (id, singular, plural, icon, schema) VALUES ('vulnerability', 'Vulnerability', 'Vulnerabilities', 'Bug', '{"type": "object", "properties": { "severity": { "title": "Severity", "enum": ["Low", "Medium", "High"]}}, "required": ["severity"]}');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO users (id, name, username, passwordHash, tokenKey, active, admin) VALUES ('system', 'System', 'system', '', lower(hex(randomblob(26))), true, true);
|
||||||
82
app/database/migrations/003_create_groups.up.sql
Normal file
82
app/database/migrations/003_create_groups.up.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
CREATE TABLE groups
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY DEFAULT ('g' || lower(hex(randomblob(7)))) NOT NULL,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
permissions TEXT NOT NULL, -- JSON array string like '["read:article","write:article"]'
|
||||||
|
created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_groups
|
||||||
|
(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
group_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, group_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE group_inheritance
|
||||||
|
(
|
||||||
|
parent_group_id TEXT NOT NULL,
|
||||||
|
child_group_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (parent_group_id, child_group_id),
|
||||||
|
FOREIGN KEY (parent_group_id) REFERENCES groups (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (child_group_id) REFERENCES groups (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIEW group_effective_groups AS
|
||||||
|
WITH RECURSIVE all_groups(child_group_id, parent_group_id, group_type)
|
||||||
|
AS (SELECT rr.child_group_id, rr.parent_group_id, 'direct' AS group_type
|
||||||
|
FROM group_inheritance rr
|
||||||
|
UNION
|
||||||
|
SELECT ar.child_group_id, ri.parent_group_id, 'indirect' AS group_type
|
||||||
|
FROM all_groups ar
|
||||||
|
JOIN group_inheritance ri ON ri.child_group_id = ar.parent_group_id)
|
||||||
|
SELECT child_group_id, parent_group_id, group_type
|
||||||
|
FROM all_groups;
|
||||||
|
|
||||||
|
CREATE VIEW group_effective_permissions AS
|
||||||
|
SELECT re.parent_group_id, CAST(json_each.value AS TEXT) AS permission
|
||||||
|
FROM group_effective_groups re
|
||||||
|
JOIN groups r ON r.id = re.child_group_id, json_each(r.permissions);
|
||||||
|
|
||||||
|
CREATE VIEW user_effective_groups AS
|
||||||
|
WITH RECURSIVE all_groups(user_id, group_id, group_type) AS (
|
||||||
|
-- Direct groups
|
||||||
|
SELECT ur.user_id, ur.group_id, 'direct' AS group_type
|
||||||
|
FROM user_groups ur
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
-- Inherited groups
|
||||||
|
SELECT ar.user_id, ri.child_group_id, 'indirect' AS group_type
|
||||||
|
FROM all_groups ar
|
||||||
|
JOIN group_inheritance ri ON ri.parent_group_id = ar.group_id)
|
||||||
|
SELECT user_id,
|
||||||
|
group_id,
|
||||||
|
group_type
|
||||||
|
FROM all_groups;
|
||||||
|
|
||||||
|
CREATE VIEW user_effective_permissions AS
|
||||||
|
SELECT DISTINCT uer.user_id,
|
||||||
|
CAST(json_each.value AS TEXT) AS permission
|
||||||
|
FROM user_effective_groups uer
|
||||||
|
JOIN groups r ON r.id = uer.group_id, json_each(r.permissions);
|
||||||
|
|
||||||
|
INSERT INTO groups (id, name, permissions)
|
||||||
|
VALUES ('analyst', 'Analyst', '["type:read", "file:read", "ticket:read", "ticket:write", "user:read", "group:read"]'),
|
||||||
|
('admin', 'Admin', '["admin"]');
|
||||||
|
|
||||||
|
INSERT INTO user_groups (user_id, group_id)
|
||||||
|
SELECT id, 'analyst'
|
||||||
|
FROM users
|
||||||
|
WHERE NOT admin;
|
||||||
|
|
||||||
|
INSERT INTO user_groups (user_id, group_id)
|
||||||
|
SELECT id, 'admin'
|
||||||
|
FROM users
|
||||||
|
WHERE admin;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN admin;
|
||||||
6
app/database/migrations/migrations.go
Normal file
6
app/database/migrations/migrations.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var Migrations embed.FS
|
||||||
52
app/database/paginate.go
Normal file
52
app/database/paginate.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Paginate(ctx context.Context, f func(ctx context.Context, offset, limit int64) (nextPage bool, err error)) error {
|
||||||
|
const pageSize int64 = 100
|
||||||
|
|
||||||
|
for i := range int64(1000) {
|
||||||
|
nextPage, err := f(ctx, i*pageSize, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// No more features to process, exit the loop
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !nextPage {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("pagination limit reached, too many pages")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PaginateItems[T any](ctx context.Context, f func(ctx context.Context, offset, limit int64) (items []T, err error)) ([]T, error) {
|
||||||
|
var allItems []T
|
||||||
|
|
||||||
|
if err := Paginate(ctx, func(ctx context.Context, offset, limit int64) (nextPage bool, err error) {
|
||||||
|
items, err := f(ctx, offset, limit)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
allItems = append(allItems, items...)
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems, nil
|
||||||
|
}
|
||||||
97
app/database/paginate_test.go
Normal file
97
app/database/paginate_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaginate_AllPages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
err := Paginate(t.Context(), func(_ context.Context, _, _ int64) (bool, error) {
|
||||||
|
calls++
|
||||||
|
if calls < 3 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "expected no error")
|
||||||
|
assert.Equal(t, 3, calls, "expected 3 calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginate_EarlyStop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
err := Paginate(t.Context(), func(_ context.Context, _, _ int64) (bool, error) {
|
||||||
|
calls++
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "expected no error")
|
||||||
|
assert.Equal(t, 1, calls, "expected 1 call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginate_Error(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
errTest := errors.New("fail")
|
||||||
|
err := Paginate(t.Context(), func(_ context.Context, _, _ int64) (bool, error) {
|
||||||
|
return false, errTest
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, errTest, "expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginate_NoRows(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := Paginate(t.Context(), func(_ context.Context, _, _ int64) (bool, error) {
|
||||||
|
return false, sql.ErrNoRows
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "expected no error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateItems(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
f := func(_ context.Context, offset, _ int64) ([]int, error) {
|
||||||
|
calls++
|
||||||
|
|
||||||
|
if offset >= 100 {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return []int{1}, nil
|
||||||
|
}
|
||||||
|
items, err := PaginateItems(t.Context(), f)
|
||||||
|
require.NoError(t, err, "expected no error")
|
||||||
|
assert.Equal(t, []int{1}, items, "expected items to match")
|
||||||
|
assert.Equal(t, 2, calls, "expected 2 calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateItemsLarge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
f := func(_ context.Context, offset, _ int64) ([]int, error) {
|
||||||
|
calls++
|
||||||
|
|
||||||
|
if offset >= 200 {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return []int{1}, nil
|
||||||
|
}
|
||||||
|
items, err := PaginateItems(t.Context(), f)
|
||||||
|
require.NoError(t, err, "expected no error")
|
||||||
|
assert.Equal(t, []int{1, 1}, items, "expected items to match")
|
||||||
|
assert.Equal(t, 3, calls, "expected 3 calls")
|
||||||
|
}
|
||||||
285
app/database/read.sql
Normal file
285
app/database/read.sql
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
-- name: Param :one
|
||||||
|
SELECT *
|
||||||
|
FROM _params
|
||||||
|
WHERE _params.key = @key;
|
||||||
|
|
||||||
|
-------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: Ticket :one
|
||||||
|
SELECT tickets.*, users.name as owner_name, types.singular as type_singular, types.plural as type_plural
|
||||||
|
FROM tickets
|
||||||
|
LEFT JOIN users ON users.id = tickets.owner
|
||||||
|
LEFT JOIN types ON types.id = tickets.type
|
||||||
|
WHERE tickets.id = @id;
|
||||||
|
|
||||||
|
-- name: ListTickets :many
|
||||||
|
SELECT tickets.*,
|
||||||
|
users.name as owner_name,
|
||||||
|
types.singular as type_singular,
|
||||||
|
types.plural as type_plural,
|
||||||
|
COUNT(*) OVER () as total_count
|
||||||
|
FROM tickets
|
||||||
|
LEFT JOIN users ON users.id = tickets.owner
|
||||||
|
LEFT JOIN types ON types.id = tickets.type
|
||||||
|
ORDER BY tickets.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetComment :one
|
||||||
|
SELECT comments.*, users.name as author_name
|
||||||
|
FROM comments
|
||||||
|
LEFT JOIN users ON users.id = comments.author
|
||||||
|
WHERE comments.id = @id;
|
||||||
|
|
||||||
|
-- name: ListComments :many
|
||||||
|
SELECT comments.*, users.name as author_name, COUNT(*) OVER () as total_count
|
||||||
|
FROM comments
|
||||||
|
LEFT JOIN users ON users.id = comments.author
|
||||||
|
WHERE ticket = @ticket
|
||||||
|
OR @ticket = ''
|
||||||
|
ORDER BY comments.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetFeature :one
|
||||||
|
SELECT *
|
||||||
|
FROM features
|
||||||
|
WHERE key = @key;
|
||||||
|
|
||||||
|
-- name: ListFeatures :many
|
||||||
|
SELECT features.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM features
|
||||||
|
ORDER BY features.key DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetFile :one
|
||||||
|
SELECT *
|
||||||
|
FROM files
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: ListFiles :many
|
||||||
|
SELECT files.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM files
|
||||||
|
WHERE ticket = @ticket
|
||||||
|
OR @ticket = ''
|
||||||
|
ORDER BY files.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetLink :one
|
||||||
|
SELECT *
|
||||||
|
FROM links
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: ListLinks :many
|
||||||
|
SELECT links.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM links
|
||||||
|
WHERE ticket = @ticket
|
||||||
|
OR @ticket = ''
|
||||||
|
ORDER BY links.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetReaction :one
|
||||||
|
SELECT *
|
||||||
|
FROM reactions
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: ListReactions :many
|
||||||
|
SELECT reactions.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM reactions
|
||||||
|
ORDER BY reactions.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
-- name: ListReactionsByTrigger :many
|
||||||
|
SELECT reactions.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM reactions
|
||||||
|
WHERE trigger = @trigger
|
||||||
|
ORDER BY reactions.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetTask :one
|
||||||
|
SELECT tasks.*, users.name as owner_name, tickets.name as ticket_name, tickets.type as ticket_type
|
||||||
|
FROM tasks
|
||||||
|
LEFT JOIN users ON users.id = tasks.owner
|
||||||
|
LEFT JOIN tickets ON tickets.id = tasks.ticket
|
||||||
|
WHERE tasks.id = @id;
|
||||||
|
|
||||||
|
-- name: ListTasks :many
|
||||||
|
SELECT tasks.*,
|
||||||
|
users.name as owner_name,
|
||||||
|
tickets.name as ticket_name,
|
||||||
|
tickets.type as ticket_type,
|
||||||
|
COUNT(*) OVER () as total_count
|
||||||
|
FROM tasks
|
||||||
|
LEFT JOIN users ON users.id = tasks.owner
|
||||||
|
LEFT JOIN tickets ON tickets.id = tasks.ticket
|
||||||
|
WHERE ticket = @ticket
|
||||||
|
OR @ticket = ''
|
||||||
|
ORDER BY tasks.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetTimeline :one
|
||||||
|
SELECT *
|
||||||
|
FROM timeline
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: ListTimeline :many
|
||||||
|
SELECT timeline.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM timeline
|
||||||
|
WHERE ticket = @ticket
|
||||||
|
OR @ticket = ''
|
||||||
|
ORDER BY timeline.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetType :one
|
||||||
|
SELECT *
|
||||||
|
FROM types
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: ListTypes :many
|
||||||
|
SELECT types.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM types
|
||||||
|
ORDER BY created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetUser :one
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: UserByUserName :one
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE username = @username;
|
||||||
|
|
||||||
|
-- name: UserByEmail :one
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE email = @email;
|
||||||
|
|
||||||
|
-- name: SystemUser :one
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE id = 'system';
|
||||||
|
|
||||||
|
-- name: ListUsers :many
|
||||||
|
SELECT users.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM users
|
||||||
|
WHERE id != 'system'
|
||||||
|
ORDER BY users.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetWebhook :one
|
||||||
|
SELECT *
|
||||||
|
FROM webhooks
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: ListWebhooks :many
|
||||||
|
SELECT webhooks.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM webhooks
|
||||||
|
ORDER BY created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetDashboardCounts :many
|
||||||
|
SELECT *
|
||||||
|
FROM dashboard_counts;
|
||||||
|
|
||||||
|
-- name: GetSidebar :many
|
||||||
|
SELECT *
|
||||||
|
FROM sidebar;
|
||||||
|
|
||||||
|
-- name: SearchTickets :many
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
created,
|
||||||
|
description,
|
||||||
|
open,
|
||||||
|
type,
|
||||||
|
state,
|
||||||
|
owner_name,
|
||||||
|
COUNT(*) OVER () as total_count
|
||||||
|
FROM ticket_search
|
||||||
|
WHERE (@query = '' OR (name LIKE '%' || @query || '%'
|
||||||
|
OR description LIKE '%' || @query || '%'
|
||||||
|
OR comment_messages LIKE '%' || @query || '%'
|
||||||
|
OR file_names LIKE '%' || @query || '%'
|
||||||
|
OR link_names LIKE '%' || @query || '%'
|
||||||
|
OR link_urls LIKE '%' || @query || '%'
|
||||||
|
OR task_names LIKE '%' || @query || '%'
|
||||||
|
OR timeline_messages LIKE '%' || @query || '%'))
|
||||||
|
AND (sqlc.narg('type') IS NULL OR type = sqlc.narg('type'))
|
||||||
|
AND (sqlc.narg('open') IS NULL OR open = sqlc.narg('open'))
|
||||||
|
ORDER BY created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetGroup :one
|
||||||
|
SELECT *
|
||||||
|
FROM groups
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: ListGroups :many
|
||||||
|
SELECT g.*, COUNT(*) OVER () as total_count
|
||||||
|
FROM groups AS g
|
||||||
|
ORDER BY g.created DESC
|
||||||
|
LIMIT @limit OFFSET @offset;
|
||||||
|
|
||||||
|
-- name: ListUserGroups :many
|
||||||
|
SELECT g.*, uer.group_type, COUNT(*) OVER () as total_count
|
||||||
|
FROM user_effective_groups uer
|
||||||
|
JOIN groups AS g ON g.id = uer.group_id
|
||||||
|
WHERE uer.user_id = @user_id
|
||||||
|
ORDER BY g.name DESC;
|
||||||
|
|
||||||
|
-- name: ListGroupUsers :many
|
||||||
|
SELECT users.*, uer.group_type
|
||||||
|
FROM user_effective_groups uer
|
||||||
|
JOIN users ON users.id = uer.user_id
|
||||||
|
WHERE uer.group_id = @group_id
|
||||||
|
ORDER BY users.name DESC;
|
||||||
|
|
||||||
|
-- name: ListUserPermissions :many
|
||||||
|
SELECT user_effective_permissions.permission
|
||||||
|
FROM user_effective_permissions
|
||||||
|
WHERE user_id = @user_id
|
||||||
|
ORDER BY permission;
|
||||||
|
|
||||||
|
-- name: ListParentGroups :many
|
||||||
|
SELECT g.*, group_effective_groups.group_type
|
||||||
|
FROM group_effective_groups
|
||||||
|
JOIN groups AS g ON g.id = group_effective_groups.child_group_id
|
||||||
|
WHERE parent_group_id = @group_id
|
||||||
|
ORDER BY group_effective_groups.group_type;
|
||||||
|
|
||||||
|
-- name: ListChildGroups :many
|
||||||
|
SELECT g.*, group_effective_groups.group_type
|
||||||
|
FROM group_effective_groups
|
||||||
|
JOIN groups AS g ON g.id = group_effective_groups.parent_group_id
|
||||||
|
WHERE child_group_id = @group_id
|
||||||
|
ORDER BY group_effective_groups.group_type;
|
||||||
|
|
||||||
|
-- name: ListParentPermissions :many
|
||||||
|
SELECT group_effective_permissions.permission
|
||||||
|
FROM group_effective_permissions
|
||||||
|
WHERE parent_group_id = @group_id
|
||||||
|
ORDER BY permission;
|
||||||
38
app/database/sqlc.db.go.tmpl
Normal file
38
app/database/sqlc.db.go.tmpl
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
|
||||||
|
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
|
||||||
|
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
*ReadQueries
|
||||||
|
*WriteQueries
|
||||||
|
|
||||||
|
ReadDB *sql.DB
|
||||||
|
WriteDB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadQueries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteQueries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(readDB, writeDB *sql.DB) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
ReadQueries: &ReadQueries{db: readDB},
|
||||||
|
WriteQueries: &WriteQueries{db: writeDB},
|
||||||
|
ReadDB: readDB,
|
||||||
|
WriteDB: writeDB,
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/database/sqlc.yaml
Normal file
32
app/database/sqlc.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- engine: "sqlite"
|
||||||
|
queries: "read.sql"
|
||||||
|
schema: "migrations"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "sqlc"
|
||||||
|
out: "sqlc"
|
||||||
|
emit_json_tags: true
|
||||||
|
emit_pointers_for_null_types: true
|
||||||
|
overrides:
|
||||||
|
- { "column": "*.schema", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "*.state", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "reactions.actiondata", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "reactions.triggerdata", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "_params.value", "go_type": { "type": "[]byte" } }
|
||||||
|
- engine: "sqlite"
|
||||||
|
queries: "write.sql"
|
||||||
|
schema: "migrations"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "sqlc"
|
||||||
|
out: "sqlc"
|
||||||
|
emit_json_tags: true
|
||||||
|
emit_pointers_for_null_types: true
|
||||||
|
overrides:
|
||||||
|
- { "column": "*.schema", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "*.state", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "reactions.actiondata", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "reactions.triggerdata", "go_type": { "type": "[]byte" } }
|
||||||
|
- { "column": "_params.value", "go_type": { "type": "[]byte" } }
|
||||||
38
app/database/sqlc/db.go
Normal file
38
app/database/sqlc/db.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
|
||||||
|
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
|
||||||
|
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
*ReadQueries
|
||||||
|
*WriteQueries
|
||||||
|
|
||||||
|
ReadDB *sql.DB
|
||||||
|
WriteDB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadQueries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteQueries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(readDB, writeDB *sql.DB) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
ReadQueries: &ReadQueries{db: readDB},
|
||||||
|
WriteQueries: &WriteQueries{db: writeDB},
|
||||||
|
ReadDB: readDB,
|
||||||
|
WriteDB: writeDB,
|
||||||
|
}
|
||||||
|
}
|
||||||
194
app/database/sqlc/models.go
Normal file
194
app/database/sqlc/models.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
|
||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Comment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardCount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Feature struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Blob string `json:"blob"`
|
||||||
|
Size float64 `json:"size"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupEffectiveGroup struct {
|
||||||
|
ChildGroupID string `json:"child_group_id"`
|
||||||
|
ParentGroupID string `json:"parent_group_id"`
|
||||||
|
GroupType string `json:"group_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupEffectivePermission struct {
|
||||||
|
ParentGroupID string `json:"parent_group_id"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupInheritance struct {
|
||||||
|
ParentGroupID string `json:"parent_group_id"`
|
||||||
|
ChildGroupID string `json:"child_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Param struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value []byte `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reaction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Actiondata []byte `json:"actiondata"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Triggerdata []byte `json:"triggerdata"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sidebar struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Singular string `json:"singular"`
|
||||||
|
Plural string `json:"plural"`
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Owner *string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Open bool `json:"open"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ticket struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Owner *string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Open bool `json:"open"`
|
||||||
|
Resolution *string `json:"resolution"`
|
||||||
|
Schema []byte `json:"schema"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketSearch struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Open bool `json:"open"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
OwnerName *string `json:"owner_name"`
|
||||||
|
CommentMessages string `json:"comment_messages"`
|
||||||
|
FileNames string `json:"file_names"`
|
||||||
|
LinkNames string `json:"link_names"`
|
||||||
|
LinkUrls string `json:"link_urls"`
|
||||||
|
TaskNames string `json:"task_names"`
|
||||||
|
TimelineMessages string `json:"timeline_messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Timeline struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Type struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
Singular string `json:"singular"`
|
||||||
|
Plural string `json:"plural"`
|
||||||
|
Schema []byte `json:"schema"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Passwordhash string `json:"passwordhash"`
|
||||||
|
Tokenkey string `json:"tokenkey"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Lastresetsentat *time.Time `json:"lastresetsentat"`
|
||||||
|
Lastverificationsentat *time.Time `json:"lastverificationsentat"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserEffectiveGroup struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
GroupType string `json:"group_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserEffectivePermission struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroup struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Webhook struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
1592
app/database/sqlc/read.sql.go
Normal file
1592
app/database/sqlc/read.sql.go
Normal file
File diff suppressed because it is too large
Load Diff
1479
app/database/sqlc/write.sql.go
Normal file
1479
app/database/sqlc/write.sql.go
Normal file
File diff suppressed because it is too large
Load Diff
45
app/database/tables.go
Normal file
45
app/database/tables.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
type Table struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
TicketsTable = Table{ID: "tickets", Name: "Tickets"}
|
||||||
|
CommentsTable = Table{ID: "comments", Name: "Comments"}
|
||||||
|
LinksTable = Table{ID: "links", Name: "Links"}
|
||||||
|
TasksTable = Table{ID: "tasks", Name: "Tasks"}
|
||||||
|
TimelinesTable = Table{ID: "timeline", Name: "Timeline"}
|
||||||
|
FilesTable = Table{ID: "files", Name: "Files"}
|
||||||
|
TypesTable = Table{ID: "types", Name: "Types"}
|
||||||
|
UsersTable = Table{ID: "users", Name: "Users"}
|
||||||
|
GroupsTable = Table{ID: "groups", Name: "Groups"}
|
||||||
|
ReactionsTable = Table{ID: "reactions", Name: "Reactions"}
|
||||||
|
WebhooksTable = Table{ID: "webhooks", Name: "Webhooks"}
|
||||||
|
|
||||||
|
DashboardCountsTable = Table{ID: "dashboard_counts", Name: "Dashboard Counts"}
|
||||||
|
SidebarTable = Table{ID: "sidebar", Name: "Sidebar"}
|
||||||
|
UserPermissionTable = Table{ID: "user_permissions", Name: "User Permissions"}
|
||||||
|
UserGroupTable = Table{ID: "user_groups", Name: "User Groups"}
|
||||||
|
GroupUserTable = Table{ID: "group_users", Name: "Group Users"}
|
||||||
|
GroupPermissionTable = Table{ID: "group_permissions", Name: "Group Permissions"}
|
||||||
|
GroupParentTable = Table{ID: "group_parents", Name: "Group Parents"}
|
||||||
|
GroupChildTable = Table{ID: "group_children", Name: "Group Children"}
|
||||||
|
|
||||||
|
CreateAction = "create"
|
||||||
|
UpdateAction = "update"
|
||||||
|
DeleteAction = "delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Tables() []Table {
|
||||||
|
return []Table{
|
||||||
|
TicketsTable,
|
||||||
|
FilesTable,
|
||||||
|
TypesTable,
|
||||||
|
UsersTable,
|
||||||
|
GroupsTable,
|
||||||
|
ReactionsTable,
|
||||||
|
WebhooksTable,
|
||||||
|
}
|
||||||
|
}
|
||||||
328
app/database/write.sql
Normal file
328
app/database/write.sql
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
-- name: CreateParam :exec
|
||||||
|
INSERT INTO _params (key, value)
|
||||||
|
VALUES (@key, @value)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateParam :exec
|
||||||
|
UPDATE _params
|
||||||
|
SET value = @value
|
||||||
|
WHERE key = @key
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertTicket :one
|
||||||
|
INSERT INTO tickets (id, name, description, open, owner, resolution, schema, state, type, created, updated)
|
||||||
|
VALUES (@id, @name, @description, @open, @owner, @resolution, @schema, @state, @type, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateTicket :one
|
||||||
|
INSERT INTO tickets (name, description, open, owner, resolution, schema, state, type)
|
||||||
|
VALUES (@name, @description, @open, @owner, @resolution, @schema, @state, @type)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateTicket :one
|
||||||
|
UPDATE tickets
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
description = coalesce(sqlc.narg('description'), description),
|
||||||
|
open = coalesce(sqlc.narg('open'), open),
|
||||||
|
owner = coalesce(sqlc.narg('owner'), owner),
|
||||||
|
resolution = coalesce(sqlc.narg('resolution'), resolution),
|
||||||
|
schema = coalesce(sqlc.narg('schema'), schema),
|
||||||
|
state = coalesce(sqlc.narg('state'), state),
|
||||||
|
type = coalesce(sqlc.narg('type'), type)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTicket :exec
|
||||||
|
DELETE
|
||||||
|
FROM tickets
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertComment :one
|
||||||
|
INSERT INTO comments (id, author, message, ticket, created, updated)
|
||||||
|
VALUES (@id, @author, @message, @ticket, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateComment :one
|
||||||
|
INSERT INTO comments (author, message, ticket)
|
||||||
|
VALUES (@author, @message, @ticket)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateComment :one
|
||||||
|
UPDATE comments
|
||||||
|
SET message = coalesce(sqlc.narg('message'), message)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteComment :exec
|
||||||
|
DELETE
|
||||||
|
FROM comments
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: CreateFeature :one
|
||||||
|
INSERT INTO features (key)
|
||||||
|
VALUES (@key)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteFeature :exec
|
||||||
|
DELETE
|
||||||
|
FROM features
|
||||||
|
WHERE key = @key;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertFile :one
|
||||||
|
INSERT INTO files (id, name, blob, size, ticket, created, updated)
|
||||||
|
VALUES (@id, @name, @blob, @size, @ticket, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateFile :one
|
||||||
|
INSERT INTO files (name, blob, size, ticket)
|
||||||
|
VALUES (@name, @blob, @size, @ticket)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateFile :one
|
||||||
|
UPDATE files
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
blob = coalesce(sqlc.narg('blob'), blob),
|
||||||
|
size = coalesce(sqlc.narg('size'), size)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteFile :exec
|
||||||
|
DELETE
|
||||||
|
FROM files
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertLink :one
|
||||||
|
INSERT INTO links (id, name, url, ticket, created, updated)
|
||||||
|
VALUES (@id, @name, @url, @ticket, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateLink :one
|
||||||
|
INSERT INTO links (name, url, ticket)
|
||||||
|
VALUES (@name, @url, @ticket)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateLink :one
|
||||||
|
UPDATE links
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
url = coalesce(sqlc.narg('url'), url)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteLink :exec
|
||||||
|
DELETE
|
||||||
|
FROM links
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertReaction :one
|
||||||
|
INSERT INTO reactions (id, name, action, actiondata, trigger, triggerdata, created, updated)
|
||||||
|
VALUES (@id, @name, @action, @actiondata, @trigger, @triggerdata, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateReaction :one
|
||||||
|
INSERT INTO reactions (name, action, actiondata, trigger, triggerdata)
|
||||||
|
VALUES (@name, @action, @actiondata, @trigger, @triggerdata)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateReaction :one
|
||||||
|
UPDATE reactions
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
action = coalesce(sqlc.narg('action'), action),
|
||||||
|
actiondata = coalesce(sqlc.narg('actiondata'), actiondata),
|
||||||
|
trigger = coalesce(sqlc.narg('trigger'), trigger),
|
||||||
|
triggerdata = coalesce(sqlc.narg('triggerdata'), triggerdata)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteReaction :exec
|
||||||
|
DELETE
|
||||||
|
FROM reactions
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertTask :one
|
||||||
|
INSERT INTO tasks (id, name, open, owner, ticket, created, updated)
|
||||||
|
VALUES (@id, @name, @open, @owner, @ticket, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateTask :one
|
||||||
|
INSERT INTO tasks (name, open, owner, ticket)
|
||||||
|
VALUES (@name, @open, @owner, @ticket)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateTask :one
|
||||||
|
UPDATE tasks
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
open = coalesce(sqlc.narg('open'), open),
|
||||||
|
owner = coalesce(sqlc.narg('owner'), owner)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTask :exec
|
||||||
|
DELETE
|
||||||
|
FROM tasks
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertTimeline :one
|
||||||
|
INSERT INTO timeline (id, message, ticket, time, created, updated)
|
||||||
|
VALUES (@id, @message, @ticket, @time, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateTimeline :one
|
||||||
|
INSERT INTO timeline (message, ticket, time)
|
||||||
|
VALUES (@message, @ticket, @time)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateTimeline :one
|
||||||
|
UPDATE timeline
|
||||||
|
SET message = coalesce(sqlc.narg('message'), message),
|
||||||
|
time = coalesce(sqlc.narg('time'), time)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTimeline :exec
|
||||||
|
DELETE
|
||||||
|
FROM timeline
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertType :one
|
||||||
|
INSERT INTO types (id, singular, plural, icon, schema, created, updated)
|
||||||
|
VALUES (@id, @singular, @plural, @icon, @schema, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateType :one
|
||||||
|
INSERT INTO types (singular, plural, icon, schema)
|
||||||
|
VALUES (@singular, @plural, @icon, @schema)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateType :one
|
||||||
|
UPDATE types
|
||||||
|
SET singular = coalesce(sqlc.narg('singular'), singular),
|
||||||
|
plural = coalesce(sqlc.narg('plural'), plural),
|
||||||
|
icon = coalesce(sqlc.narg('icon'), icon),
|
||||||
|
schema = coalesce(sqlc.narg('schema'), schema)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteType :exec
|
||||||
|
DELETE
|
||||||
|
FROM types
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertUser :one
|
||||||
|
INSERT INTO users (id, name, email, username, passwordHash, tokenKey, avatar, active, created, updated)
|
||||||
|
VALUES (@id, @name, @email, @username, @passwordHash, @tokenKey, @avatar, @active, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateUser :one
|
||||||
|
INSERT INTO users (name, email, username, passwordHash, tokenKey, avatar, active)
|
||||||
|
VALUES (@name, @email, @username, @passwordHash, @tokenKey, @avatar, @active)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateUser :one
|
||||||
|
UPDATE users
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
email = coalesce(sqlc.narg('email'), email),
|
||||||
|
username = coalesce(sqlc.narg('username'), username),
|
||||||
|
passwordHash = coalesce(sqlc.narg('passwordHash'), passwordHash),
|
||||||
|
tokenKey = coalesce(sqlc.narg('tokenKey'), tokenKey),
|
||||||
|
avatar = coalesce(sqlc.narg('avatar'), avatar),
|
||||||
|
active = coalesce(sqlc.narg('active'), active),
|
||||||
|
lastResetSentAt = coalesce(sqlc.narg('lastResetSentAt'), lastResetSentAt),
|
||||||
|
lastVerificationSentAt = coalesce(sqlc.narg('lastVerificationSentAt'), lastVerificationSentAt)
|
||||||
|
WHERE id = @id
|
||||||
|
AND id != 'system'
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteUser :exec
|
||||||
|
DELETE
|
||||||
|
FROM users
|
||||||
|
WHERE id = @id
|
||||||
|
AND id != 'system';
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertWebhook :one
|
||||||
|
INSERT INTO webhooks (id, name, collection, destination, created, updated)
|
||||||
|
VALUES (@id, @name, @collection, @destination, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateWebhook :one
|
||||||
|
INSERT INTO webhooks (name, collection, destination)
|
||||||
|
VALUES (@name, @collection, @destination)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateWebhook :one
|
||||||
|
UPDATE webhooks
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
collection = coalesce(sqlc.narg('collection'), collection),
|
||||||
|
destination = coalesce(sqlc.narg('destination'), destination)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteWebhook :exec
|
||||||
|
DELETE
|
||||||
|
FROM webhooks
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: InsertGroup :one
|
||||||
|
INSERT INTO groups (id, name, permissions, created, updated)
|
||||||
|
VALUES (@id, @name, @permissions, @created, @updated)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateGroup :one
|
||||||
|
INSERT INTO groups (name, permissions)
|
||||||
|
VALUES (@name, @permissions)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateGroup :one
|
||||||
|
UPDATE groups
|
||||||
|
SET name = coalesce(sqlc.narg('name'), name),
|
||||||
|
permissions = coalesce(sqlc.narg('permissions'), permissions)
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteGroup :exec
|
||||||
|
DELETE
|
||||||
|
FROM groups
|
||||||
|
WHERE id = @id;
|
||||||
|
|
||||||
|
-- name: AssignGroupToUser :exec
|
||||||
|
INSERT INTO user_groups (user_id, group_id)
|
||||||
|
VALUES (@user_id, @group_id);
|
||||||
|
|
||||||
|
-- name: RemoveGroupFromUser :exec
|
||||||
|
DELETE
|
||||||
|
FROM user_groups
|
||||||
|
WHERE user_id = @user_id
|
||||||
|
AND group_id = @group_id;
|
||||||
|
|
||||||
|
-- name: AssignParentGroup :exec
|
||||||
|
INSERT INTO group_inheritance (parent_group_id, child_group_id)
|
||||||
|
VALUES (@parent_group_id, @child_group_id);
|
||||||
|
|
||||||
|
-- name: RemoveParentGroup :exec
|
||||||
|
DELETE
|
||||||
|
FROM group_inheritance
|
||||||
|
WHERE parent_group_id = @parent_group_id
|
||||||
|
AND child_group_id = @child_group_id;
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
119
app/flags.go
119
app/flags.go
@@ -1,119 +0,0 @@
|
|||||||
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 := app.Settings()
|
|
||||||
|
|
||||||
settings.Meta.AppUrl = args[0]
|
|
||||||
|
|
||||||
if err := app.Dao().SaveSettings(settings); err != nil {
|
|
||||||
app.Logger().Error(err.Error())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
17
app/hook/hook.go
Normal file
17
app/hook/hook.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Hook struct {
|
||||||
|
subscribers []func(ctx context.Context, table string, record any)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hook) Publish(ctx context.Context, table string, record any) {
|
||||||
|
for _, subscriber := range h.subscribers {
|
||||||
|
subscriber(ctx, table, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hook) Subscribe(fn func(ctx context.Context, table string, record any)) {
|
||||||
|
h.subscribers = append(h.subscribers, fn)
|
||||||
|
}
|
||||||
126
app/hook/hook_test.go
Normal file
126
app/hook/hook_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHook_Publish(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type fields struct {
|
||||||
|
subscribers []func(ctx context.Context, table string, record any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
table string
|
||||||
|
record any
|
||||||
|
}
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
|
||||||
|
subscriber := func(_ context.Context, _ string, _ any) {
|
||||||
|
called = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "publish with no subscribers",
|
||||||
|
fields: fields{
|
||||||
|
subscribers: nil,
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
table: "test_table",
|
||||||
|
record: "test_record",
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publish with one subscriber",
|
||||||
|
fields: fields{
|
||||||
|
subscribers: []func(ctx context.Context, table string, record any){subscriber},
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
table: "test_table",
|
||||||
|
record: "test_record",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
called = false
|
||||||
|
h := &Hook{
|
||||||
|
subscribers: tt.fields.subscribers,
|
||||||
|
}
|
||||||
|
h.Publish(t.Context(), tt.args.table, tt.args.record)
|
||||||
|
|
||||||
|
if called != tt.want {
|
||||||
|
t.Errorf("Hook.Publish() called = %v, want %v", called, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHook_Subscribe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type fields struct {
|
||||||
|
subscribers []func(ctx context.Context, table string, record any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
fn func(ctx context.Context, table string, record any)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriber := func(_ context.Context, _ string, _ any) {}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "subscribe to empty hook",
|
||||||
|
fields: fields{
|
||||||
|
subscribers: nil,
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
fn: subscriber,
|
||||||
|
},
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subscribe to hook with existing subscriber",
|
||||||
|
fields: fields{
|
||||||
|
subscribers: []func(ctx context.Context, table string, record any){subscriber},
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
fn: subscriber,
|
||||||
|
},
|
||||||
|
want: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Hook{
|
||||||
|
subscribers: tt.fields.subscribers,
|
||||||
|
}
|
||||||
|
h.Subscribe(tt.args.fn)
|
||||||
|
|
||||||
|
if got := len(h.subscribers); got != tt.want {
|
||||||
|
t.Errorf("Hook.Subscribe() subscriber count = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/hook/hooks.go
Normal file
25
app/hook/hooks.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
type Hooks struct {
|
||||||
|
OnRecordsListRequest *Hook
|
||||||
|
OnRecordViewRequest *Hook
|
||||||
|
OnRecordBeforeCreateRequest *Hook
|
||||||
|
OnRecordAfterCreateRequest *Hook
|
||||||
|
OnRecordBeforeUpdateRequest *Hook
|
||||||
|
OnRecordAfterUpdateRequest *Hook
|
||||||
|
OnRecordBeforeDeleteRequest *Hook
|
||||||
|
OnRecordAfterDeleteRequest *Hook
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHooks() *Hooks {
|
||||||
|
return &Hooks{
|
||||||
|
OnRecordsListRequest: &Hook{},
|
||||||
|
OnRecordViewRequest: &Hook{},
|
||||||
|
OnRecordBeforeCreateRequest: &Hook{},
|
||||||
|
OnRecordAfterCreateRequest: &Hook{},
|
||||||
|
OnRecordBeforeUpdateRequest: &Hook{},
|
||||||
|
OnRecordAfterUpdateRequest: &Hook{},
|
||||||
|
OnRecordBeforeDeleteRequest: &Hook{},
|
||||||
|
OnRecordAfterDeleteRequest: &Hook{},
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/mail/mail.go
Normal file
109
app/mail/mail.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/wneessen/go-mail"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mailer struct {
|
||||||
|
queries *sqlc.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(queries *sqlc.Queries) *Mailer {
|
||||||
|
return &Mailer{
|
||||||
|
queries: queries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) Send(ctx context.Context, to, subject, plainTextBody, htmlBody string) error {
|
||||||
|
settings, err := settings.Load(ctx, m.queries)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !settings.SMTP.Enabled {
|
||||||
|
return fmt.Errorf("SMTP is not enabled in settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.SMTP.Host == "" || settings.SMTP.Username == "" || settings.SMTP.Password == "" {
|
||||||
|
return fmt.Errorf("SMTP settings are not configured properly: host, username, and password must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mailClient(settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create mail client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := createMessage(settings, to, subject, plainTextBody, htmlBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create mail message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.DialAndSend(message); err != nil {
|
||||||
|
return fmt.Errorf("failed to deliver mail: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "mail sent successfully", "to", to, "subject", subject)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createMessage(settings *settings.Settings, to string, subject string, plainTextBody, htmlBody string) (*mail.Msg, error) {
|
||||||
|
message := mail.NewMsg()
|
||||||
|
|
||||||
|
if err := message.FromFormat(settings.Meta.SenderName, settings.Meta.SenderAddress); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set FROM address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := message.To(to); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set TO address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.Subject(subject)
|
||||||
|
message.SetBodyString(mail.TypeTextPlain, plainTextBody)
|
||||||
|
|
||||||
|
if htmlBody != "" {
|
||||||
|
message.SetBodyString(mail.TypeTextHTML, htmlBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
return message, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailClient(settings *settings.Settings) (*mail.Client, error) {
|
||||||
|
var authType mail.SMTPAuthType
|
||||||
|
if err := authType.UnmarshalString(cmp.Or(settings.SMTP.AuthMethod, "plain")); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse SMTP auth method: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []mail.Option{
|
||||||
|
mail.WithSMTPAuth(authType),
|
||||||
|
mail.WithUsername(settings.SMTP.Username),
|
||||||
|
mail.WithPassword(settings.SMTP.Password),
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.SMTP.Port != 0 {
|
||||||
|
opts = append(opts, mail.WithPort(settings.SMTP.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.SMTP.TLS {
|
||||||
|
opts = append(opts, mail.WithSSL())
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.SMTP.LocalName != "" {
|
||||||
|
opts = append(opts, mail.WithHELO(settings.SMTP.LocalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mail.NewClient(settings.SMTP.Host, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create new mail delivery client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
56
app/migration/files_migration.go
Normal file
56
app/migration/files_migration.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
type filesMigration struct{}
|
||||||
|
|
||||||
|
func newFilesMigration() func() (migration, error) {
|
||||||
|
return func() (migration, error) {
|
||||||
|
return filesMigration{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (filesMigration) name() string { return "005_pocketbase_files_to_tusd" }
|
||||||
|
|
||||||
|
func (filesMigration) up(ctx context.Context, queries *sqlc.Queries, dir string, uploader *upload.Uploader) error {
|
||||||
|
oldUploadDir := filepath.Join(dir, "storage")
|
||||||
|
if _, err := os.Stat(oldUploadDir); os.IsNotExist(err) {
|
||||||
|
// If the old upload directory does not exist, we assume no migration is needed.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldUploadRoot, err := os.OpenRoot(oldUploadDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open old uploads root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := database.PaginateItems(ctx, func(ctx context.Context, offset, limit int64) ([]sqlc.ListFilesRow, error) {
|
||||||
|
return queries.ListFiles(ctx, sqlc.ListFilesParams{Limit: limit, Offset: offset})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
data, err := fs.ReadFile(oldUploadRoot.FS(), filepath.Join(file.ID, file.Blob))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read file %s: %w", file.Blob, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := uploader.CreateFile(file.ID, file.Name, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
49
app/migration/migration.go
Normal file
49
app/migration/migration.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
type migration interface {
|
||||||
|
name() string
|
||||||
|
up(ctx context.Context, queries *sqlc.Queries, dir string, uploader *upload.Uploader) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func Apply(ctx context.Context, queries *sqlc.Queries, dir string, uploader *upload.Uploader) error {
|
||||||
|
currentVersion, err := version(ctx, queries.WriteDB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Current database version", "version", currentVersion)
|
||||||
|
|
||||||
|
migrations, err := migrations(currentVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(migrations) == 0 {
|
||||||
|
slog.InfoContext(ctx, "No migrations to apply")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
slog.InfoContext(ctx, "Applying migration", "name", m.name())
|
||||||
|
|
||||||
|
if err := m.up(ctx, queries, dir, uploader); err != nil {
|
||||||
|
return fmt.Errorf("migration %s failed: %w", m.name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setVersion(ctx, queries.WriteDB, currentVersion+len(migrations)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
22
app/migration/migration_test.go
Normal file
22
app/migration/migration_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApply(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
queries := database.TestDB(t, dir)
|
||||||
|
uploader, err := upload.New(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, Apply(t.Context(), queries, dir, uploader))
|
||||||
|
}
|
||||||
34
app/migration/migrations.go
Normal file
34
app/migration/migrations.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
var migrationGenerators = []func() (migration, error){
|
||||||
|
newSQLMigration("000_create_pocketbase_tables"),
|
||||||
|
newSQLMigration("001_create_tables"),
|
||||||
|
newFilesMigration(),
|
||||||
|
newSQLMigration("002_create_defaultdata"),
|
||||||
|
newSQLMigration("003_create_groups"),
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrations(version int) ([]migration, error) {
|
||||||
|
var migrations []migration
|
||||||
|
|
||||||
|
if version < 0 || version > len(migrationGenerators) {
|
||||||
|
return nil, fmt.Errorf("invalid migration version: %d", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if version == len(migrationGenerators) {
|
||||||
|
return migrations, nil // No migrations to apply
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migrationFunc := range migrationGenerators[version:] {
|
||||||
|
migration, err := migrationFunc()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations = append(migrations, migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
32
app/migration/migrations_test.go
Normal file
32
app/migration/migrations_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrations_Success(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
migs, err := migrations(0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, migs, len(migrationGenerators))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrations_VersionOffset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
migs, err := migrations(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, migs, len(migrationGenerators)-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrations_Error(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
migs, err := migrations(999) // Invalid version
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, migs)
|
||||||
|
require.Contains(t, err.Error(), "invalid migration version: 999")
|
||||||
|
}
|
||||||
42
app/migration/sql.go
Normal file
42
app/migration/sql.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sqlmigrations "github.com/SecurityBrewery/catalyst/app/database/migrations"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqlMigration struct {
|
||||||
|
sqlName string
|
||||||
|
upSQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSQLMigration(name string) func() (migration, error) {
|
||||||
|
return func() (migration, error) {
|
||||||
|
up, err := sqlmigrations.Migrations.ReadFile(name + ".up.sql")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read up migration file for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sqlMigration{
|
||||||
|
sqlName: name,
|
||||||
|
upSQL: string(up),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m sqlMigration) name() string {
|
||||||
|
return m.sqlName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m sqlMigration) up(ctx context.Context, queries *sqlc.Queries, _ string, _ *upload.Uploader) error {
|
||||||
|
_, err := queries.WriteDB.ExecContext(ctx, m.upSQL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migration %s up failed: %w", m.sqlName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
41
app/migration/sql_test.go
Normal file
41
app/migration/sql_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSQLMigration_UpAndDown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := sqlMigration{
|
||||||
|
sqlName: "test_migration",
|
||||||
|
upSQL: "CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT);",
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
queries := database.TestDB(t, dir)
|
||||||
|
uploader, err := upload.New(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test up
|
||||||
|
require.NoError(t, m.up(t.Context(), queries, dir, uploader))
|
||||||
|
|
||||||
|
// Table should exist
|
||||||
|
_, err = queries.WriteDB.ExecContext(t.Context(), "INSERT INTO test_table (name) VALUES ('foo')")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSQLMigration_FileNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newSQLMigration("does_not_exist")
|
||||||
|
_, err := f()
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "failed to read up migration file")
|
||||||
|
}
|
||||||
27
app/migration/version.go
Normal file
27
app/migration/version.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func version(ctx context.Context, db *sql.DB) (int, error) {
|
||||||
|
// get the current version of the database
|
||||||
|
var currentVersion int
|
||||||
|
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(¤tVersion); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get current database version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVersion(ctx context.Context, db *sql.DB, version int) error {
|
||||||
|
// Update the database version after successful migration
|
||||||
|
_, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", version))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update database version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
29
app/migration/version_test.go
Normal file
29
app/migration/version_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVersionAndSetVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
require.NoError(t, err, "failed to open in-memory db")
|
||||||
|
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ver, err := version(t.Context(), db)
|
||||||
|
require.NoError(t, err, "failed to get version")
|
||||||
|
require.Equal(t, 0, ver, "expected version 0")
|
||||||
|
|
||||||
|
err = setVersion(t.Context(), db, 2)
|
||||||
|
require.NoError(t, err, "failed to set version")
|
||||||
|
|
||||||
|
ver, err = version(t.Context(), db)
|
||||||
|
require.NoError(t, err, "failed to get version after set")
|
||||||
|
require.Equal(t, 2, ver, "expected version 2")
|
||||||
|
}
|
||||||
8
app/openapi/config.yml
Normal file
8
app/openapi/config.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package: openapi
|
||||||
|
generate:
|
||||||
|
chi-server: true
|
||||||
|
models: true
|
||||||
|
strict-server: true
|
||||||
|
output: app/openapi/gen.go
|
||||||
|
output-options:
|
||||||
|
skip-prune: true
|
||||||
7384
app/openapi/gen.go
Normal file
7384
app/openapi/gen.go
Normal file
File diff suppressed because it is too large
Load Diff
15
app/pointer/pointer.go
Normal file
15
app/pointer/pointer.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package pointer
|
||||||
|
|
||||||
|
func Pointer[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dereference[T any](v *T) T {
|
||||||
|
if v == nil {
|
||||||
|
var zero T
|
||||||
|
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
|
||||||
|
return *v
|
||||||
|
}
|
||||||
34
app/pointer/pointer_test.go
Normal file
34
app/pointer/pointer_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package pointer
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPointer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := 42
|
||||||
|
|
||||||
|
ptr := Pointer(v)
|
||||||
|
if ptr == nil {
|
||||||
|
t.Fatal("Pointer returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if *ptr != v {
|
||||||
|
t.Errorf("Pointer value = %v, want %v", *ptr, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDereference(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := 42
|
||||||
|
ptr := &v
|
||||||
|
|
||||||
|
if Dereference(ptr) != v {
|
||||||
|
t.Errorf("Dereference(ptr) = %v, want %v", Dereference(ptr), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nilPtr *int
|
||||||
|
if Dereference(nilPtr) != 0 {
|
||||||
|
t.Errorf("Dereference(nil) = %v, want 0", Dereference(nilPtr))
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/reaction/action/action.go
Normal file
72
app/reaction/action/action.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/action/python"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/action/webhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(ctx context.Context, url string, queries *sqlc.Queries, actionName string, actionData, payload json.RawMessage) ([]byte, error) {
|
||||||
|
action, err := decode(actionName, actionData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if a, ok := action.(authenticatedAction); ok {
|
||||||
|
token, err := systemToken(ctx, queries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get system token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.SetEnv([]string{
|
||||||
|
"CATALYST_APP_URL=" + url,
|
||||||
|
"CATALYST_TOKEN=" + token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return action.Run(ctx, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
type action interface {
|
||||||
|
Run(ctx context.Context, payload json.RawMessage) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type authenticatedAction interface {
|
||||||
|
SetEnv(env []string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(actionName string, actionData json.RawMessage) (action, error) {
|
||||||
|
switch actionName {
|
||||||
|
case "python":
|
||||||
|
var reaction python.Python
|
||||||
|
if err := json.Unmarshal(actionData, &reaction); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &reaction, nil
|
||||||
|
case "webhook":
|
||||||
|
var reaction webhook.Webhook
|
||||||
|
if err := json.Unmarshal(actionData, &reaction); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &reaction, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("action %q not found", actionName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemToken(ctx context.Context, queries *sqlc.Queries) (string, error) {
|
||||||
|
user, err := queries.SystemUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to find system auth record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth.CreateAccessToken(ctx, &user, auth.All(), time.Hour, queries)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package python
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -20,7 +21,7 @@ func (a *Python) SetEnv(env []string) {
|
|||||||
a.env = env
|
a.env = env
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Python) Run(ctx context.Context, payload string) ([]byte, error) {
|
func (a *Python) Run(ctx context.Context, payload json.RawMessage) ([]byte, error) {
|
||||||
tempDir, err := os.MkdirTemp("", "catalyst_action")
|
tempDir, err := os.MkdirTemp("", "catalyst_action")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -48,7 +49,7 @@ func (a *Python) Run(ctx context.Context, payload string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to run install requirements, %w: %s", err, string(b))
|
return nil, fmt.Errorf("failed to run install requirements, %w: %s", err, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err = a.pythonRunScript(ctx, tempDir, payload)
|
b, err = a.pythonRunScript(ctx, tempDir, string(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ee *exec.ExitError
|
var ee *exec.ExitError
|
||||||
if errors.As(err, &ee) {
|
if errors.As(err, &ee) {
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package python_test
|
package python_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/SecurityBrewery/catalyst/reaction/action/python"
|
"github.com/SecurityBrewery/catalyst/app/reaction/action/python"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPython_Run(t *testing.T) {
|
func TestPython_Run(t *testing.T) {
|
||||||
@@ -89,13 +89,13 @@ func TestPython_Run(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
a := &python.Python{
|
a := &python.Python{
|
||||||
Requirements: tt.fields.Requirements,
|
Requirements: tt.fields.Requirements,
|
||||||
Script: tt.fields.Script,
|
Script: tt.fields.Script,
|
||||||
}
|
}
|
||||||
got, err := a.Run(ctx, tt.args.payload)
|
got, err := a.Run(ctx, json.RawMessage(tt.args.payload))
|
||||||
tt.wantErr(t, err)
|
tt.wantErr(t, err)
|
||||||
|
|
||||||
assert.Equal(t, tt.want, got)
|
assert.Equal(t, tt.want, got)
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/SecurityBrewery/catalyst/reaction/action/webhook"
|
"github.com/SecurityBrewery/catalyst/app/reaction/action/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEncodeBody(t *testing.T) {
|
func TestEncodeBody(t *testing.T) {
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package webhook
|
package webhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Webhook struct {
|
type Webhook struct {
|
||||||
@@ -12,8 +12,8 @@ type Webhook struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Webhook) Run(ctx context.Context, payload string) ([]byte, error) {
|
func (a *Webhook) Run(ctx context.Context, payload json.RawMessage) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.URL, strings.NewReader(payload))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.URL, bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package webhook_test
|
package webhook_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -11,18 +10,18 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
|
|
||||||
"github.com/SecurityBrewery/catalyst/reaction/action/webhook"
|
"github.com/SecurityBrewery/catalyst/app/reaction/action/webhook"
|
||||||
catalystTesting "github.com/SecurityBrewery/catalyst/testing"
|
testing2 "github.com/SecurityBrewery/catalyst/testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebhook_Run(t *testing.T) {
|
func TestWebhook_Run(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
server := catalystTesting.NewRecordingServer()
|
server := testing2.NewRecordingServer()
|
||||||
|
|
||||||
go http.ListenAndServe("127.0.0.1:12347", server) //nolint:gosec,errcheck
|
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 {
|
if err := testing2.WaitForStatus("http://127.0.0.1:12347/health", http.StatusOK, 5*time.Second); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +53,10 @@ func TestWebhook_Run(t *testing.T) {
|
|||||||
want: map[string]any{
|
want: map[string]any{
|
||||||
"statusCode": 200,
|
"statusCode": 200,
|
||||||
"headers": map[string]any{
|
"headers": map[string]any{
|
||||||
"Content-Length": []any{"14"},
|
"Content-Length": []any{"13"},
|
||||||
"Content-Type": []any{"application/json; charset=UTF-8"},
|
"Content-Type": []any{"application/json; charset=UTF-8"},
|
||||||
},
|
},
|
||||||
"body": "{\"test\":true}\n",
|
"body": "{\"test\":true}",
|
||||||
"isBase64Encoded": false,
|
"isBase64Encoded": false,
|
||||||
},
|
},
|
||||||
wantErr: assert.NoError,
|
wantErr: assert.NoError,
|
||||||
@@ -67,13 +66,11 @@ func TestWebhook_Run(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
a := &webhook.Webhook{
|
a := &webhook.Webhook{
|
||||||
Headers: tt.fields.Headers,
|
Headers: tt.fields.Headers,
|
||||||
URL: tt.fields.URL,
|
URL: tt.fields.URL,
|
||||||
}
|
}
|
||||||
got, err := a.Run(ctx, tt.args.payload)
|
got, err := a.Run(t.Context(), json.RawMessage(tt.args.payload))
|
||||||
tt.wantErr(t, err)
|
tt.wantErr(t, err)
|
||||||
|
|
||||||
want, err := json.Marshal(tt.want)
|
want, err := json.Marshal(tt.want)
|
||||||
113
app/reaction/schedule/schedule.go
Normal file
113
app/reaction/schedule/schedule.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package schedule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/action"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scheduler struct {
|
||||||
|
scheduler gocron.Scheduler
|
||||||
|
queries *sqlc.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
type Schedule struct {
|
||||||
|
Expression string `json:"expression"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(ctx context.Context, queries *sqlc.Queries) (*Scheduler, error) {
|
||||||
|
innerScheduler, err := gocron.NewScheduler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create scheduler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler := &Scheduler{
|
||||||
|
scheduler: innerScheduler,
|
||||||
|
queries: queries,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scheduler.loadJobs(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load jobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
innerScheduler.Start()
|
||||||
|
|
||||||
|
return scheduler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) AddReaction(reaction *sqlc.Reaction) error {
|
||||||
|
var schedule Schedule
|
||||||
|
if err := json.Unmarshal(reaction.Triggerdata, &schedule); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal schedule data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.scheduler.NewJob(
|
||||||
|
gocron.CronJob(schedule.Expression, false),
|
||||||
|
gocron.NewTask(
|
||||||
|
func(ctx context.Context) {
|
||||||
|
settings, err := settings.Load(ctx, s.queries)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to load settings", "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = action.Run(ctx, settings.Meta.AppURL, s.queries, reaction.Action, reaction.Actiondata, json.RawMessage("{}"))
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to run schedule reaction", "error", err, "reaction_id", reaction.ID)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
gocron.WithTags(reaction.ID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create new job for reaction %s: %w", reaction.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) RemoveReaction(id string) {
|
||||||
|
s.scheduler.RemoveByTags(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) loadJobs(ctx context.Context) error {
|
||||||
|
reactions, err := database.PaginateItems(ctx, func(ctx context.Context, offset, limit int64) ([]sqlc.ListReactionsByTriggerRow, error) {
|
||||||
|
return s.queries.ListReactionsByTrigger(ctx, sqlc.ListReactionsByTriggerParams{Trigger: "schedule", Limit: limit, Offset: offset})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find schedule reaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reactions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for _, reaction := range reactions {
|
||||||
|
if err := s.AddReaction(&sqlc.Reaction{
|
||||||
|
Action: reaction.Action,
|
||||||
|
Actiondata: reaction.Actiondata,
|
||||||
|
Created: reaction.Created,
|
||||||
|
ID: reaction.ID,
|
||||||
|
Name: reaction.Name,
|
||||||
|
Trigger: reaction.Trigger,
|
||||||
|
Triggerdata: reaction.Triggerdata,
|
||||||
|
Updated: reaction.Updated,
|
||||||
|
}); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to add reaction %s: %w", reaction.ID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
17
app/reaction/trigger.go
Normal file
17
app/reaction/trigger.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package reaction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/hook"
|
||||||
|
reactionHook "github.com/SecurityBrewery/catalyst/app/reaction/trigger/hook"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/trigger/webhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BindHooks(hooks *hook.Hooks, router chi.Router, queries *sqlc.Queries, test bool) error {
|
||||||
|
reactionHook.BindHooks(hooks, queries, test)
|
||||||
|
webhook.BindHooks(router, queries)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
122
app/reaction/trigger/hook/hook.go
Normal file
122
app/reaction/trigger/hook/hook.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth/usercontext"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/hook"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/action"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/settings"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/webhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Hook struct {
|
||||||
|
Collections []string `json:"collections"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BindHooks(hooks *hook.Hooks, queries *sqlc.Queries, test bool) {
|
||||||
|
hooks.OnRecordAfterCreateRequest.Subscribe(func(ctx context.Context, table string, record any) {
|
||||||
|
bindHook(ctx, queries, database.CreateAction, table, record, test)
|
||||||
|
})
|
||||||
|
hooks.OnRecordAfterUpdateRequest.Subscribe(func(ctx context.Context, table string, record any) {
|
||||||
|
bindHook(ctx, queries, database.UpdateAction, table, record, test)
|
||||||
|
})
|
||||||
|
hooks.OnRecordAfterDeleteRequest.Subscribe(func(ctx context.Context, table string, record any) {
|
||||||
|
bindHook(ctx, queries, database.DeleteAction, table, record, test)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindHook(ctx context.Context, queries *sqlc.Queries, event, collection string, record any, test bool) {
|
||||||
|
user, ok := usercontext.UserFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
slog.ErrorContext(ctx, "failed to get user from session")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !test {
|
||||||
|
go mustRunHook(context.Background(), queries, collection, event, record, user) //nolint:contextcheck
|
||||||
|
} else {
|
||||||
|
mustRunHook(ctx, queries, collection, event, record, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRunHook(ctx context.Context, queries *sqlc.Queries, collection, event string, record any, auth *sqlc.User) {
|
||||||
|
if err := runHook(ctx, queries, collection, event, record, auth); err != nil {
|
||||||
|
slog.ErrorContext(ctx, fmt.Sprintf("failed to run hook reaction: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHook(ctx context.Context, queries *sqlc.Queries, collection, event string, record any, auth *sqlc.User) error {
|
||||||
|
payload, err := json.Marshal(&webhook.Payload{
|
||||||
|
Action: event,
|
||||||
|
Collection: collection,
|
||||||
|
Record: record,
|
||||||
|
Auth: webhook.SanitizeUser(auth),
|
||||||
|
Admin: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks, err := findByHookTrigger(ctx, queries, collection, event)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find hook by trigger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hooks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := settings.Load(ctx, queries)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for _, hook := range hooks {
|
||||||
|
_, err = action.Run(ctx, settings.Meta.AppURL, queries, hook.Action, hook.Actiondata, payload)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findByHookTrigger(ctx context.Context, queries *sqlc.Queries, collection, event string) ([]*sqlc.ListReactionsByTriggerRow, error) {
|
||||||
|
reactions, err := database.PaginateItems(ctx, func(ctx context.Context, offset, limit int64) ([]sqlc.ListReactionsByTriggerRow, error) {
|
||||||
|
return queries.ListReactionsByTrigger(ctx, sqlc.ListReactionsByTriggerParams{Trigger: "hook", Limit: limit, Offset: offset})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find hook reaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reactions) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedRecords []*sqlc.ListReactionsByTriggerRow
|
||||||
|
|
||||||
|
for _, reaction := range reactions {
|
||||||
|
var hook Hook
|
||||||
|
if err := json.Unmarshal(reaction.Triggerdata, &hook); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(hook.Collections, collection) && slices.Contains(hook.Events, event) {
|
||||||
|
matchedRecords = append(matchedRecords, &reaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedRecords, nil
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package webhook
|
package webhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
@@ -14,10 +13,3 @@ type Request struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
IsBase64Encoded bool `json:"isBase64Encoded"`
|
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
|
|
||||||
}
|
|
||||||
162
app/reaction/trigger/webhook/webhook.go
Normal file
162
app/reaction/trigger/webhook/webhook.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/action"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/reaction/action/webhook"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Webhook struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = "/reaction/"
|
||||||
|
|
||||||
|
func BindHooks(router chi.Router, queries *sqlc.Queries) {
|
||||||
|
router.HandleFunc(prefix+"*", handle(queries))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(queries *sqlc.Queries) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reaction, payload, status, err := parseRequest(queries, r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), status)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := settings.Load(r.Context(), queries)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to load settings: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := action.Run(r.Context(), settings.Meta.AppURL, queries, reaction.Action, reaction.Actiondata, payload)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOutput(w, output); err != nil {
|
||||||
|
slog.ErrorContext(r.Context(), "failed to write output", "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRequest(queries *sqlc.Queries, r *http.Request) (*sqlc.ListReactionsByTriggerRow, []byte, int, error) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||||
|
return nil, nil, http.StatusNotFound, fmt.Errorf("wrong prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
reactionName := strings.TrimPrefix(r.URL.Path, prefix)
|
||||||
|
|
||||||
|
reaction, trigger, found, err := findByWebhookTrigger(r.Context(), queries, reactionName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, http.StatusNotFound, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return nil, nil, http.StatusNotFound, fmt.Errorf("reaction not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if trigger.Token != "" {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return nil, nil, http.StatusUnauthorized, fmt.Errorf("missing token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if trigger.Token != strings.TrimPrefix(auth, "Bearer ") {
|
||||||
|
return nil, nil, http.StatusUnauthorized, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reaction, payload, http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findByWebhookTrigger(ctx context.Context, queries *sqlc.Queries, path string) (*sqlc.ListReactionsByTriggerRow, *Webhook, bool, error) {
|
||||||
|
reactions, err := database.PaginateItems(ctx, func(ctx context.Context, offset, limit int64) ([]sqlc.ListReactionsByTriggerRow, error) {
|
||||||
|
return queries.ListReactionsByTrigger(ctx, sqlc.ListReactionsByTriggerParams{Trigger: "webhook", Limit: limit, Offset: offset})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reactions) == 0 {
|
||||||
|
return nil, nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reaction := range reactions {
|
||||||
|
var webhook Webhook
|
||||||
|
if err := json.Unmarshal(reaction.Triggerdata, &webhook); err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if webhook.Path == path {
|
||||||
|
return &reaction, &webhook, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOutput(w http.ResponseWriter, 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 {
|
||||||
|
w.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 json.Valid(output) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(output)
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
290
app/rootstore/rootstore.go
Normal file
290
app/rootstore/rootstore.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
package rootstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/tus/tusd/v2/pkg/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultFilePerm = os.FileMode(0o664)
|
||||||
|
defaultDirectoryPerm = os.FileMode(0o754)
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StorageKeyPath is the key of the path of uploaded file in handler.FileInfo.Storage.
|
||||||
|
StorageKeyPath = "Path"
|
||||||
|
// StorageKeyInfoPath is the key of the path of .info file in handler.FileInfo.Storage.
|
||||||
|
StorageKeyInfoPath = "InfoPath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RootStore is a file system based data store for tusd.
|
||||||
|
type RootStore struct {
|
||||||
|
root *os.Root
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(root *os.Root) RootStore {
|
||||||
|
return RootStore{root: root}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseIn sets this store as the core data store in the passed composer and adds
|
||||||
|
// all possible extension to it.
|
||||||
|
func (store RootStore) UseIn(composer *handler.StoreComposer) {
|
||||||
|
composer.UseCore(store)
|
||||||
|
composer.UseTerminater(store)
|
||||||
|
composer.UseConcater(store)
|
||||||
|
composer.UseLengthDeferrer(store)
|
||||||
|
composer.UseContentServer(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store RootStore) NewUpload(_ context.Context, info handler.FileInfo) (handler.Upload, error) {
|
||||||
|
if info.ID == "" {
|
||||||
|
info.ID = rand.Text()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The .info file's location can directly be deduced from the upload ID
|
||||||
|
infoPath := store.infoPath(info.ID)
|
||||||
|
// The binary file's location might be modified by the pre-create hook.
|
||||||
|
var binPath string
|
||||||
|
if info.Storage != nil && info.Storage[StorageKeyPath] != "" {
|
||||||
|
binPath = info.Storage[StorageKeyPath]
|
||||||
|
} else {
|
||||||
|
binPath = store.defaultBinPath(info.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Storage = map[string]string{
|
||||||
|
"Type": "rootstore",
|
||||||
|
StorageKeyPath: binPath,
|
||||||
|
StorageKeyInfoPath: infoPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = store.root.MkdirAll(filepath.Dir(binPath), defaultDirectoryPerm)
|
||||||
|
|
||||||
|
// Create binary file with no content
|
||||||
|
if err := store.root.WriteFile(binPath, nil, defaultFilePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
upload := &fileUpload{
|
||||||
|
root: store.root,
|
||||||
|
info: info,
|
||||||
|
infoPath: infoPath,
|
||||||
|
binPath: binPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInfo creates the file by itself if necessary
|
||||||
|
if err := upload.writeInfo(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return upload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store RootStore) GetUpload(_ context.Context, id string) (handler.Upload, error) {
|
||||||
|
infoPath := store.infoPath(id)
|
||||||
|
|
||||||
|
data, err := fs.ReadFile(store.root.FS(), filepath.ToSlash(infoPath))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Interpret os.ErrNotExist as 404 Not Found
|
||||||
|
err = handler.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var info handler.FileInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the info file contains a custom path to the binary file, we use that. If not, we
|
||||||
|
// fall back to the default value (although the Path property should always be set in recent
|
||||||
|
// tusd versions).
|
||||||
|
var binPath string
|
||||||
|
if info.Storage != nil && info.Storage[StorageKeyPath] != "" {
|
||||||
|
// No filepath.Join here because the joining already happened in NewUpload. Duplicate joining
|
||||||
|
// with relative paths lead to incorrect paths
|
||||||
|
binPath = info.Storage[StorageKeyPath]
|
||||||
|
} else {
|
||||||
|
binPath = store.defaultBinPath(info.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := store.root.Stat(binPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Interpret os.ErrNotExist as 404 Not Found
|
||||||
|
err = handler.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Offset = stat.Size()
|
||||||
|
|
||||||
|
return &fileUpload{
|
||||||
|
root: store.root,
|
||||||
|
info: info,
|
||||||
|
binPath: binPath,
|
||||||
|
infoPath: infoPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store RootStore) AsTerminatableUpload(upload handler.Upload) handler.TerminatableUpload {
|
||||||
|
return upload.(*fileUpload) //nolint:forcetypeassert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store RootStore) AsLengthDeclarableUpload(upload handler.Upload) handler.LengthDeclarableUpload {
|
||||||
|
return upload.(*fileUpload) //nolint:forcetypeassert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store RootStore) AsConcatableUpload(upload handler.Upload) handler.ConcatableUpload {
|
||||||
|
return upload.(*fileUpload) //nolint:forcetypeassert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store RootStore) AsServableUpload(upload handler.Upload) handler.ServableUpload {
|
||||||
|
return upload.(*fileUpload) //nolint:forcetypeassert
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultBinPath returns the path to the file storing the binary data, if it is
|
||||||
|
// not customized using the pre-create hook.
|
||||||
|
func (store RootStore) defaultBinPath(id string) string {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// infoPath returns the path to the .info file storing the file's info.
|
||||||
|
func (store RootStore) infoPath(id string) string {
|
||||||
|
return id + ".info"
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileUpload struct {
|
||||||
|
root *os.Root
|
||||||
|
|
||||||
|
// info stores the current information about the upload
|
||||||
|
info handler.FileInfo
|
||||||
|
// infoPath is the path to the .info file
|
||||||
|
infoPath string
|
||||||
|
// binPath is the path to the binary file (which has no extension)
|
||||||
|
binPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) GetInfo(_ context.Context) (handler.FileInfo, error) {
|
||||||
|
return upload.info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) WriteChunk(_ context.Context, _ int64, src io.Reader) (int64, error) {
|
||||||
|
file, err := upload.root.OpenFile(upload.binPath, os.O_WRONLY|os.O_APPEND, defaultFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Avoid the use of defer file.Close() here to ensure no errors are lost
|
||||||
|
// See https://github.com/tus/tusd/issues/698.
|
||||||
|
|
||||||
|
n, err := io.Copy(file, src)
|
||||||
|
upload.info.Offset += n
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) GetReader(_ context.Context) (io.ReadCloser, error) {
|
||||||
|
return upload.root.Open(upload.binPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) Terminate(_ context.Context) error {
|
||||||
|
// We ignore errors indicating that the files cannot be found because we want
|
||||||
|
// to delete them anyways. The files might be removed by a cron job for cleaning up
|
||||||
|
// or some file might have been removed when tusd crashed during the termination.
|
||||||
|
err := upload.root.Remove(upload.binPath)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = upload.root.Remove(upload.infoPath)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) ConcatUploads(_ context.Context, uploads []handler.Upload) (err error) {
|
||||||
|
file, err := upload.root.OpenFile(upload.binPath, os.O_WRONLY|os.O_APPEND, defaultFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
// Ensure that close error is propagated, if it occurs.
|
||||||
|
// See https://github.com/tus/tusd/issues/698.
|
||||||
|
cerr := file.Close()
|
||||||
|
if err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for _, partialUpload := range uploads {
|
||||||
|
if err := partialUpload.(*fileUpload).appendTo(file); err != nil { //nolint:forcetypeassert
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) appendTo(file *os.File) error {
|
||||||
|
src, err := upload.root.Open(upload.binPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(file, src); err != nil {
|
||||||
|
src.Close()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return src.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) DeclareLength(_ context.Context, length int64) error {
|
||||||
|
upload.info.Size = length
|
||||||
|
upload.info.SizeIsDeferred = false
|
||||||
|
|
||||||
|
return upload.writeInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInfo updates the entire information. Everything will be overwritten.
|
||||||
|
func (upload *fileUpload) writeInfo() error {
|
||||||
|
data, err := json.Marshal(upload.info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = upload.root.MkdirAll(filepath.Dir(upload.infoPath), defaultDirectoryPerm)
|
||||||
|
|
||||||
|
return upload.root.WriteFile(upload.infoPath, data, defaultFilePerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) FinishUpload(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upload *fileUpload) ServeContent(_ context.Context, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
http.ServeFileFS(w, r, upload.root.FS(), filepath.ToSlash(upload.binPath))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
391
app/rootstore/rootstore_test.go
Normal file
391
app/rootstore/rootstore_test.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
package rootstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tus/tusd/v2/pkg/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test interface implementation of FSStore.
|
||||||
|
var (
|
||||||
|
_ handler.DataStore = RootStore{}
|
||||||
|
_ handler.TerminaterDataStore = RootStore{}
|
||||||
|
_ handler.ConcaterDataStore = RootStore{}
|
||||||
|
_ handler.LengthDeferrerDataStore = RootStore{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFSStore(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { root.Close() })
|
||||||
|
|
||||||
|
store := New(root)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// Create new upload
|
||||||
|
upload, err := store.NewUpload(ctx, handler.FileInfo{
|
||||||
|
Size: 42,
|
||||||
|
MetaData: map[string]string{
|
||||||
|
"hello": "world",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, upload)
|
||||||
|
|
||||||
|
// Check info without writing
|
||||||
|
info, err := upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 42, info.Size)
|
||||||
|
assert.EqualValues(t, 0, info.Offset)
|
||||||
|
assert.Equal(t, handler.MetaData{"hello": "world"}, info.MetaData)
|
||||||
|
assert.Len(t, info.Storage, 3)
|
||||||
|
assert.Equal(t, "rootstore", info.Storage["Type"])
|
||||||
|
assert.Equal(t, info.ID, info.Storage["Path"])
|
||||||
|
assert.Equal(t, info.ID+".info", info.Storage["InfoPath"])
|
||||||
|
|
||||||
|
// Write data to upload
|
||||||
|
bytesWritten, err := upload.WriteChunk(ctx, 0, strings.NewReader("hello world"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, len("hello world"), bytesWritten)
|
||||||
|
|
||||||
|
// Check new offset
|
||||||
|
info, err = upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 42, info.Size)
|
||||||
|
assert.EqualValues(t, 11, info.Offset)
|
||||||
|
|
||||||
|
// Read content
|
||||||
|
reader, err := upload.GetReader(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello world", string(content))
|
||||||
|
reader.Close()
|
||||||
|
|
||||||
|
// Serve content
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.Header.Set("Range", "bytes=0-4")
|
||||||
|
|
||||||
|
err = store.AsServableUpload(upload).ServeContent(t.Context(), w, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusPartialContent, w.Code)
|
||||||
|
assert.Equal(t, "5", w.Header().Get("Content-Length"))
|
||||||
|
assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type"))
|
||||||
|
assert.Equal(t, "bytes 0-4/11", w.Header().Get("Content-Range"))
|
||||||
|
assert.NotEmpty(t, w.Header().Get("Last-Modified"))
|
||||||
|
assert.Equal(t, "hello", w.Body.String())
|
||||||
|
|
||||||
|
// Terminate upload
|
||||||
|
require.NoError(t, store.AsTerminatableUpload(upload).Terminate(ctx))
|
||||||
|
|
||||||
|
// Test if upload is deleted
|
||||||
|
upload, err = store.GetUpload(ctx, info.ID)
|
||||||
|
assert.Nil(t, upload)
|
||||||
|
assert.Equal(t, handler.ErrNotFound, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateDirectories tests whether an upload with a slash in its ID causes
|
||||||
|
// the correct directories to be created.
|
||||||
|
func TestFSStoreCreateDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { root.Close() })
|
||||||
|
|
||||||
|
store := New(root)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// Create new upload
|
||||||
|
upload, err := store.NewUpload(ctx, handler.FileInfo{
|
||||||
|
ID: "hello/world/123",
|
||||||
|
Size: 42,
|
||||||
|
MetaData: map[string]string{
|
||||||
|
"hello": "world",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, upload)
|
||||||
|
|
||||||
|
// Check info without writing
|
||||||
|
info, err := upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 42, info.Size)
|
||||||
|
assert.EqualValues(t, 0, info.Offset)
|
||||||
|
assert.Equal(t, handler.MetaData{"hello": "world"}, info.MetaData)
|
||||||
|
assert.Len(t, info.Storage, 3)
|
||||||
|
assert.Equal(t, "rootstore", info.Storage["Type"])
|
||||||
|
assert.Equal(t, filepath.FromSlash(info.ID), info.Storage["Path"])
|
||||||
|
assert.Equal(t, filepath.FromSlash(info.ID+".info"), info.Storage["InfoPath"])
|
||||||
|
|
||||||
|
// Write data to upload
|
||||||
|
bytesWritten, err := upload.WriteChunk(ctx, 0, strings.NewReader("hello world"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, len("hello world"), bytesWritten)
|
||||||
|
|
||||||
|
// Check new offset
|
||||||
|
info, err = upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 42, info.Size)
|
||||||
|
assert.EqualValues(t, 11, info.Offset)
|
||||||
|
|
||||||
|
// Read content
|
||||||
|
reader, err := upload.GetReader(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello world", string(content))
|
||||||
|
reader.Close()
|
||||||
|
|
||||||
|
// Check that the file and directory exists on disk
|
||||||
|
statInfo, err := os.Stat(filepath.Join(tmp, "hello/world/123"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, statInfo.Mode().IsRegular())
|
||||||
|
assert.EqualValues(t, 11, statInfo.Size())
|
||||||
|
statInfo, err = os.Stat(filepath.Join(tmp, "hello/world/"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, statInfo.Mode().IsDir())
|
||||||
|
|
||||||
|
// Terminate upload
|
||||||
|
require.NoError(t, store.AsTerminatableUpload(upload).Terminate(ctx))
|
||||||
|
|
||||||
|
// Test if upload is deleted
|
||||||
|
upload, err = store.GetUpload(ctx, info.ID)
|
||||||
|
assert.Nil(t, upload)
|
||||||
|
assert.Equal(t, handler.ErrNotFound, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSStoreNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { root.Close() })
|
||||||
|
|
||||||
|
store := New(root)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
upload, err := store.GetUpload(ctx, "upload-that-does-not-exist")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, handler.ErrNotFound, err)
|
||||||
|
assert.Nil(t, upload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSStoreConcatUploads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { root.Close() })
|
||||||
|
|
||||||
|
store := New(root)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// Create new upload to hold concatenated upload
|
||||||
|
finUpload, err := store.NewUpload(ctx, handler.FileInfo{Size: 9})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, finUpload)
|
||||||
|
|
||||||
|
finInfo, err := finUpload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
finID := finInfo.ID
|
||||||
|
|
||||||
|
// Create three uploads for concatenating
|
||||||
|
partialUploads := make([]handler.Upload, 3)
|
||||||
|
contents := []string{
|
||||||
|
"abc",
|
||||||
|
"def",
|
||||||
|
"ghi",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
upload, err := store.NewUpload(ctx, handler.FileInfo{Size: 3})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
n, err := upload.WriteChunk(ctx, 0, strings.NewReader(contents[i]))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 3, n)
|
||||||
|
|
||||||
|
partialUploads[i] = upload
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.AsConcatableUpload(finUpload).ConcatUploads(ctx, partialUploads)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check offset
|
||||||
|
finUpload, err = store.GetUpload(ctx, finID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
info, err := finUpload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 9, info.Size)
|
||||||
|
assert.EqualValues(t, 9, info.Offset)
|
||||||
|
|
||||||
|
// Read content
|
||||||
|
reader, err := finUpload.GetReader(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "abcdefghi", string(content))
|
||||||
|
reader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSStoreDeclareLength(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { root.Close() })
|
||||||
|
|
||||||
|
store := New(root)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
upload, err := store.NewUpload(ctx, handler.FileInfo{
|
||||||
|
Size: 0,
|
||||||
|
SizeIsDeferred: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, upload)
|
||||||
|
|
||||||
|
info, err := upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 0, info.Size)
|
||||||
|
assert.True(t, info.SizeIsDeferred)
|
||||||
|
|
||||||
|
err = store.AsLengthDeclarableUpload(upload).DeclareLength(ctx, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedInfo, err := upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 100, updatedInfo.Size)
|
||||||
|
assert.False(t, updatedInfo.SizeIsDeferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomRelativePath tests whether the upload's destination can be customized
|
||||||
|
// relative to the storage directory.
|
||||||
|
func TestFSStoreCustomRelativePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { root.Close() })
|
||||||
|
|
||||||
|
store := New(root)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// Create new upload
|
||||||
|
upload, err := store.NewUpload(ctx, handler.FileInfo{
|
||||||
|
ID: "folder1/info",
|
||||||
|
Size: 42,
|
||||||
|
Storage: map[string]string{
|
||||||
|
"Path": "./folder2/bin",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, upload)
|
||||||
|
|
||||||
|
// Check info without writing
|
||||||
|
info, err := upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 42, info.Size)
|
||||||
|
assert.EqualValues(t, 0, info.Offset)
|
||||||
|
assert.Len(t, info.Storage, 3)
|
||||||
|
assert.Equal(t, "rootstore", info.Storage["Type"])
|
||||||
|
assert.Equal(t, filepath.FromSlash("./folder2/bin"), info.Storage["Path"])
|
||||||
|
assert.Equal(t, filepath.FromSlash("folder1/info.info"), info.Storage["InfoPath"])
|
||||||
|
|
||||||
|
// Write data to upload
|
||||||
|
bytesWritten, err := upload.WriteChunk(ctx, 0, strings.NewReader("hello world"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, len("hello world"), bytesWritten)
|
||||||
|
|
||||||
|
// Check new offset
|
||||||
|
info, err = upload.GetInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 42, info.Size)
|
||||||
|
assert.EqualValues(t, 11, info.Offset)
|
||||||
|
|
||||||
|
// Read content
|
||||||
|
reader, err := upload.GetReader(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello world", string(content))
|
||||||
|
reader.Close()
|
||||||
|
|
||||||
|
// Check that the output file and info file exist on disk
|
||||||
|
statInfo, err := os.Stat(filepath.Join(tmp, "folder2/bin"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, statInfo.Mode().IsRegular())
|
||||||
|
assert.EqualValues(t, 11, statInfo.Size())
|
||||||
|
statInfo, err = os.Stat(filepath.Join(tmp, "folder1/info.info"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, statInfo.Mode().IsRegular())
|
||||||
|
|
||||||
|
// Terminate upload
|
||||||
|
require.NoError(t, store.AsTerminatableUpload(upload).Terminate(ctx))
|
||||||
|
|
||||||
|
// Test if upload is deleted
|
||||||
|
upload, err = store.GetUpload(ctx, info.ID)
|
||||||
|
assert.Nil(t, upload)
|
||||||
|
assert.Equal(t, handler.ErrNotFound, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomAbsolutePath tests whether the upload's destination can be customized
|
||||||
|
// using an absolute path to the storage directory.
|
||||||
|
func TestFSStoreCustomAbsolutePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { root.Close() })
|
||||||
|
|
||||||
|
store := New(root)
|
||||||
|
|
||||||
|
// Create new upload, but the Path property points to a directory
|
||||||
|
// outside of the directory given to FSStore
|
||||||
|
binPath := filepath.Join(t.TempDir(), "dir/my-upload.bin")
|
||||||
|
_, err = store.NewUpload(t.Context(), handler.FileInfo{
|
||||||
|
ID: "my-upload",
|
||||||
|
Size: 42,
|
||||||
|
Storage: map[string]string{
|
||||||
|
"Path": binPath,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = os.Stat(binPath)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
79
app/router/demomode.go
Normal file
79
app/router/demomode.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func demoMode(queries *sqlc.Queries) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isCriticalPath(r) && isCriticalMethod(r) && isDemoMode(r.Context(), queries) {
|
||||||
|
http.Error(w, "Cannot modify reactions or files in demo mode", http.StatusForbidden)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCriticalPath(r *http.Request) bool {
|
||||||
|
// Define critical paths that should not be accessed in demo mode
|
||||||
|
criticalPaths := []string{
|
||||||
|
"/api/files",
|
||||||
|
"/api/groups",
|
||||||
|
"/api/reactions",
|
||||||
|
"/api/settings",
|
||||||
|
"/api/users",
|
||||||
|
"/api/webhooks",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range criticalPaths {
|
||||||
|
if strings.Contains(r.URL.Path, path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCriticalMethod(r *http.Request) bool {
|
||||||
|
return !slices.Contains([]string{http.MethodHead, http.MethodGet}, r.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDemoMode(ctx context.Context, queries *sqlc.Queries) bool {
|
||||||
|
var demoMode bool
|
||||||
|
|
||||||
|
if err := database.Paginate(ctx, func(ctx context.Context, offset, limit int64) (nextPage bool, err error) {
|
||||||
|
slog.InfoContext(ctx, "Checking for demo mode", "offset", offset, "limit", limit)
|
||||||
|
|
||||||
|
features, err := queries.ListFeatures(ctx, sqlc.ListFeaturesParams{Offset: offset, Limit: limit})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, feature := range features {
|
||||||
|
if feature.Key == "demo" {
|
||||||
|
demoMode = true
|
||||||
|
|
||||||
|
return false, nil // Stop pagination if demo mode is found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(features) > 0, nil
|
||||||
|
}); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to check demo mode", "error", err)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return demoMode
|
||||||
|
}
|
||||||
115
app/router/demomode_test.go
Normal file
115
app/router/demomode_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_isCriticalPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"/api/reactions/1", true},
|
||||||
|
{"/api/files/1", true},
|
||||||
|
{"/api/other", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||||
|
assert.Equal(t, tt.want, isCriticalPath(req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_isCriticalMethod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
method string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{http.MethodPost, true},
|
||||||
|
{http.MethodPut, true},
|
||||||
|
{http.MethodGet, false},
|
||||||
|
{http.MethodHead, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
req := httptest.NewRequest(tt.method, "/", nil)
|
||||||
|
assert.Equal(t, tt.want, isCriticalMethod(req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_isDemoMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := data.NewTestDB(t, t.TempDir())
|
||||||
|
assert.False(t, isDemoMode(t.Context(), queries))
|
||||||
|
|
||||||
|
_, err := queries.CreateFeature(t.Context(), "demo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, isDemoMode(t.Context(), queries))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_demoModeMiddleware(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := data.NewTestDB(t, t.TempDir())
|
||||||
|
mw := demoMode(queries)
|
||||||
|
nextCalled := false
|
||||||
|
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
nextCalled = true
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
})
|
||||||
|
|
||||||
|
// not demo mode
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/reactions", nil).WithContext(t.Context())
|
||||||
|
mw(next).ServeHTTP(rr, req)
|
||||||
|
assert.True(t, nextCalled)
|
||||||
|
assert.Equal(t, http.StatusTeapot, rr.Code)
|
||||||
|
|
||||||
|
// enable demo mode
|
||||||
|
_, err := queries.CreateFeature(t.Context(), "demo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
nextCalled = false
|
||||||
|
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/reactions", nil).WithContext(t.Context())
|
||||||
|
mw(next).ServeHTTP(rr, req)
|
||||||
|
assert.False(t, nextCalled)
|
||||||
|
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||||
|
|
||||||
|
// non critical path
|
||||||
|
nextCalled = false
|
||||||
|
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/other", nil).WithContext(t.Context())
|
||||||
|
mw(next).ServeHTTP(rr, req)
|
||||||
|
assert.True(t, nextCalled)
|
||||||
|
assert.Equal(t, http.StatusTeapot, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_handlers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries := data.NewTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
// healthHandler
|
||||||
|
healthRR := httptest.NewRecorder()
|
||||||
|
|
||||||
|
healthReq := httptest.NewRequest(http.MethodGet, "/health", nil).WithContext(t.Context())
|
||||||
|
healthHandler(queries)(healthRR, healthReq)
|
||||||
|
assert.Equal(t, http.StatusOK, healthRR.Code)
|
||||||
|
assert.Equal(t, "OK", healthRR.Body.String())
|
||||||
|
}
|
||||||
37
app/router/http.go
Normal file
37
app/router/http.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func staticFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if devServer := os.Getenv("UI_DEVSERVER"); devServer != "" {
|
||||||
|
u, _ := url.Parse(devServer)
|
||||||
|
|
||||||
|
r.Host = r.URL.Host
|
||||||
|
|
||||||
|
httputil.NewSingleHostReverseProxy(u).ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vueStatic(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vueStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handler := http.FileServer(http.FS(ui.UI()))
|
||||||
|
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/ui/assets/") {
|
||||||
|
handler = http.StripPrefix("/ui", handler)
|
||||||
|
} else {
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
30
app/router/http_test.go
Normal file
30
app/router/http_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStaticFiles_DevServer(t *testing.T) {
|
||||||
|
t.Setenv("UI_DEVSERVER", "http://localhost:1234")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/ui/assets/test.js", nil)
|
||||||
|
|
||||||
|
// This will try to proxy, but since the dev server isn't running, it should not panic
|
||||||
|
// We just want to make sure it doesn't crash
|
||||||
|
staticFiles(rec, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticFiles_VueStatic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/ui/assets/test.js", nil)
|
||||||
|
staticFiles(rec, r)
|
||||||
|
// Should not panic, and should serve something (even if it's a 404)
|
||||||
|
if rec.Result().StatusCode == 0 {
|
||||||
|
t.Error("expected a status code from vueStatic")
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/router/router.go
Normal file
69
app/router/router.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/google/martian/v3/cors"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/auth"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/mail"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/service"
|
||||||
|
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(service *service.Service, queries *sqlc.Queries, uploader *upload.Uploader, mailer *mail.Mailer) (*chi.Mux, error) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// middleware for the router
|
||||||
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
|
return http.Handler(cors.NewHandler(next))
|
||||||
|
})
|
||||||
|
r.Use(demoMode(queries))
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Timeout(time.Second * 60))
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
// base routes
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/ui/", http.StatusFound)
|
||||||
|
})
|
||||||
|
r.Get("/ui/*", staticFiles)
|
||||||
|
r.Get("/health", healthHandler(queries))
|
||||||
|
|
||||||
|
// auth routes
|
||||||
|
r.Mount("/auth", auth.Server(queries, mailer))
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
r.With(auth.Middleware(queries)).Mount("/api", http.StripPrefix("/api", service))
|
||||||
|
|
||||||
|
uploadHandler, err := tusRoutes(queries, uploader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Mount("/files", http.StripPrefix("/files", uploadHandler))
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthHandler(queries *sqlc.Queries) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := queries.ListFeatures(r.Context(), sqlc.ListFeaturesParams{Offset: 0, Limit: 100}); err != nil {
|
||||||
|
slog.ErrorContext(r.Context(), "Failed to get flags", "error", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user