Compare commits
14 Commits
v0.15.0-rc
...
v0.15.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a4baf88a | ||
|
|
148f625b00 | ||
|
|
97ebe9f01a | ||
|
|
770390c4ea | ||
|
|
4e03a52b71 | ||
|
|
e475b38ea4 | ||
|
|
e07afd0f3a | ||
|
|
fedda9daaf | ||
|
|
4d844c567c | ||
|
|
9da90e7cc8 | ||
|
|
d9f759c879 | ||
|
|
1e3f2f24dc | ||
|
|
3cb097126c | ||
|
|
df96362c3c |
14
.github/workflows/ci.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.25' }
|
||||
- run: make install-golangci-lint generate-go
|
||||
- run: git diff --exit-code
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.25' }
|
||||
- run: make install-golangci-lint lint-go
|
||||
|
||||
lint-ui:
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.25' }
|
||||
- run: make test-coverage
|
||||
- uses: codecov/codecov-action@v4
|
||||
with:
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.25' }
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: make install-ui test-ui
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.25' }
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: make install-ui build-ui install-playwright test-playwright
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
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
|
||||
|
||||
6
.github/workflows/goreleaser.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.25' }
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
|
||||
- run: make install-ui build-ui
|
||||
@@ -36,5 +36,5 @@ jobs:
|
||||
-w /go/src/github.com/SecurityBrewery/catalyst \
|
||||
-e CGO_ENABLED=1 \
|
||||
-e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \
|
||||
ghcr.io/goreleaser/goreleaser-cross:latest \
|
||||
release --clean
|
||||
ghcr.io/goreleaser/goreleaser-cross:v1.25.1 \
|
||||
release --clean
|
||||
|
||||
@@ -8,6 +8,7 @@ linters:
|
||||
- dupl
|
||||
- err113
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- funlen
|
||||
- gochecknoglobals
|
||||
- godox
|
||||
@@ -16,6 +17,7 @@ linters:
|
||||
- lll
|
||||
- maintidx
|
||||
- mnd
|
||||
- noinlineerr
|
||||
- nonamedreturns
|
||||
- perfsprint
|
||||
- prealloc
|
||||
@@ -25,6 +27,7 @@ linters:
|
||||
- unparam
|
||||
- varnamelen
|
||||
- wrapcheck
|
||||
- wsl
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
|
||||
@@ -7,7 +7,7 @@ before:
|
||||
builds:
|
||||
- id: darwin-amd64
|
||||
main: ./
|
||||
binary: golang-cross
|
||||
binary: catalyst
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
@@ -22,7 +22,7 @@ builds:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
- id: linux-arm64
|
||||
main: ./
|
||||
binary: golang-cross
|
||||
binary: catalyst
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -37,7 +37,7 @@ builds:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
- id: linux-amd64
|
||||
main: ./
|
||||
binary: golang-cross
|
||||
binary: catalyst
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -52,7 +52,7 @@ builds:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
- id: windows-amd64
|
||||
main: ./
|
||||
binary: golang-cross
|
||||
binary: catalyst
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
@@ -67,7 +67,7 @@ builds:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
- id: windows-arm64
|
||||
main: ./
|
||||
binary: golang-cross
|
||||
binary: catalyst
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
@@ -82,7 +82,7 @@ builds:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
|
||||
dockers:
|
||||
- ids: [ catalyst ]
|
||||
- ids: [ linux-amd64 ]
|
||||
dockerfile: docker/Dockerfile
|
||||
image_templates:
|
||||
- "ghcr.io/securitybrewery/catalyst:main"
|
||||
|
||||
2
Makefile
@@ -4,7 +4,7 @@
|
||||
|
||||
.PHONY: install-golangci-lint
|
||||
install-golangci-lint:
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.1.6
|
||||
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:
|
||||
|
||||
@@ -432,6 +432,7 @@ func weeksAgo(c int) time.Time {
|
||||
|
||||
func dates(ticketCount int) (time.Time, time.Time) {
|
||||
const ticketsPerWeek = 10
|
||||
|
||||
weeks := ticketCount / ticketsPerWeek
|
||||
|
||||
created := gofakeit.DateRange(weeksAgo(1), weeksAgo(weeks+1)).UTC()
|
||||
|
||||
@@ -15,6 +15,7 @@ type DBTX interface {
|
||||
type Queries struct {
|
||||
*ReadQueries
|
||||
*WriteQueries
|
||||
|
||||
ReadDB *sql.DB
|
||||
WriteDB *sql.DB
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type DBTX interface {
|
||||
type Queries struct {
|
||||
*ReadQueries
|
||||
*WriteQueries
|
||||
|
||||
ReadDB *sql.DB
|
||||
WriteDB *sql.DB
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestSQLMigration_UpAndDown(t *testing.T) {
|
||||
require.NoError(t, m.up(t.Context(), queries, dir, uploader))
|
||||
|
||||
// Table should exist
|
||||
_, err = queries.WriteDB.Exec("INSERT INTO test_table (name) VALUES ('foo')")
|
||||
_, err = queries.WriteDB.ExecContext(t.Context(), "INSERT INTO test_table (name) VALUES ('foo')")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ func TestVersionAndSetVersion(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
@@ -60,7 +60,7 @@ func runHook(ctx context.Context, queries *sqlc.Queries, collection, event strin
|
||||
Action: event,
|
||||
Collection: collection,
|
||||
Record: record,
|
||||
Auth: auth,
|
||||
Auth: webhook.SanitizeUser(auth),
|
||||
Admin: nil,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
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
@@ -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)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func isDemoMode(ctx context.Context, queries *sqlc.Queries) bool {
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
return len(features) > 0, nil
|
||||
}); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to check demo mode", "error", err)
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tus/tusd/v2/pkg/filelocker"
|
||||
tusd "github.com/tus/tusd/v2/pkg/handler"
|
||||
"github.com/tus/tusd/v2/pkg/rootstore"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/app/auth"
|
||||
"github.com/SecurityBrewery/catalyst/app/database"
|
||||
"github.com/SecurityBrewery/catalyst/app/database/sqlc"
|
||||
"github.com/SecurityBrewery/catalyst/app/rootstore"
|
||||
"github.com/SecurityBrewery/catalyst/app/upload"
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ func Test_marshal(t *testing.T) {
|
||||
out := marshal(data)
|
||||
|
||||
var res map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(out), &res)
|
||||
require.NoError(t, err, "invalid json")
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/app/auth/usercontext"
|
||||
"github.com/SecurityBrewery/catalyst/app/database"
|
||||
@@ -35,11 +36,43 @@ func BindHooks(hooks *hook.Hooks, queries *sqlc.Queries) {
|
||||
}
|
||||
|
||||
type Payload struct {
|
||||
Action string `json:"action"`
|
||||
Collection string `json:"collection"`
|
||||
Record any `json:"record"`
|
||||
Auth *sqlc.User `json:"auth,omitempty"`
|
||||
Admin *sqlc.User `json:"admin,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Collection string `json:"collection"`
|
||||
Record any `json:"record"`
|
||||
Auth *AuthUser `json:"auth,omitempty"`
|
||||
Admin *AuthUser `json:"admin,omitempty"`
|
||||
}
|
||||
|
||||
type AuthUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Active bool `json:"active"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Lastresetsentat *time.Time `json:"lastresetsentat,omitempty"`
|
||||
Lastverificationsentat *time.Time `json:"lastverificationsentat,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
func SanitizeUser(user *sqlc.User) *AuthUser {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &AuthUser{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Active: user.Active,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
Lastresetsentat: user.Lastresetsentat,
|
||||
Lastverificationsentat: user.Lastverificationsentat,
|
||||
Created: user.Created,
|
||||
Updated: user.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
func event(ctx context.Context, queries *sqlc.Queries, event, collection string, record any) {
|
||||
@@ -67,7 +100,7 @@ func event(ctx context.Context, queries *sqlc.Queries, event, collection string,
|
||||
Action: event,
|
||||
Collection: collection,
|
||||
Record: record,
|
||||
Auth: user,
|
||||
Auth: SanitizeUser(user),
|
||||
Admin: nil,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 167 KiB |
BIN
docs/screenshots/groups.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 155 KiB |
BIN
docs/screenshots/settings.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 229 KiB |
BIN
docs/screenshots/types.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
docs/screenshots/users.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
22
go.mod
@@ -1,17 +1,15 @@
|
||||
module github.com/SecurityBrewery/catalyst
|
||||
|
||||
go 1.24
|
||||
go 1.25
|
||||
|
||||
tool (
|
||||
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||
github.com/sqlc-dev/sqlc/cmd/sqlc
|
||||
)
|
||||
|
||||
replace github.com/tus/tusd/v2 v2.8.0 => github.com/SecurityBrewery/tusd/v2 v2.0.0-20250628083448-4def5f97f3a6
|
||||
|
||||
require (
|
||||
github.com/brianvoe/gofakeit/v7 v7.2.1
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
github.com/go-co-op/gocron/v2 v2.16.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/martian/v3 v3.3.3
|
||||
@@ -22,8 +20,8 @@ require (
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/tus/tusd/v2 v2.8.0
|
||||
github.com/urfave/cli/v3 v3.3.8
|
||||
github.com/wneessen/go-mail v0.6.2
|
||||
golang.org/x/crypto v0.39.0
|
||||
github.com/wneessen/go-mail v0.7.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -84,12 +82,12 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/grpc v1.73.0 // indirect
|
||||
|
||||
118
go.sum
@@ -6,8 +6,6 @@ github.com/Acconut/go-httptest-recorder v1.0.0 h1:TAv2dfnqp/l+SUvIaMAUK4GeN4+wqb
|
||||
github.com/Acconut/go-httptest-recorder v1.0.0/go.mod h1:CwQyhTH1kq/gLyWiRieo7c0uokpu3PXeyF/nZjUNtmM=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/SecurityBrewery/tusd/v2 v2.0.0-20250628083448-4def5f97f3a6 h1:RVwfrJlnyEOigrDU95mJI/DyoaWWUewE8S4bT8PARlg=
|
||||
github.com/SecurityBrewery/tusd/v2 v2.0.0-20250628083448-4def5f97f3a6/go.mod h1:ZfOwo1YI2XpbsvMDLNmLDedopkC7QVebdvywnSNiluA=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
@@ -34,8 +32,8 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
@@ -61,7 +59,6 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
|
||||
github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=
|
||||
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
@@ -188,6 +185,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tus/lockfile v1.2.0 h1:92dMoNyeb5zaNi8eQ79WLqt/npUWUFkaM5ZM9kOMIDM=
|
||||
github.com/tus/lockfile v1.2.0/go.mod h1:JyfWCHNyfd7eGxudGohrkt38kuKRki6L0JH82p2e+mc=
|
||||
github.com/tus/tusd/v2 v2.8.0 h1:X2jGxQ05jAW4inDd2ogmOKqwnb4c/D0lw2yhgHayWyU=
|
||||
github.com/tus/tusd/v2 v2.8.0/go.mod h1:3/zEOVQQIwmJhvNam8phV4x/UQt68ZmZiTzeuJUNhVo=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
|
||||
@@ -198,21 +197,20 @@ github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfP
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb h1:gQ+ZV4wJke/EBKYciZ2MshEouEHFuinB85dY3f5s1q8=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
|
||||
github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8=
|
||||
github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/wneessen/go-mail v0.7.1 h1:rvy63sp14N06/kdGqCYwW8Na5gDCXjTQM1E7So4PuKk=
|
||||
github.com/wneessen/go-mail v0.7.1/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
@@ -229,91 +227,35 @@ go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||
|
||||
@@ -9,6 +9,10 @@ files=(
|
||||
"ticket.png"
|
||||
"tasks.png"
|
||||
"reactions.png"
|
||||
"settings.png"
|
||||
"users.png"
|
||||
"groups.png"
|
||||
"types.png"
|
||||
)
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
|
||||
@@ -49,3 +49,42 @@ test('reactions screenshot', async ({ page }) => {
|
||||
await page.waitForTimeout(7000)
|
||||
await page.screenshot({ path: screenshot('reactions') })
|
||||
})
|
||||
|
||||
test('settings screenshot', async ({ page }) => {
|
||||
await login(page, true)
|
||||
await page.goto('settings')
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible()
|
||||
await page.getByText("Toggle Sidebar").click()
|
||||
await page.waitForTimeout(7000)
|
||||
await page.screenshot({ path: screenshot('settings') })
|
||||
})
|
||||
|
||||
test('users screenshot', async ({ page }) => {
|
||||
await login(page, true)
|
||||
await page.goto('users')
|
||||
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible()
|
||||
await page.getByText("Toggle Sidebar").click()
|
||||
await page.getByText("Test User").click()
|
||||
await page.waitForTimeout(7000)
|
||||
await page.screenshot({ path: screenshot('users') })
|
||||
})
|
||||
|
||||
test('groups screenshot', async ({ page }) => {
|
||||
await login(page, true)
|
||||
await page.goto('groups')
|
||||
await expect(page.getByRole('heading', { name: 'Groups' })).toBeVisible()
|
||||
await page.getByText("Toggle Sidebar").click()
|
||||
await page.getByText("Analyst").click()
|
||||
await page.waitForTimeout(7000)
|
||||
await page.screenshot({ path: screenshot('groups') })
|
||||
})
|
||||
|
||||
test('types screenshot', async ({ page }) => {
|
||||
await login(page, true)
|
||||
await page.goto('types')
|
||||
await expect(page.getByRole('heading', { name: 'Types' })).toBeVisible()
|
||||
await page.getByText("Toggle Sidebar").click()
|
||||
await page.locator('main').getByText("Incident").click()
|
||||
await page.waitForTimeout(7000)
|
||||
await page.screenshot({ path: screenshot('types') })
|
||||
})
|
||||
@@ -5,7 +5,7 @@ export function useAPI() {
|
||||
const authStore = useAuthStore()
|
||||
return new DefaultApi(
|
||||
new Configuration({
|
||||
basePath: 'http://localhost:8090/api',
|
||||
basePath: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
|
||||
@@ -62,7 +62,7 @@ watch(
|
||||
<div v-for="(property, key) in schema?.properties" :key="key">
|
||||
<FormField v-if="property.enum" :name="key" v-slot="{ componentField }" v-model="formdata[key]">
|
||||
<FormItem>
|
||||
<FormLabel :for="key" class="text-right">
|
||||
<FormLabel :for="key" class="text-left">
|
||||
{{ property.title }}
|
||||
</FormLabel>
|
||||
<Select :id="key" class="col-span-3" v-bind="componentField">
|
||||
@@ -87,7 +87,7 @@ watch(
|
||||
v-model="formdata[key]"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel :for="key" class="text-right">
|
||||
<FormLabel :for="key" class="text-left">
|
||||
{{ property.title }}
|
||||
</FormLabel>
|
||||
<Input :id="key" class="col-span-3" v-bind="componentField" />
|
||||
@@ -120,7 +120,7 @@ watch(
|
||||
v-model="formdata[key]"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel :for="key" class="text-right">
|
||||
<FormLabel :for="key" class="text-left">
|
||||
{{ property.title }}
|
||||
</FormLabel>
|
||||
<Input :id="key" class="col-span-3" type="number" v-bind="componentField" />
|
||||
|
||||
@@ -133,7 +133,7 @@ const permissionItems = computed(() => config.value?.permissions || [])
|
||||
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
|
||||
<FormField name="name" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="name" class="text-right">Name</FormLabel>
|
||||
<FormLabel for="name" class="text-left">Name</FormLabel>
|
||||
<Input
|
||||
id="name"
|
||||
class="col-span-3"
|
||||
@@ -152,7 +152,7 @@ const permissionItems = computed(() => config.value?.permissions || [])
|
||||
>
|
||||
<FormItem id="permissions" class="w-full">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel for="permissions" class="text-right">Permissions</FormLabel>
|
||||
<FormLabel for="permissions" class="text-left">Permissions</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<MultiSelect
|
||||
|
||||
@@ -82,7 +82,7 @@ watch(
|
||||
<form @submit="onSubmit" @change="change">
|
||||
<FormField name="group" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel for="group" class="text-right"> Group</FormLabel>
|
||||
<FormLabel for="group" class="text-left"> Group</FormLabel>
|
||||
<Select id="group" v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a group" />
|
||||
|
||||
@@ -109,6 +109,19 @@ const initials = (user: { name?: string } | undefined) => {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Overview</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton :tooltip="'Dashboard'" as-child>
|
||||
<RouterLink to="/dashboard">
|
||||
<Icon name="LayoutDashboard" class="size-4" />
|
||||
<span>Dashboard</span>
|
||||
</RouterLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Tickets</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
<template>
|
||||
<FormField name="actiondata.requirements" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel for="requirements" class="text-right">requirements.txt</FormLabel>
|
||||
<FormLabel for="requirements" class="text-left">requirements.txt</FormLabel>
|
||||
<FormControl>
|
||||
<GrowTextarea id="requirements" class="col-span-3" v-bind="componentField" />
|
||||
</FormControl>
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
</FormField>
|
||||
<FormField name="actiondata.script" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="script" class="text-right">Script</FormLabel>
|
||||
<FormLabel for="script" class="text-left">Script</FormLabel>
|
||||
<FormControl>
|
||||
<GrowTextarea id="script" class="col-span-3" v-bind="componentField" />
|
||||
</FormControl>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input'
|
||||
<template>
|
||||
<FormField name="actiondata.headers" v-slot="{ value, handleChange }">
|
||||
<FormItem>
|
||||
<FormLabel for="headers" class="text-right">Headers</FormLabel>
|
||||
<FormLabel for="headers" class="text-left">Headers</FormLabel>
|
||||
<FormControl>
|
||||
<GrowListTextarea
|
||||
id="headers"
|
||||
@@ -29,7 +29,7 @@ import { Input } from '@/components/ui/input'
|
||||
</FormField>
|
||||
<FormField name="actiondata.url" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="url" class="text-right">URL</FormLabel>
|
||||
<FormLabel for="url" class="text-left">URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="url" v-bind="componentField" placeholder="https://example.com/webhook" />
|
||||
</FormControl>
|
||||
|
||||
@@ -267,7 +267,7 @@ const curlExample = computed(() => {
|
||||
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
|
||||
<FormField name="name" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="name" class="text-right">Name</FormLabel>
|
||||
<FormLabel for="name" class="text-left">Name</FormLabel>
|
||||
<Input id="name" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -280,7 +280,7 @@ const curlExample = computed(() => {
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<FormField name="trigger" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="trigger" class="text-right">Type</FormLabel>
|
||||
<FormLabel for="trigger" class="text-left">Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select id="trigger" class="col-span-3" v-bind="componentField">
|
||||
<SelectTrigger class="font-medium">
|
||||
@@ -321,7 +321,7 @@ const curlExample = computed(() => {
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<FormField name="action" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="action" class="text-right">Type</FormLabel>
|
||||
<FormLabel for="action" class="text-left">Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select id="action" class="col-span-3" v-bind="componentField">
|
||||
<SelectTrigger class="font-medium">
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
<template>
|
||||
<FormField name="triggerdata.collections" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="collections" class="text-right">Collections</FormLabel>
|
||||
<FormLabel for="collections" class="text-left">Collections</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerHookFormFieldCollections id="collections" v-bind="componentField" />
|
||||
</FormControl>
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
|
||||
<FormField name="triggerdata.events" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="events" class="text-right">Events</FormLabel>
|
||||
<FormLabel for="events" class="text-left">Events</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerHookFormFieldEvents id="events" v-bind="componentField" />
|
||||
</FormControl>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Input } from '@/components/ui/input'
|
||||
<template>
|
||||
<FormField name="triggerdata.expression" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="expression" class="text-right"> Cron Expression </FormLabel>
|
||||
<FormLabel for="expression" class="text-left"> Cron Expression </FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="expression"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Input } from '@/components/ui/input'
|
||||
<template>
|
||||
<FormField name="triggerdata.token" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="token" class="text-right">Token</FormLabel>
|
||||
<FormLabel for="token" class="text-left">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="token" class="col-span-3" v-bind="componentField" placeholder="Enter a token" />
|
||||
</FormControl>
|
||||
@@ -27,7 +27,7 @@ import { Input } from '@/components/ui/input'
|
||||
|
||||
<FormField name="triggerdata.path" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="path" class="text-right">Path</FormLabel>
|
||||
<FormLabel for="path" class="text-left">Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="path" class="col-span-3" v-bind="componentField" placeholder="Enter a path" />
|
||||
</FormControl>
|
||||
|
||||
@@ -225,7 +225,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
|
||||
<FormField name="meta.appName" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="meta.appName" class="text-right">App Name</FormLabel>
|
||||
<FormLabel for="meta.appName" class="text-left">App Name</FormLabel>
|
||||
<Input id="meta.appName" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -233,7 +233,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="meta.appUrl" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="meta.appUrl" class="text-right">App URL</FormLabel>
|
||||
<FormLabel for="meta.appUrl" class="text-left">App URL</FormLabel>
|
||||
<Input id="meta.appUrl" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -241,7 +241,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="meta.senderName" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="meta.senderName" class="text-right">Sender Name</FormLabel>
|
||||
<FormLabel for="meta.senderName" class="text-left">Sender Name</FormLabel>
|
||||
<Input id="meta.senderName" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -249,7 +249,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="meta.senderAddress" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="meta.senderAddress" class="text-right">Sender Address</FormLabel>
|
||||
<FormLabel for="meta.senderAddress" class="text-left">Sender Address</FormLabel>
|
||||
<Input id="meta.senderAddress" type="email" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -262,7 +262,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<FormField name="maxDays" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="maxDays" class="text-right">Max Days</FormLabel>
|
||||
<FormLabel for="maxDays" class="text-left">Max Days</FormLabel>
|
||||
<Input id="maxDays" type="number" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -270,7 +270,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="logLevel" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="logLevel" class="text-right">Log Level</FormLabel>
|
||||
<FormLabel for="logLevel" class="text-left">Log Level</FormLabel>
|
||||
<FormControl>
|
||||
<Select id="logLevel" class="col-span-3" v-bind="componentField">
|
||||
<SelectTrigger class="font-medium">
|
||||
@@ -328,7 +328,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="smtp.host" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="smtp.host" class="text-right">Host</FormLabel>
|
||||
<FormLabel for="smtp.host" class="text-left">Host</FormLabel>
|
||||
<Input id="smtp.host" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
<FormDescription>SMTP server hostname (e.g., smtp.gmail.com)</FormDescription>
|
||||
@@ -337,7 +337,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="smtp.port" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="smtp.port" class="text-right">Port</FormLabel>
|
||||
<FormLabel for="smtp.port" class="text-left">Port</FormLabel>
|
||||
<Input id="smtp.port" type="number" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
<FormDescription>Common ports: 25, 465 (SSL), 587 (TLS)</FormDescription>
|
||||
@@ -346,7 +346,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="smtp.username" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="smtp.username" class="text-right">Username</FormLabel>
|
||||
<FormLabel for="smtp.username" class="text-left">Username</FormLabel>
|
||||
<Input id="smtp.username" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -354,7 +354,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="smtp.password" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="smtp.password" class="text-right">Password</FormLabel>
|
||||
<FormLabel for="smtp.password" class="text-left">Password</FormLabel>
|
||||
<Input id="smtp.password" type="password" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -362,7 +362,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="smtp.authMethod" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem>
|
||||
<FormLabel for="smtp.authMethod" class="text-right"> Authentication Method </FormLabel>
|
||||
<FormLabel for="smtp.authMethod" class="text-left"> Authentication Method </FormLabel>
|
||||
<Select id="smtp.authMethod" class="col-span-3" v-bind="componentField">
|
||||
<SelectTrigger class="font-medium">
|
||||
<SelectValue placeholder="Select authentication method" />
|
||||
@@ -392,7 +392,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="smtp.localName" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="smtp.localName" class="text-right">HELO domain</FormLabel>
|
||||
<FormLabel for="smtp.localName" class="text-left">HELO domain</FormLabel>
|
||||
<Input id="smtp.localName" class="col-span-3" v-bind="componentField" />
|
||||
<FormDescription>Optional. Leave empty to use default hostname.</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -408,7 +408,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<FormField name="cron" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="cron" class="text-right">Cron Expression</FormLabel>
|
||||
<FormLabel for="cron" class="text-left">Cron Expression</FormLabel>
|
||||
<Input id="cron" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -416,7 +416,7 @@ const onSubmit = handleSubmit((vals) => {
|
||||
|
||||
<FormField name="cronMaxKeep" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="cronMaxKeep" class="text-right">Max Keep</FormLabel>
|
||||
<FormLabel for="cronMaxKeep" class="text-left">Max Keep</FormLabel>
|
||||
<Input id="cronMaxKeep" type="number" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -142,7 +142,7 @@ watch(
|
||||
<form @submit="onSubmit">
|
||||
<FormField name="name" v-slot="{ componentField }" v-model="name">
|
||||
<FormItem>
|
||||
<FormLabel for="name" class="text-right">Name</FormLabel>
|
||||
<FormLabel for="name" class="text-left">Name</FormLabel>
|
||||
<Input id="name" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -150,7 +150,7 @@ watch(
|
||||
|
||||
<FormField name="description" v-slot="{ componentField }" v-model="description">
|
||||
<FormItem>
|
||||
<FormLabel for="description" class="text-right">Description</FormLabel>
|
||||
<FormLabel for="description" class="text-left">Description</FormLabel>
|
||||
<Input id="description" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
38
ui/src/components/ticket/file/ImageModal.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
imageUrl: string
|
||||
fileName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const closeModal = () => {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="relative rounded-lg bg-white p-2 shadow-xl">
|
||||
<img :src="imageUrl" :alt="fileName" class="max-h-[80vh] max-w-[80vw]" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute -right-1 -top-1 h-8 w-8 rounded-full bg-white"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
<span class="sr-only">Close image modal</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import '@uppy/dashboard/dist/style.min.css'
|
||||
import DeleteDialog from '@/components/common/DeleteDialog.vue'
|
||||
import TicketPanel from '@/components/ticket/TicketPanel.vue'
|
||||
import FileAddDialog from '@/components/ticket/file/FileAddDialog.vue'
|
||||
import ImageModal from '@/components/ticket/file/ImageModal.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
@@ -30,6 +31,36 @@ const props = defineProps<{
|
||||
files: Array<ModelFile> | undefined
|
||||
}>()
|
||||
|
||||
const isImageModalOpen = ref(false)
|
||||
const selectedImageUrl = ref('')
|
||||
const selectedImageName = ref('')
|
||||
|
||||
const isImage = (fileName: string) => {
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg']
|
||||
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||
return extension ? imageExtensions.includes(extension) : false
|
||||
}
|
||||
|
||||
const openImageModal = async (file: ModelFile) => {
|
||||
if (!isImage(file.name)) return
|
||||
try {
|
||||
const response = await fetch(`/api/files/${file.id}/download`, {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` }
|
||||
})
|
||||
const blob = await response.blob()
|
||||
selectedImageUrl.value = window.URL.createObjectURL(blob)
|
||||
selectedImageName.value = file.name
|
||||
isImageModalOpen.value = true
|
||||
} catch (err) {
|
||||
console.error('Error fetching image:', err)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Could not load image for preview.',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = (file: any) => {
|
||||
fetch(`/api/files/${file.id}/download`, {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` }
|
||||
@@ -44,6 +75,8 @@ const downloadFile = (file: any) => {
|
||||
link.download = file.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(_url)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
@@ -82,11 +115,24 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(isImageModalOpen, (isOpen) => {
|
||||
if (!isOpen && selectedImageUrl.value) {
|
||||
window.URL.revokeObjectURL(selectedImageUrl.value)
|
||||
selectedImageUrl.value = ''
|
||||
selectedImageName.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TicketPanel title="Files" @add="dialogOpen = true" :hideAdd="isDemo">
|
||||
<FileAddDialog v-if="!isDemo" v-model="dialogOpen" :ticket="ticket" />
|
||||
<ImageModal
|
||||
v-model="isImageModalOpen"
|
||||
:image-url="selectedImageUrl"
|
||||
:file-name="selectedImageName"
|
||||
/>
|
||||
<div
|
||||
v-if="!files || files.length === 0"
|
||||
class="flex h-10 items-center p-4 text-muted-foreground"
|
||||
@@ -99,7 +145,11 @@ watch(
|
||||
:title="file.name"
|
||||
class="flex w-full items-center border-t py-1 pl-2 pr-1 first:rounded-t first:border-none last:rounded-b"
|
||||
>
|
||||
<div class="flex flex-1 items-center overflow-hidden pr-2">
|
||||
<div
|
||||
class="flex flex-1 items-center overflow-hidden pr-2"
|
||||
:class="isImage(file.name) ? 'cursor-pointer' : ''"
|
||||
@click="openImageModal(file)"
|
||||
>
|
||||
{{ file.name }}
|
||||
|
||||
<div class="ml-1 flex-1 text-nowrap text-sm text-muted-foreground">
|
||||
|
||||
@@ -86,7 +86,7 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
|
||||
<form @submit="onSubmit" @change="change">
|
||||
<FormField name="name" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel for="name" class="text-right"> Name</FormLabel>
|
||||
<FormLabel for="name" class="text-left"> Name</FormLabel>
|
||||
<Input id="name" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -94,7 +94,7 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
|
||||
|
||||
<FormField name="url" v-slot="{ componentField }" class="mt-2">
|
||||
<FormItem>
|
||||
<FormLabel for="url" class="text-right"> URL</FormLabel>
|
||||
<FormLabel for="url" class="text-left"> URL</FormLabel>
|
||||
<Input id="url" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -138,7 +138,7 @@ const onSubmit = handleSubmit((values) => {
|
||||
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
|
||||
<FormField name="singular" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="singular" class="text-right">Singular</FormLabel>
|
||||
<FormLabel for="singular" class="text-left">Singular</FormLabel>
|
||||
<Input id="singular" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -146,7 +146,7 @@ const onSubmit = handleSubmit((values) => {
|
||||
|
||||
<FormField name="plural" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="plural" class="text-right">Plural</FormLabel>
|
||||
<FormLabel for="plural" class="text-left">Plural</FormLabel>
|
||||
<Input id="plural" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -6,7 +6,7 @@ export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
export const SIDEBAR_WIDTH = '16rem'
|
||||
export const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||
export const SIDEBAR_WIDTH_ICON = '3rem'
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = '`'
|
||||
|
||||
export const [useSidebar, provideSidebarContext] = createContext<{
|
||||
state: ComputedRef<'expanded' | 'collapsed'>
|
||||
|
||||
@@ -153,7 +153,7 @@ const onSubmit = handleSubmit((values) => emit('submit', values))
|
||||
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
|
||||
<FormField name="name" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="name" class="text-right">Name</FormLabel>
|
||||
<FormLabel for="name" class="text-left">Name</FormLabel>
|
||||
<Input id="name" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -161,7 +161,7 @@ const onSubmit = handleSubmit((values) => emit('submit', values))
|
||||
|
||||
<FormField name="username" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="username" class="text-right">Username</FormLabel>
|
||||
<FormLabel for="username" class="text-left">Username</FormLabel>
|
||||
<Input id="username" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -169,7 +169,7 @@ const onSubmit = handleSubmit((values) => emit('submit', values))
|
||||
|
||||
<FormField name="email" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="email" class="text-right">Email</FormLabel>
|
||||
<FormLabel for="email" class="text-left">Email</FormLabel>
|
||||
<Input id="email" type="email" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -111,7 +111,7 @@ const onSubmit = handleSubmit((values) => emit('submit', values))
|
||||
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
|
||||
<FormField name="password" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="password" class="text-right">Password</FormLabel>
|
||||
<FormLabel for="password" class="text-left">Password</FormLabel>
|
||||
<Input id="password" type="password" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -119,7 +119,7 @@ const onSubmit = handleSubmit((values) => emit('submit', values))
|
||||
|
||||
<FormField name="passwordConfirm" v-slot="{ componentField }" validate-on-input>
|
||||
<FormItem class="w-full">
|
||||
<FormLabel for="passwordConfirm" class="text-right">Confirm Password</FormLabel>
|
||||
<FormLabel for="passwordConfirm" class="text-left">Confirm Password</FormLabel>
|
||||
<Input id="passwordConfirm" type="password" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -82,7 +82,7 @@ watch(
|
||||
<form @submit="onSubmit" @change="change">
|
||||
<FormField name="user" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel for="user" class="text-right"> User</FormLabel>
|
||||
<FormLabel for="user" class="text-left"> User</FormLabel>
|
||||
<Select id="user" v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a user" />
|
||||
|
||||
@@ -58,14 +58,6 @@ const count = (id: string) => {
|
||||
Open Catalyst Handbook
|
||||
<ExternalLink class="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="/_/"
|
||||
target="_blank"
|
||||
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
|
||||
>
|
||||
Open Admin Interface
|
||||
<ExternalLink class="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -118,14 +110,6 @@ const count = (id: string) => {
|
||||
Open Catalyst Handbook
|
||||
<ExternalLink class="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="/_/"
|
||||
target="_blank"
|
||||
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
|
||||
>
|
||||
Open Admin Interface
|
||||
<ExternalLink class="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
||||
@@ -86,7 +86,7 @@ watch(
|
||||
<Input
|
||||
v-model="mail"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
placeholder="Email"
|
||||
class="w-full"
|
||||
@keydown.enter="login"
|
||||
/>
|
||||
|
||||
@@ -19,9 +19,9 @@ export const test = baseTest.extend({
|
||||
export const login = async (page, admin: boolean = true) => {
|
||||
await page.goto('login')
|
||||
if (admin) {
|
||||
await page.getByPlaceholder('Username').fill('admin@catalyst-soar.com')
|
||||
await page.getByPlaceholder('Email').fill('admin@catalyst-soar.com')
|
||||
} else {
|
||||
await page.getByPlaceholder('Username').fill('user@catalyst-soar.com')
|
||||
await page.getByPlaceholder('Email').fill('user@catalyst-soar.com')
|
||||
}
|
||||
await page.getByPlaceholder('Password').fill('1234567890')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
|
||||