Compare commits

..

12 Commits

Author SHA1 Message Date
Jonas Plum
fedda9daaf chore: add more screenshots (#1155) 2025-09-21 14:50:28 +02:00
Jonas Plum
4d844c567c fix: multiple minor fixes (#1154) 2025-09-21 12:08:28 +00:00
Jonas Plum
9da90e7cc8 refactor: add root store (#1153) 2025-09-21 09:47:29 +00:00
Jonas Plum
d9f759c879 fix: ui basePath (#1152) 2025-09-21 09:02:00 +00:00
dependabot[bot]
1e3f2f24dc build(deps): bump github.com/go-chi/chi/v5 from 5.2.1 to 5.2.2 (#1149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 07:02:08 +02:00
Jonas Plum
3cb097126c fix: rename binary (#1150) 2025-09-04 04:52:55 +00:00
Jonas Plum
df96362c3c fix: release docker ids (#1148) 2025-09-02 23:58:54 +00:00
Jonas Plum
377d2dad5f fix: cross-compile (#1147) 2025-09-02 23:27:23 +00:00
Jonas Plum
87fc0e6567 fix: working directory (#1146) 2025-09-02 22:02:52 +00:00
Jonas Plum
06fdae4ab9 fix: adapt goreleaser for cross compilation (#1145) 2025-09-02 21:43:33 +00:00
Jonas Plum
27129f24d5 fix: recreate http flag (#1144) 2025-09-02 20:54:47 +00:00
Jonas Plum
de105f19c1 fix: release CI (#1143) 2025-09-02 20:23:30 +00:00
33 changed files with 873 additions and 52 deletions

View File

@@ -11,7 +11,7 @@ jobs:
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 generate-go - run: make install-golangci-lint generate-go
- run: git diff --exit-code - run: git diff --exit-code
@@ -30,7 +30,7 @@ jobs:
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: make install-golangci-lint fmt-go
- run: git diff --exit-code - run: git diff --exit-code
@@ -48,7 +48,7 @@ jobs:
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 lint-go - run: make install-golangci-lint lint-go
lint-ui: lint-ui:
@@ -73,7 +73,7 @@ jobs:
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 test-coverage - run: make test-coverage
- uses: codecov/codecov-action@v4 - uses: codecov/codecov-action@v4
with: with:
@@ -86,7 +86,7 @@ jobs:
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 - uses: oven-sh/setup-bun@v1
- run: make install-ui test-ui - run: make install-ui test-ui
@@ -96,7 +96,7 @@ jobs:
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 - uses: oven-sh/setup-bun@v1
- run: make install-ui build-ui install-playwright test-playwright - run: make install-ui build-ui install-playwright test-playwright
@@ -122,7 +122,7 @@ jobs:
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 - uses: oven-sh/setup-bun@v1
- run: mkdir -p catalyst_data - run: mkdir -p catalyst_data
- run: cp testing/data/${{ matrix.folder }}/data.db catalyst_data/data.db - run: cp testing/data/${{ matrix.folder }}/data.db catalyst_data/data.db

View File

@@ -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:latest \
release --clean

View File

@@ -8,6 +8,7 @@ linters:
- dupl - dupl
- err113 - err113
- exhaustruct - exhaustruct
- funcorder
- funlen - funlen
- gochecknoglobals - gochecknoglobals
- godox - godox
@@ -16,6 +17,7 @@ linters:
- lll - lll
- maintidx - maintidx
- mnd - mnd
- noinlineerr
- nonamedreturns - nonamedreturns
- perfsprint - perfsprint
- prealloc - prealloc
@@ -25,6 +27,7 @@ linters:
- unparam - unparam
- varnamelen - varnamelen
- wrapcheck - wrapcheck
- wsl
exclusions: exclusions:
generated: lax generated: lax
presets: presets:

View File

@@ -5,14 +5,84 @@ 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"
@@ -22,7 +92,7 @@ dockers:
- docker/entrypoint.sh - 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 }}_
@@ -34,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

View File

@@ -4,7 +4,7 @@
.PHONY: install-golangci-lint .PHONY: install-golangci-lint
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 .PHONY: install-ui
install-ui: install-ui:

View File

@@ -432,6 +432,7 @@ func weeksAgo(c int) time.Time {
func dates(ticketCount int) (time.Time, time.Time) { func dates(ticketCount int) (time.Time, time.Time) {
const ticketsPerWeek = 10 const ticketsPerWeek = 10
weeks := ticketCount / ticketsPerWeek weeks := ticketCount / ticketsPerWeek
created := gofakeit.DateRange(weeksAgo(1), weeksAgo(weeks+1)).UTC() created := gofakeit.DateRange(weeksAgo(1), weeksAgo(weeks+1)).UTC()

View File

@@ -15,6 +15,7 @@ type DBTX interface {
type Queries struct { type Queries struct {
*ReadQueries *ReadQueries
*WriteQueries *WriteQueries
ReadDB *sql.DB ReadDB *sql.DB
WriteDB *sql.DB WriteDB *sql.DB
} }

View File

@@ -15,6 +15,7 @@ type DBTX interface {
type Queries struct { type Queries struct {
*ReadQueries *ReadQueries
*WriteQueries *WriteQueries
ReadDB *sql.DB ReadDB *sql.DB
WriteDB *sql.DB WriteDB *sql.DB
} }

View File

@@ -27,7 +27,7 @@ func TestSQLMigration_UpAndDown(t *testing.T) {
require.NoError(t, m.up(t.Context(), queries, dir, uploader)) require.NoError(t, m.up(t.Context(), queries, dir, uploader))
// Table should exist // 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) require.NoError(t, err)
} }

View File

@@ -13,6 +13,7 @@ func TestVersionAndSetVersion(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:") db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err, "failed to open in-memory db") require.NoError(t, err, "failed to open in-memory db")
defer db.Close() defer db.Close()
ver, err := version(t.Context(), db) ver, err := version(t.Context(), db)

290
app/rootstore/rootstore.go Normal file
View 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
}

View 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)
}

View File

@@ -68,7 +68,7 @@ func isDemoMode(ctx context.Context, queries *sqlc.Queries) bool {
} }
} }
return true, nil return len(features) > 0, nil
}); err != nil { }); err != nil {
slog.ErrorContext(ctx, "Failed to check demo mode", "error", err) slog.ErrorContext(ctx, "Failed to check demo mode", "error", err)

View File

@@ -11,11 +11,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/tus/tusd/v2/pkg/filelocker" "github.com/tus/tusd/v2/pkg/filelocker"
tusd "github.com/tus/tusd/v2/pkg/handler" 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/auth"
"github.com/SecurityBrewery/catalyst/app/database" "github.com/SecurityBrewery/catalyst/app/database"
"github.com/SecurityBrewery/catalyst/app/database/sqlc" "github.com/SecurityBrewery/catalyst/app/database/sqlc"
"github.com/SecurityBrewery/catalyst/app/rootstore"
"github.com/SecurityBrewery/catalyst/app/upload" "github.com/SecurityBrewery/catalyst/app/upload"
) )

View File

@@ -16,6 +16,7 @@ func Test_marshal(t *testing.T) {
out := marshal(data) out := marshal(data)
var res map[string]any var res map[string]any
err := json.Unmarshal([]byte(out), &res) err := json.Unmarshal([]byte(out), &res)
require.NoError(t, err, "invalid json") require.NoError(t, err, "invalid json")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 167 KiB

BIN
docs/screenshots/groups.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 229 KiB

BIN
docs/screenshots/types.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
docs/screenshots/users.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

6
go.mod
View File

@@ -1,17 +1,15 @@
module github.com/SecurityBrewery/catalyst module github.com/SecurityBrewery/catalyst
go 1.24 go 1.25
tool ( tool (
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
github.com/sqlc-dev/sqlc/cmd/sqlc 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 ( require (
github.com/brianvoe/gofakeit/v7 v7.2.1 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/go-co-op/gocron/v2 v2.16.2
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/martian/v3 v3.3.3 github.com/google/martian/v3 v3.3.3

28
go.sum
View File

@@ -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/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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= 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 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 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= 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/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 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= 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.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
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/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 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI= 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= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
@@ -188,6 +186,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/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 h1:92dMoNyeb5zaNi8eQ79WLqt/npUWUFkaM5ZM9kOMIDM=
github.com/tus/lockfile v1.2.0/go.mod h1:JyfWCHNyfd7eGxudGohrkt38kuKRki6L0JH82p2e+mc= 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 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E= github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
@@ -203,16 +203,16 @@ github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy5
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 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/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 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.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=

11
main.go
View File

@@ -40,8 +40,11 @@ func main() {
}, },
}, },
{ {
Name: "serve", Name: "serve",
Usage: "Start the Catalyst server", Usage: "Start the Catalyst server",
Flags: []cli.Flag{
&cli.StringFlag{Name: "http", Usage: "HTTP listen address", Value: ":8090"},
},
Action: serve, Action: serve,
}, },
{ {
@@ -108,8 +111,10 @@ func serve(ctx context.Context, command *cli.Command) error {
defer cleanup() defer cleanup()
addr := command.String("http")
server := &http.Server{ server := &http.Server{
Addr: ":8090", Addr: addr,
Handler: catalyst, Handler: catalyst,
ReadTimeout: 10 * time.Minute, ReadTimeout: 10 * time.Minute,
} }

View File

@@ -9,6 +9,10 @@ files=(
"ticket.png" "ticket.png"
"tasks.png" "tasks.png"
"reactions.png" "reactions.png"
"settings.png"
"users.png"
"groups.png"
"types.png"
) )
for file in "${files[@]}"; do for file in "${files[@]}"; do

View File

@@ -49,3 +49,42 @@ test('reactions screenshot', async ({ page }) => {
await page.waitForTimeout(7000) await page.waitForTimeout(7000)
await page.screenshot({ path: screenshot('reactions') }) 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') })
})

View File

@@ -5,7 +5,7 @@ export function useAPI() {
const authStore = useAuthStore() const authStore = useAuthStore()
return new DefaultApi( return new DefaultApi(
new Configuration({ new Configuration({
basePath: 'http://localhost:8090/api', basePath: '/api',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',

View File

@@ -109,6 +109,19 @@ const initials = (user: { name?: string } | undefined) => {
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <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> <SidebarGroup>
<SidebarGroupLabel>Tickets</SidebarGroupLabel> <SidebarGroupLabel>Tickets</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>

View File

@@ -6,7 +6,7 @@ export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
export const SIDEBAR_WIDTH = '16rem' export const SIDEBAR_WIDTH = '16rem'
export const SIDEBAR_WIDTH_MOBILE = '18rem' export const SIDEBAR_WIDTH_MOBILE = '18rem'
export const SIDEBAR_WIDTH_ICON = '3rem' export const SIDEBAR_WIDTH_ICON = '3rem'
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b' export const SIDEBAR_KEYBOARD_SHORTCUT = '`'
export const [useSidebar, provideSidebarContext] = createContext<{ export const [useSidebar, provideSidebarContext] = createContext<{
state: ComputedRef<'expanded' | 'collapsed'> state: ComputedRef<'expanded' | 'collapsed'>

View File

@@ -86,7 +86,7 @@ watch(
<Input <Input
v-model="mail" v-model="mail"
type="text" type="text"
placeholder="Username" placeholder="Email"
class="w-full" class="w-full"
@keydown.enter="login" @keydown.enter="login"
/> />

View File

@@ -19,9 +19,9 @@ export const test = baseTest.extend({
export const login = async (page, admin: boolean = true) => { export const login = async (page, admin: boolean = true) => {
await page.goto('login') await page.goto('login')
if (admin) { if (admin) {
await page.getByPlaceholder('Username').fill('admin@catalyst-soar.com') await page.getByPlaceholder('Email').fill('admin@catalyst-soar.com')
} else { } 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.getByPlaceholder('Password').fill('1234567890')
await page.getByRole('button', { name: 'Login' }).click() await page.getByRole('button', { name: 'Login' }).click()