diff --git a/docs/docsrc/examples/examples_presets/detailing.go b/docs/docsrc/examples/examples_presets/detailing.go index be4ea351d..d7dfbdb46 100644 --- a/docs/docsrc/examples/examples_presets/detailing.go +++ b/docs/docsrc/examples/examples_presets/detailing.go @@ -436,3 +436,28 @@ func PresetsDetailDisableSave(b *presets.Builder, db *gorm.DB) ( dpc.Section(sec) return } + +func PresetsDetailSaverValidation(b *presets.Builder, db *gorm.DB) ( + cust *presets.ModelBuilder, + cl *presets.ListingBuilder, + ce *presets.EditingBuilder, + dp *presets.DetailingBuilder, +) { + cust, cl, ce, dp = PresetsHelloWorld(b, db) + dp = cust.Detailing("Customer") + section := presets.NewSectionBuilder(cust, "Customer").Editing("Name", "Email") + section.WrapSaveFunc(func(in presets.SaveFunc) presets.SaveFunc { + return func(obj interface{}, id string, ctx *web.EventContext) (err error) { + ve := web.ValidationErrors{} + if obj.(*Customer).Name == "system" { + ve.GlobalError("You can not use system as name") + } + if ve.HaveErrors() { + return &ve + } + return in(obj, id, ctx) + } + }) + dp.Section(section) + return +} diff --git a/docs/docsrc/examples/examples_presets/detailing_test.go b/docs/docsrc/examples/examples_presets/detailing_test.go index 62b5ecf64..fc2c4b0cd 100644 --- a/docs/docsrc/examples/examples_presets/detailing_test.go +++ b/docs/docsrc/examples/examples_presets/detailing_test.go @@ -908,3 +908,28 @@ func TestPresetsDetailDisableSave(t *testing.T) { }) } } + +func TestPresetsDetailSaverValidation(t *testing.T) { + pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB)) + PresetsDetailSaverValidation(pb, TestDB) + + cases := []multipartestutils.TestCase{ + { + Name: "detail saver validation", + ReqFunc: func() *http.Request { + customPageData.TruncatePut(SqlDB) + return multipartestutils.NewMultipartBuilder(). + PageURL("/customers"). + EventFunc("section_save_Customer"). + AddField("Name", "system"). + BuildEventFuncRequest() + }, + ExpectRunScriptContainsInOrder: []string{"You can not use system as name"}, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + multipartestutils.RunCase(t, c, pb) + }) + } +} diff --git a/docs/docsrc/examples/examples_presets/editing.go b/docs/docsrc/examples/examples_presets/editing.go index 078b22642..f66ec17e1 100644 --- a/docs/docsrc/examples/examples_presets/editing.go +++ b/docs/docsrc/examples/examples_presets/editing.go @@ -376,3 +376,24 @@ func PresetsEditingSection(b *presets.Builder, db *gorm.DB) ( edit.Section(section.Clone()) return } + +func PresetsEditingSaverValidation(b *presets.Builder, db *gorm.DB) (mb *presets.ModelBuilder, + cl *presets.ListingBuilder, + ce *presets.EditingBuilder, + dp *presets.DetailingBuilder, +) { + b.DataOperator(gorm2op.DataOperator(db)) + db.AutoMigrate(&Company{}) + mb = b.Model(&Company{}) + mb.Editing().SaveFunc(func(obj interface{}, id string, ctx *web.EventContext) (err error) { + ve := web.ValidationErrors{} + if obj.(*Company).Name == "system" { + ve.FieldError("Name", "You can not use system as name") + } + if ve.HaveErrors() { + return &ve + } + return nil + }) + return +} diff --git a/docs/docsrc/examples/examples_presets/editing_test.go b/docs/docsrc/examples/examples_presets/editing_test.go index a321341ae..bd5d199ce 100644 --- a/docs/docsrc/examples/examples_presets/editing_test.go +++ b/docs/docsrc/examples/examples_presets/editing_test.go @@ -522,3 +522,27 @@ func TestCreateHTMLSanitizerSetterFunc(t *testing.T) { t.Errorf("Expected %q, got %q", expected, customer.HTMLSanitizerPolicyTiptapInput) } } + +func TestPresetsEditingSaver(t *testing.T) { + pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB)) + PresetsEditingSaverValidation(pb, TestDB) + + cases := []multipartestutils.TestCase{ + { + Name: "saver return error", + ReqFunc: func() *http.Request { + return multipartestutils.NewMultipartBuilder(). + PageURL("/companies?__execute_event__=presets_Update"). + AddField("Name", "system"). + BuildEventFuncRequest() + }, + ExpectPortalUpdate0ContainsInOrder: []string{`You can not use system as name`}, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + multipartestutils.RunCase(t, c, pb) + }) + } +} diff --git a/docs/docsrc/examples/examples_presets/listing.go b/docs/docsrc/examples/examples_presets/listing.go index 9907007d6..f9f653cb1 100644 --- a/docs/docsrc/examples/examples_presets/listing.go +++ b/docs/docsrc/examples/examples_presets/listing.go @@ -14,6 +14,7 @@ import ( "gorm.io/gorm" "github.com/qor5/x/v3/i18n" + "github.com/qor5/x/v3/statusx" v "github.com/qor5/x/v3/ui/vuetify" "github.com/qor5/x/v3/ui/vuetifyx" @@ -480,3 +481,71 @@ func PresetsListingDatatableFunc(b *presets.Builder, db *gorm.DB) ( } // @snippet_end + +// @snippet_begin(PresetsListingFilterNotificationFuncSample) + +func PresetsListingFilterNotificationFunc(b *presets.Builder, db *gorm.DB) ( + mb *presets.ModelBuilder, + cl *presets.ListingBuilder, + ce *presets.EditingBuilder, + dp *presets.DetailingBuilder, +) { + b.DataOperator(gorm2op.DataOperator(db)) + err := db.AutoMigrate(&Customer{}) + if err != nil { + panic(err) + } + mb = b.Model(&Customer{}) + mb.Listing().FilterNotificationFunc(func(_ *web.EventContext) h.HTMLComponent { + return h.Div().Text("Filter Notification").Class(fmt.Sprintf("text-%s", v.ColorWarning)) + }) + return +} + +// @snippet_end +type mockGRPCDataOperatorWrapper struct { + next presets.DataOperator +} + +func mockGRPCDataOperator(next presets.DataOperator) presets.DataOperator { + return &mockGRPCDataOperatorWrapper{next: next} +} + +func (w *mockGRPCDataOperatorWrapper) Search(eventCtx *web.EventContext, params *presets.SearchParams) (*presets.SearchResult, error) { + return w.next.Search(eventCtx, params) +} + +func (w *mockGRPCDataOperatorWrapper) Fetch(obj any, id string, eventCtx *web.EventContext) (any, error) { + return w.next.Fetch(obj, id, eventCtx) +} + +func (w *mockGRPCDataOperatorWrapper) Save(obj any, id string, eventCtx *web.EventContext) error { + var fvs statusx.FieldViolations + p := obj.(*Customer) + if p.Name == "system" { + fvs = append(fvs, statusx.NewFieldViolation("Name", "name can`t set system", "name can`t set system")) + return statusx.BadRequest(fvs).Err() + } + return nil +} + +func (w *mockGRPCDataOperatorWrapper) Delete(obj any, id string, eventCtx *web.EventContext) error { + return w.next.Delete(obj, id, eventCtx) +} + +func PresetsDataOperatorWithGRPC(b *presets.Builder, db *gorm.DB) ( + mb *presets.ModelBuilder, + cl *presets.ListingBuilder, + ce *presets.EditingBuilder, + dp *presets.DetailingBuilder, +) { + b.DataOperator(presets.DataOperatorWithGRPC(mockGRPCDataOperator(gorm2op.DataOperator(db)))) + err := db.AutoMigrate(&Customer{}) + if err != nil { + panic(err) + } + mb = b.Model(&Customer{}) + cl = mb.Listing() + ce = mb.Editing() + return +} diff --git a/docs/docsrc/examples/examples_presets/listing_test.go b/docs/docsrc/examples/examples_presets/listing_test.go index fbe897ea0..dbf76841f 100644 --- a/docs/docsrc/examples/examples_presets/listing_test.go +++ b/docs/docsrc/examples/examples_presets/listing_test.go @@ -9,6 +9,7 @@ import ( "github.com/theplant/gofixtures" "github.com/qor5/admin/v3/presets" + "github.com/qor5/admin/v3/presets/actions" "github.com/qor5/admin/v3/presets/gorm2op" ) @@ -171,3 +172,78 @@ func TestPresetsListingDatatableFunc(t *testing.T) { }) } } + +func TestPresetsListingFilterNotificationFunc(t *testing.T) { + pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB)) + PresetsListingFilterNotificationFunc(pb, TestDB) + cases := []multipartestutils.TestCase{ + { + Name: "Filter Notification", + Debug: true, + ReqFunc: func() *http.Request { + listingDatatableData.TruncatePut(SqlDB) + return multipartestutils.NewMultipartBuilder(). + PageURL("/customers"). + BuildEventFuncRequest() + }, + ExpectPageBodyContainsInOrder: []string{`Filter Notification`}, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + multipartestutils.RunCase(t, c, pb) + }) + } +} + +func TestPresetsDataOperatorWithGRPC(t *testing.T) { + pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB)) + PresetsDataOperatorWithGRPC(pb, TestDB) + cases := []multipartestutils.TestCase{ + { + Name: "Index Customer", + Debug: true, + ReqFunc: func() *http.Request { + listingDatatableData.TruncatePut(SqlDB) + return multipartestutils.NewMultipartBuilder(). + PageURL("/customers"). + BuildEventFuncRequest() + }, + ExpectPageBodyContainsInOrder: []string{`v-card`, `Felix 1`, `abc@example.com`}, + }, + { + Name: "Update Customer", + Debug: true, + ReqFunc: func() *http.Request { + listingDatatableData.TruncatePut(SqlDB) + return multipartestutils.NewMultipartBuilder(). + PageURL("/customers"). + Query(presets.ParamID, "12"). + EventFunc(actions.Update). + AddField("Name", "system"). + BuildEventFuncRequest() + }, + ExpectPortalUpdate0ContainsInOrder: []string{"invalid argument"}, + }, + { + Name: "Delete Customer", + Debug: true, + ReqFunc: func() *http.Request { + listingDatatableData.TruncatePut(SqlDB) + return multipartestutils.NewMultipartBuilder(). + PageURL("/customers"). + Query(presets.ParamID, "12"). + EventFunc(actions.DoDelete). + BuildEventFuncRequest() + }, + ExpectRunScriptContainsInOrder: []string{"PresetsNotifModelsDeletedexamplesPresetsCustomer"}, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + multipartestutils.RunCase(t, c, pb) + }) + } +} diff --git a/docs/docsrc/examples/examples_presets/menu_test.go b/docs/docsrc/examples/examples_presets/menu_test.go index a40581b34..c78c9f115 100644 --- a/docs/docsrc/examples/examples_presets/menu_test.go +++ b/docs/docsrc/examples/examples_presets/menu_test.go @@ -67,7 +67,7 @@ func TestPresetsPresetsMenuComponent(t *testing.T) { cases := []multipartestutils.TestCase{ { Name: "multiple openStrategy", - Debug: false, + Debug: true, ReqFunc: func() *http.Request { return multipartestutils.NewMultipartBuilder(). PageURL("/presets-menu-component/books"). diff --git a/docs/docsrc/examples/examples_presets/mux.go b/docs/docsrc/examples/examples_presets/mux.go index b3b49d70b..e74f26927 100644 --- a/docs/docsrc/examples/examples_presets/mux.go +++ b/docs/docsrc/examples/examples_presets/mux.go @@ -47,8 +47,10 @@ func SamplesHandler(mux examples.Muxer) { addExample(mux, db, PresetsEditingValidate) addExample(mux, db, PresetsEditingSetter) addExample(mux, db, PresetsEditingSection) + addExample(mux, db, PresetsEditingSaverValidation) addExample(mux, db, PresetsListingCustomizationSearcher) addExample(mux, db, PresetsListingDatatableFunc) + addExample(mux, db, PresetsListingFilterNotificationFunc) addExample(mux, db, PresetsDetailInlineEditDetails) addExample(mux, db, PresetsDetailSectionView) addExample(mux, db, PresetsDetailTabsSection) @@ -65,6 +67,8 @@ func SamplesHandler(mux examples.Muxer) { addExample(mux, db, PresetsCustomPage) addExample(mux, db, PresetsPlainNestedField) addExample(mux, db, PresetsDetailDisableSave) + addExample(mux, db, PresetsDetailSaverValidation) + addExample(mux, db, PresetsDataOperatorWithGRPC) return } diff --git a/example/integration/login_test.go b/example/integration/login_test.go index 820ba3901..3e8df7c78 100644 --- a/example/integration/login_test.go +++ b/example/integration/login_test.go @@ -24,7 +24,7 @@ func TestLogin(t *testing.T) { cases := []multipartestutils.TestCase{ { Name: "view by en", - Debug: false, + Debug: true, ReqFunc: func() *http.Request { req := multipartestutils.NewMultipartBuilder(). PageURL("/auth/login"). @@ -36,7 +36,7 @@ func TestLogin(t *testing.T) { }, { Name: "view by zh", - Debug: false, + Debug: true, ReqFunc: func() *http.Request { req := multipartestutils.NewMultipartBuilder(). PageURL("/auth/login"). @@ -48,7 +48,7 @@ func TestLogin(t *testing.T) { }, { Name: "view by ja", - Debug: false, + Debug: true, ReqFunc: func() *http.Request { req := multipartestutils.NewMultipartBuilder(). PageURL("/auth/login"). @@ -60,7 +60,7 @@ func TestLogin(t *testing.T) { }, { Name: "view by en (customized)", - Debug: false, + Debug: true, HandlerMaker: func() http.Handler { mux := http.NewServeMux() c := admin.NewConfig(TestDB, false) diff --git a/go.mod b/go.mod index 828f6b2d4..ae853936d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/fatih/color v1.17.0 github.com/go-chi/chi/v5 v5.2.2 github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 + github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/gosimple/slug v1.14.0 @@ -21,17 +22,18 @@ require ( github.com/lib/pq v1.10.9 github.com/manifoldco/promptui v0.9.0 github.com/markbates/goth v1.80.0 - github.com/mholt/archiver/v4 v4.0.0-alpha.8 + github.com/mholt/archiver/v4 v4.0.0-alpha.9 github.com/microcosm-cc/bluemonday v1.0.27 github.com/orcaman/concurrent-map/v2 v2.0.1 github.com/ory/ladon v1.3.0 github.com/oschwald/geoip2-golang v1.11.0 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.4.0 + github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 github.com/qor5/imaging v1.6.4 github.com/qor5/web v1.3.2 github.com/qor5/web/v3 v3.0.12-0.20250618085230-3764d0e521a8 - github.com/qor5/x/v3 v3.1.3-0.20251013064705-2b80bfbbe517 + github.com/qor5/x/v3 v3.1.3-0.20251021020244-c33f2868c60b github.com/samber/lo v1.50.0 github.com/shurcooL/sanitized_anchor_name v1.0.0 github.com/spf13/cast v1.7.1 @@ -42,6 +44,7 @@ require ( github.com/theplant/docgo v0.0.16 github.com/theplant/gofixtures v1.1.3 github.com/theplant/htmlgo v1.0.3 + github.com/theplant/inject v1.1.0 github.com/theplant/osenv v0.0.2 github.com/theplant/relay v0.4.3-0.20250424075128-61850ded6ace github.com/theplant/sliceutils v0.0.0-20200406042209-89153d988eb1 @@ -55,22 +58,28 @@ require ( go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/text v0.28.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c + google.golang.org/grpc v1.75.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.5.6 gorm.io/gorm v1.30.1 ) require ( - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.8.0 // indirect + connectrpc.com/connect v1.18.1 // indirect + connectrpc.com/cors v0.1.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/STARRY-S/zip v0.1.0 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect github.com/aws/aws-sdk-go-v2/config v1.29.6 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.59 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect @@ -85,7 +94,7 @@ require ( github.com/aws/smithy-go v1.22.5 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect - github.com/bodgit/sevenzip v1.5.1 // indirect + github.com/bodgit/sevenzip v1.5.2 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/boombuler/barcode v1.0.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -103,34 +112,49 @@ require ( github.com/docker/docker v28.3.3+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect + github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/ebitengine/purego v0.8.4 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289 // indirect + github.com/go-kit/log v0.2.0 // indirect + github.com/go-logfmt/logfmt v0.5.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/form v3.1.4+incompatible // indirect github.com/go-playground/form/v4 v4.2.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.25.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.3.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/jjeffery/errors v1.0.3 // indirect + github.com/jjeffery/kv v0.8.1 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/markbates/going v1.0.3 // indirect @@ -138,6 +162,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect @@ -153,11 +178,15 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/ory/pagination v0.0.1 // indirect github.com/oschwald/maxminddb-golang v1.13.0 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/redis/go-redis/v9 v9.11.0 // indirect + github.com/rs/cors v1.11.1 // indirect github.com/russross/blackfriday v1.6.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect @@ -165,12 +194,19 @@ require ( github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a // indirect github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sorairolake/lzip-go v0.3.5 // indirect github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect + github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/testcontainers/testcontainers-go v0.38.0 // indirect github.com/testcontainers/testcontainers-go/modules/redis v0.38.0 // indirect - github.com/theplant/inject v1.0.2 // indirect + github.com/theplant/appkit v0.0.0-20250528023215-3d0d299dc4c6 // indirect + github.com/theplant/validator v0.0.0-20210202101755-357a9daa8f5f // indirect github.com/therootcompany/xz v1.0.1 // indirect github.com/tidwall/gjson v1.17.3 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -194,8 +230,9 @@ require ( golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2d01e5dff..a0e504db3 100644 --- a/go.sum +++ b/go.sum @@ -9,13 +9,17 @@ cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6T cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ= +connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -31,10 +35,12 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/STARRY-S/zip v0.1.0 h1:eUER3jKmHKXjv+iy3BekLa+QnNSo1Lqz4eTzYBcGDqo= +github.com/STARRY-S/zip v0.1.0/go.mod h1:qj/mTZkvb3AvfGQ2e775/3AODRvB4peSw8KNMvrM8/I= github.com/ahmetb/go-linq/v3 v3.2.0 h1:BEuMfp+b59io8g5wYzNoFe9pWPalRklhlhbiU3hYZDE= github.com/ahmetb/go-linq/v3 v3.2.0/go.mod h1:haQ3JfOeWK8HpVxMtHHEMPVgBKiYyQ+f1/kLZh/cj9U= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8= @@ -47,6 +53,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.59 h1:9btwmrt//Q6JcSdgJOLI98sdr5p github.com/aws/aws-sdk-go-v2/credentials v1.17.59/go.mod h1:NM8fM6ovI3zak23UISdWidyZuI1ghNe2xjzUZAyT+08= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.4 h1:2RIi889b7VHUULrQXbB5RcNvN9JZ1VJZPAOG2FJJ6YU= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.4/go.mod h1:2oLW5huI9B5XV6ycns7nRLeRJtue48ZB5kZ5ZRL1HSU= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= @@ -75,10 +83,11 @@ github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -github.com/bodgit/sevenzip v1.5.1 h1:rVj0baZsooZFy64DJN0zQogPzhPrT8BQ8TTRd1H4WHw= -github.com/bodgit/sevenzip v1.5.1/go.mod h1:Q3YMySuVWq6pyGEolyIE98828lOfEoeWg5zeH6x22rc= +github.com/bodgit/sevenzip v1.5.2 h1:acMIYRaqoHAdeu9LhEGGjL9UzBD4RNf9z7+kWDNignI= +github.com/bodgit/sevenzip v1.5.2/go.mod h1:gTGzXA67Yko6/HLSD0iK4kWaWzPlPmLfDO73jTjSRqc= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -103,6 +112,7 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -131,15 +141,21 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= -github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -147,10 +163,22 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 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-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289 h1:468Nv6YtYO38Z+pFL6fRSVILGfdTFPei9ksVneiUHUc= +github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-kit/log v0.2.0 h1:7i2K3eKTos3Vc0enKCfnVcgHh2olr/MyfboYq7cAcFw= +github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -159,12 +187,20 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/form v3.1.4+incompatible h1:lvKiHVxE2WvzDIoyMnWcjyiBxKt2+uFJyZcPYWsLnjI= github.com/go-playground/form v3.1.4+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg= github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw= github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= +github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -188,6 +224,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -224,8 +262,12 @@ github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es= github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 h1:CWyXh/jylQWp2dtiV33mY4iSSp6yf4lmn+c7/tN+ObI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0/go.mod h1:nCLIt0w3Ept2NwF8ThLmrppXsfT07oC8k0XNDxd8sVU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -238,6 +280,8 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= @@ -260,6 +304,10 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jjeffery/errors v1.0.3 h1:oM8cLCCNP8ccKKpn7tbii4AW5hqZsr2pvEXxvwVoBUs= +github.com/jjeffery/errors v1.0.3/go.mod h1:K2Ea6XpV2ki89b7/nPp5DVHqMuTvcbv6q95/XkzvXx8= +github.com/jjeffery/kv v0.8.1 h1:S4/KbfPVNTGM/YCt5F+Za93o09119VNtOT+rLL4Gls0= +github.com/jjeffery/kv v0.8.1/go.mod h1:iHA3uy+umBqxcJFr+e+gaGAv1OcyHlU6rSo3TcR61yQ= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -272,6 +320,7 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -279,6 +328,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= @@ -301,10 +352,12 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= -github.com/mholt/archiver/v4 v4.0.0-alpha.8 h1:tRGQuDVPh66WCOelqe6LIGh0gwmfwxUrSSDunscGsRM= -github.com/mholt/archiver/v4 v4.0.0-alpha.8/go.mod h1:5f7FUYGXdJWUjESffJaYR4R60VhnHxb2X3T1teMyv5A= +github.com/mholt/archiver/v4 v4.0.0-alpha.9 h1:EZgAsW6DsuawxDgTtIdjCUBa2TQ6AOe9pnCidofSRtE= +github.com/mholt/archiver/v4 v4.0.0-alpha.9/go.mod h1:5D3uct315OMkMRXKwEuMB+wQi/2m5NQngKDmApqwVlo= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= @@ -337,6 +390,7 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= github.com/ory/ladon v1.3.0 h1:35Rc3O8d+mhFWxzmKs6Qj/ETQEHGEI5BmWQf8wtqFHk= @@ -349,8 +403,11 @@ github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnY github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -361,24 +418,34 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:Om github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 h1:sO/saPkFgwfLaiCVTg+e9x6lAYBrqY91h1REu4NLMm0= +github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54/go.mod h1:03dPo1SHYn9sU57mH67Y1p9FIcglWaHr4i/xkeYmX4o= github.com/qor5/imaging v1.6.4 h1:6VNy1EnFKAns8e73uzsZa6UQDw0oekvUHui1oeydy78= github.com/qor5/imaging v1.6.4/go.mod h1:i1E3GRDbTFbXuzZtm1/8zWu+lDbDw6O1hCmjtyQFnR4= github.com/qor5/web v1.3.2 h1:zw796YJeDLe8vRwGR1cM+uS1ZuSkPutchBEXv2GgOhI= github.com/qor5/web v1.3.2/go.mod h1:LszskQJbFQDJwOeZC6j6afOiHxxyjrzz8B3zuBwfgKQ= github.com/qor5/web/v3 v3.0.12-0.20250618085230-3764d0e521a8 h1:s3jBS5bq6VX56GicRPCeXvb0TRLSz5w1xJHymPnhTuo= github.com/qor5/web/v3 v3.0.12-0.20250618085230-3764d0e521a8/go.mod h1:hrhZ4nc1U+AOBrGmnUoRUPpA9fymxlAbNfGvn9TJLns= -github.com/qor5/x/v3 v3.1.3-0.20251013064705-2b80bfbbe517 h1:YuZ/ttBTtGf6W4kDQ8fujWY+kCdAB8A5mShbBTWOQOg= -github.com/qor5/x/v3 v3.1.3-0.20251013064705-2b80bfbbe517/go.mod h1:UugTZhkbyUdlYaGAvSUsRXrCgt/j3qoTWxbJV11tK3E= +github.com/qor5/x/v3 v3.1.3-0.20251016020953-4d227d199456 h1:BTwsRW2eb/lHBC31xhzbC48hVTSVgUShLs3f1YtPiVQ= +github.com/qor5/x/v3 v3.1.3-0.20251016020953-4d227d199456/go.mod h1:ZtfomAwdcec3E0CNM7oSLNLJRRkTa2OsND7bmJl5Kzg= +github.com/qor5/x/v3 v3.1.3-0.20251021020244-c33f2868c60b h1:apfqd+AQQHNzUx34jZQ2zxhV05/z+q/ftqB1DivKok0= +github.com/qor5/x/v3 v3.1.3-0.20251021020244-c33f2868c60b/go.mod h1:63Xf2S/u3kCH/ByS8Q+XCJOAAv8CvFO2GtMOO7kHITk= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs= @@ -400,21 +467,34 @@ github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 h1:W5meM/5DP0Igf+ github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8/go.mod h1:hWBWTvIJ918VxbNOk2hxQg1/5j1M9yQI1Kp8d9qrOq8= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg= +github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -423,6 +503,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/sunfmin/reflectutils v1.0.6 h1:tX1ecTgYLsv2F8iBO2JL3BY2AdMtyFz/ir3ip+njFNs= github.com/sunfmin/reflectutils v1.0.6/go.mod h1:ao2bbF4RZrTe2PboJKdZoC3BA71gdU6rFkCuUjoeqMw= github.com/sunfmin/snippetgo v0.0.3 h1:pMCpFCyW2fYHhfLp4tb5ccvTCpIuSNFomtDCr9duUaE= @@ -431,6 +513,8 @@ github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxd github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= github.com/testcontainers/testcontainers-go/modules/redis v0.38.0 h1:289pn0BFmGqDrd6BrImZAprFef9aaPZacx07YOQaPV4= github.com/testcontainers/testcontainers-go/modules/redis v0.38.0/go.mod h1:EcKPWRzOglnQfYe+ekA8RPEIWSNJTGwaC5oE5bQV+D0= +github.com/theplant/appkit v0.0.0-20250528023215-3d0d299dc4c6 h1:LQEFJ4e9i7Yyv0Drk2/rZOakxzFWATFwpcjwW8E2wuU= +github.com/theplant/appkit v0.0.0-20250528023215-3d0d299dc4c6/go.mod h1:Eg9VHkTQzjBVksZIMq9SkmbOcrCiOm4objkq0LhH+Io= github.com/theplant/bimg v1.1.1 h1:97KW0oDbGt8d3K7vu2rgM88gT/+beWzTu0ZwtlqcxwE= github.com/theplant/bimg v1.1.1/go.mod h1:H0qlp9lKZoOO4akI0VxEtxO8lgLBLNuhF/+2U00LSPg= github.com/theplant/docgo v0.0.16 h1:6K6IHfeQ0sx9k7b1h9L6RV2pD0SiN8TAQm1RNLYezgU= @@ -441,8 +525,8 @@ github.com/theplant/htmlgo v1.0.3 h1:G7/YSf8OrOIRHVQ13avd78T/GV1kDl/jMwpQURrXB0o github.com/theplant/htmlgo v1.0.3/go.mod h1:pCKSFJsoVNkyW+yN2i1Mst+8130NSQzIU7L2IbnuyKg= github.com/theplant/htmltestingutils v0.0.0-20190423050759-0e06de7b6967 h1:yPrgtU8bj7Q/XbXgjjmngZtOhsUufBAraruNwxv/eXM= github.com/theplant/htmltestingutils v0.0.0-20190423050759-0e06de7b6967/go.mod h1:86iN4EAYaQbx1VTW5uPslTIviRkYH8CzslMC//g+BgY= -github.com/theplant/inject v1.0.2 h1:JXN2C/bAWJ9COG53WGoM9npJyYpwufYTHop+x2abDaQ= -github.com/theplant/inject v1.0.2/go.mod h1:vuuYp7lKifex8HfI4izobKSqulfhpWIvGvW2/pP4Rqo= +github.com/theplant/inject v1.1.0 h1:Bxiu4rXQN0BJSwNHAMPszkiySmozPTYZvbhaOKJr+lg= +github.com/theplant/inject v1.1.0/go.mod h1:vuuYp7lKifex8HfI4izobKSqulfhpWIvGvW2/pP4Rqo= github.com/theplant/osenv v0.0.2 h1:SI2I/gLQQj5pQgpBQ8YKx/u4i7KE7yG2Gmr/ZORuxn8= github.com/theplant/osenv v0.0.2/go.mod h1:gUdlLzvmJb/dyBmXk+qEWiIhAN1tmVhYktzK1HHEz3c= github.com/theplant/relay v0.4.3-0.20250424075128-61850ded6ace h1:2Vxx6Wh7d8G4S2gY6Z+kaqL/YirmLGX6p4g/2ziaVAg= @@ -453,6 +537,8 @@ github.com/theplant/testenv v0.2.1 h1:GL80bSN7GHs4tl98W1ufdd2YcQ0BkHncWSgsOZsNY6 github.com/theplant/testenv v0.2.1/go.mod h1:/Eq/353mtHC7t1VzGpZ/dzGI/YQ5QN1kZMB0+5GqCn4= github.com/theplant/testingutils v0.0.2 h1:ryFb7J8NPnyMA4mdgBEf5ha3QUqWA9WVulWGyUbH2u4= github.com/theplant/testingutils v0.0.2/go.mod h1:nh7wj3YTJehg0PBHnPXtvqIqdnBUn0Gqb79JHnblFuc= +github.com/theplant/validator v0.0.0-20210202101755-357a9daa8f5f h1:VQbZHMv9xuUihaRESflntL8VIi/cI6nVaP6a+F78BkU= +github.com/theplant/validator v0.0.0-20210202101755-357a9daa8f5f/go.mod h1:Gy7nm6qbfZJXKLRga/Gbb+zca34+j4eKVgPvK+TeRHU= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -480,6 +566,8 @@ github.com/wI2L/jsondiff v0.6.0 h1:zrsH3FbfVa3JO9llxrcDy/XLkYPLgoMX6Mz3T2PP2AI= github.com/wI2L/jsondiff v0.6.0/go.mod h1:D6aQ5gKgPF9g17j+E9N7aasmU1O+XvfmWm1y8UMmNpw= github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts= github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -511,10 +599,14 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.18.1/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= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= @@ -618,9 +710,11 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -635,6 +729,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -693,6 +788,7 @@ golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/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-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -712,6 +808,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -738,17 +836,20 @@ google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvx google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= @@ -762,13 +863,17 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= diff --git a/microsite/microsite.go b/microsite/microsite.go index e55895b74..89660a9de 100644 --- a/microsite/microsite.go +++ b/microsite/microsite.go @@ -3,6 +3,7 @@ package microsite import ( "context" "encoding/json" + "errors" "fmt" "io" "path" @@ -176,9 +177,9 @@ func (this *MicroSite) GetUnPublishActions(ctx context.Context, db *gorm.DB, sto } func (*MicroSite) UnArchiveAndPublish(getPath func(string) string, fileName string, f io.Reader, storage oss.StorageInterface) (filesList []string, err error) { - format, reader, err := archiver.Identify(fileName, f) + format, reader, err := archiver.Identify(context.Background(), fileName, f) if err != nil { - if err == archiver.ErrNoMatch { + if errors.Is(err, archiver.NoMatch) { err = utils.Upload(storage, getPath(fileName), f) return } @@ -189,19 +190,21 @@ func (*MicroSite) UnArchiveAndPublish(getPath func(string) string, fileName stri var putError error var mutex sync.Mutex - err = format.(archiver.Extractor).Extract(context.Background(), reader, nil, func(ctx context.Context, f archiver.File) (err error) { - if f.IsDir() || strings.Contains(f.NameInArchive, "__MACOSX") || strings.Contains(f.NameInArchive, "DS_Store") { + err = format.(archiver.Extractor).Extract(context.Background(), reader, func(ctx context.Context, info archiver.FileInfo) (err error) { + if info.IsDir() || strings.Contains(info.NameInArchive, "__MACOSX") || strings.Contains(info.NameInArchive, "DS_Store") { return } - rc, err := f.Open() + rc, err := info.Open() if err != nil { return } - filesList = append(filesList, f.NameInArchive) + mutex.Lock() + filesList = append(filesList, info.NameInArchive) + mutex.Unlock() - publishedPath := getPath(f.NameInArchive) + publishedPath := getPath(info.NameInArchive) wg.Add(1) putSemaphore <- struct{}{} go func() { diff --git a/presets/api.go b/presets/api.go index c17464cf4..2f2ea36e7 100644 --- a/presets/api.go +++ b/presets/api.go @@ -98,6 +98,8 @@ type SlugEncoder interface { type FilterDataFunc func(ctx *web.EventContext) vuetifyx.FilterData +type FilterNotificationFunc func(ctx *web.EventContext) h.HTMLComponent + type FilterTab struct { ID string Label string diff --git a/presets/data_operator.go b/presets/data_operator.go new file mode 100644 index 000000000..a2758b6e6 --- /dev/null +++ b/presets/data_operator.go @@ -0,0 +1,105 @@ +package presets + +import ( + "cmp" + + "github.com/pkg/errors" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/i18n" + "github.com/qor5/x/v3/i18nx" + "github.com/qor5/x/v3/statusx" + "github.com/samber/lo" + "golang.org/x/text/language" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// DataOperatorWithGRPC returns a hook that handles gRPC-related concerns for +// DataOperator: outgoing metadata injection and error conversion. +type grpcWrapper struct { + next DataOperator +} + +func DataOperatorWithGRPC(next DataOperator) DataOperator { + return &grpcWrapper{next: next} +} + +func (w *grpcWrapper) Search(eventCtx *web.EventContext, params *SearchParams) (*SearchResult, error) { + w.inject(eventCtx) + res, err := w.next.Search(eventCtx, params) + if err != nil { + return nil, w.convert(err) + } + return res, nil +} + +func (w *grpcWrapper) Fetch(obj any, id string, eventCtx *web.EventContext) (any, error) { + w.inject(eventCtx) + res, err := w.next.Fetch(obj, id, eventCtx) + if err != nil { + return nil, w.convert(err) + } + return res, nil +} + +func (w *grpcWrapper) Save(obj any, id string, eventCtx *web.EventContext) error { + w.inject(eventCtx) + if err := w.next.Save(obj, id, eventCtx); err != nil { + return w.convert(err) + } + return nil +} + +func (w *grpcWrapper) Delete(obj any, id string, eventCtx *web.EventContext) error { + w.inject(eventCtx) + if err := w.next.Delete(obj, id, eventCtx); err != nil { + return w.convert(err) + } + return nil +} + +func (w *grpcWrapper) inject(eventCtx *web.EventContext) { + ctx := eventCtx.R.Context() + ctx = metadata.AppendToOutgoingContext( + ctx, + i18nx.HeaderSelectedLanguage, + i18n.LanguageTagFromContext(ctx, language.English).String(), + ) + eventCtx.R = eventCtx.R.WithContext(ctx) +} + +func (w *grpcWrapper) convert(err error) error { + if err == nil { + return nil + } + + st, ok := status.FromError(err) + if !ok { + return err + } + + var vErr web.ValidationErrors + + details := st.Details() + + badRequest := statusx.ExtractDetail[*errdetails.BadRequest](details) + if badRequest != nil { + for _, violation := range badRequest.GetFieldViolations() { + vErr.FieldError( + statusx.FormatField(violation.GetField(), lo.PascalCase), + cmp.Or(violation.GetLocalizedMessage().GetMessage(), violation.GetDescription()), + ) + } + return errors.WithStack(&vErr) + } + + localized := statusx.ExtractDetail[*errdetails.LocalizedMessage](details) + if localized != nil { + vErr.GlobalError(cmp.Or(localized.GetMessage(), st.Message())) + } else { + vErr.GlobalError(st.Message()) + } + + return errors.WithStack(&vErr) +} diff --git a/presets/editing.go b/presets/editing.go index 3b977aa9e..7e9817b8c 100644 --- a/presets/editing.go +++ b/presets/editing.go @@ -1,6 +1,7 @@ package presets import ( + "errors" "fmt" "strings" "time" @@ -567,7 +568,12 @@ func (b *EditingBuilder) doUpdate( err1 := usingB.Saver(obj, id, ctx) if err1 != nil { - usingB.UpdateOverlayContent(ctx, r, obj, "", err1) + var ve *web.ValidationErrors + if errors.As(err1, &ve) { + usingB.UpdateOverlayContent(ctx, r, obj, "", ve) + } else { + usingB.UpdateOverlayContent(ctx, r, obj, "", err1) + } return created, err1 } diff --git a/presets/listener.go b/presets/listener.go index c9cd64a14..c82db6579 100644 --- a/presets/listener.go +++ b/presets/listener.go @@ -42,7 +42,7 @@ func NotifModelsDeleted(v any) string { } func (*ModelBuilder) NotifRowUpdated() string { - return fmt.Sprintf("presets_NotifRowUpdated") + return "presets_NotifRowUpdated" } type PayloadRowUpdated struct { diff --git a/presets/listing_builder.go b/presets/listing_builder.go index 05ab2ed04..7103bf253 100644 --- a/presets/listing_builder.go +++ b/presets/listing_builder.go @@ -40,23 +40,24 @@ type OrderableField struct { } type ListingBuilder struct { - mb *ModelBuilder - bulkActions []*BulkActionBuilder - footerActions []*FooterActionBuilder - actions []*ActionBuilder - actionsAsMenu bool - rowMenu *RowMenuBuilder - filterDataFunc FilterDataFunc - filterTabsFunc FilterTabsFunc - newBtnFunc ComponentFunc - pageFunc web.PageFunc - cellWrapperFunc vx.CellWrapperFunc - cellProcessor CellProcessor - rowProcessor RowProcessor - tableProcessor TableProcessor - Searcher SearchFunc - searchColumns []string - titleFunc func(evCtx *web.EventContext, style ListingStyle, defaultTitle string) (title string, titleCompo h.HTMLComponent, err error) + mb *ModelBuilder + bulkActions []*BulkActionBuilder + footerActions []*FooterActionBuilder + actions []*ActionBuilder + actionsAsMenu bool + rowMenu *RowMenuBuilder + filterDataFunc FilterDataFunc + filterTabsFunc FilterTabsFunc + filterNotificationFunc FilterNotificationFunc + newBtnFunc ComponentFunc + pageFunc web.PageFunc + cellWrapperFunc vx.CellWrapperFunc + cellProcessor CellProcessor + rowProcessor RowProcessor + tableProcessor TableProcessor + Searcher SearchFunc + searchColumns []string + titleFunc func(evCtx *web.EventContext, style ListingStyle, defaultTitle string) (title string, titleCompo h.HTMLComponent, err error) // perPage is the number of records per page. // if request query param "per_page" is set, it will be set to that value. @@ -223,6 +224,11 @@ func (b *ListingBuilder) RelayPagination(v RelayPagination) (r *ListingBuilder) return b } +func (b *ListingBuilder) FilterNotificationFunc(v FilterNotificationFunc) (r *ListingBuilder) { + b.filterNotificationFunc = v + return b +} + func (b *ListingBuilder) NewButtonFunc(v ComponentFunc) (r *ListingBuilder) { b.newBtnFunc = v return b diff --git a/presets/listing_compo.go b/presets/listing_compo.go index a5266832f..43909901a 100644 --- a/presets/listing_compo.go +++ b/presets/listing_compo.go @@ -187,6 +187,14 @@ func (c *ListingCompo) MarshalHTML(ctx context.Context) (r []byte, err error) { ).MarshalHTML(ctx) } +func (c *ListingCompo) filterNotification(ctx context.Context) h.HTMLComponent { + if c.lb.filterNotificationFunc == nil { + return nil + } + evCtx, _ := c.MustGetEventContext(ctx) + return c.lb.filterNotificationFunc(evCtx) +} + func (c *ListingCompo) tabsFilter(ctx context.Context) h.HTMLComponent { if c.lb.filterTabsFunc == nil { return nil @@ -710,6 +718,7 @@ func (c *ListingCompo) dataTable(ctx context.Context) h.HTMLComponent { return h.Components( filterScript, + c.filterNotification(ctx), dataBody, ) } @@ -817,7 +826,7 @@ func CardDataTableFunc(lb *ListingBuilder, config *CardDataTableConfig) func(ctx ).Class("pa-0"), ) var card h.HTMLComponent - if selectedActions != nil && len(selectedActions) > 0 { + if len(selectedActions) > 0 { card = VHover( web.Slot( VCard( diff --git a/presets/presets.go b/presets/presets.go index cb852b271..f2b3adb06 100644 --- a/presets/presets.go +++ b/presets/presets.go @@ -7,6 +7,7 @@ import ( "net/http" "regexp" "strings" + "sync" "github.com/iancoleman/strcase" "github.com/jinzhu/inflection" @@ -30,6 +31,7 @@ type Builder struct { prefix string models []*ModelBuilder handler http.Handler + warmupOnce sync.Once builder *web.Builder dc *stateful.DependencyCenter i18nBuilder *i18n.Builder @@ -1393,8 +1395,15 @@ func (b *Builder) LookUpModelBuilder(uriName string) *ModelBuilder { } func (b *Builder) ServeHTTP(w http.ResponseWriter, r *http.Request) { + b.warmupOnce.Do(func() { + if b.handler == nil { + b.Build() + } + }) if b.handler == nil { - b.Build() + log.Printf("presets: Builder.handler is nil after Build; cannot serve request for %s", r.URL.Path) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } redirectSlashes(b.handler).ServeHTTP(w, r) } diff --git a/presets/presets_test.go b/presets/presets_test.go index 73c1105c4..3fe4fc97e 100644 --- a/presets/presets_test.go +++ b/presets/presets_test.go @@ -1,6 +1,8 @@ package presets import ( + "bytes" + "log" "net/http" "net/http/httptest" "net/url" @@ -183,3 +185,26 @@ func TestNewMuxHook(t *testing.T) { assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "next handler", w2.Body.String()) } + +func TestServeHTTP_HandlerNilAfterOnce_Returns500(t *testing.T) { + b := New() + + // Mark warmupOnce as done to skip Build() so that handler stays nil. + b.warmupOnce.Do(func() {}) + + // Capture log output. + var buf bytes.Buffer + orig := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(orig) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo", nil) + + b.ServeHTTP(w, r) + + require.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Internal Server Error") + assert.Contains(t, buf.String(), "Builder.handler is nil after Build") + assert.Contains(t, buf.String(), "/foo") +} diff --git a/presets/section.go b/presets/section.go index 1bcad1dab..91a7f6a2a 100644 --- a/presets/section.go +++ b/presets/section.go @@ -1097,10 +1097,18 @@ func (b *SectionBuilder) SaveDetailField(ctx *web.EventContext) (r web.EventResp } if needSave { - err = b.saver(obj, id, ctx) + err := b.saver(obj, id, ctx) if err != nil { - ShowMessage(&r, err.Error(), "warning") - return r, nil + var ve *web.ValidationErrors + if errors.As(err, &ve) { + ctx.Flash = ve + if ve.GetGlobalError() != "" { + ShowMessage(&r, ve.GetGlobalError(), "warning") + } + } else { + ShowMessage(&r, err.Error(), "warning") + return r, nil + } } } diff --git a/starter/auth.go b/starter/auth.go new file mode 100644 index 000000000..ae724767b --- /dev/null +++ b/starter/auth.go @@ -0,0 +1,398 @@ +package starter + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/iancoleman/strcase" + "github.com/markbates/goth" + "github.com/markbates/goth/providers/github" + "github.com/markbates/goth/providers/google" + "github.com/markbates/goth/providers/microsoftonline" + "github.com/pkg/errors" + "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/admin/v3/presets/gorm2op" + "github.com/qor5/admin/v3/role" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/i18n" + "github.com/qor5/x/v3/login" + "github.com/qor5/x/v3/perm" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + . "github.com/theplant/htmlgo" + + plogin "github.com/qor5/admin/v3/login" +) + +// AuthConfig contains all authentication-related configuration +type AuthConfig struct { + Secret string `confx:"secret" validate:"required"` + BaseURL string `confx:"baseURL" validate:"required,url"` + InitialUserEmail string `confx:"initialUserEmail" validate:"required,email"` + InitialUserPassword string `confx:"initialUserPassword" validate:"required,min=12"` + InitialUserRole string `confx:"initialUserRole" validate:"required"` + GoogleClientKey string `confx:"googleClientKey"` + GoogleClientSecret string `confx:"googleClientSecret"` + MicrosoftClientKey string `confx:"microsoftClientKey"` + MicrosoftClientSecret string `confx:"microsoftClientSecret"` + GitHubClientKey string `confx:"githubClientKey"` + GitHubClientSecret string `confx:"githubClientSecret"` + EnableRecaptcha bool `confx:"enableRecaptcha"` + RecaptchaSiteKey string `confx:"recaptchaSiteKey"` + RecaptchaSecretKey string `confx:"recaptchaSecretKey"` + MaxRetryCount int `confx:"maxRetryCount" validate:"min=1"` + EnableTOTP bool `confx:"enableTOTP"` +} + +// createRoleBuilder creates and configures the role builder +func (a *Handler) createRoleBuilder() *role.Builder { + roleBuilder := role.New(a.DB). + AfterInstall(func(_ *presets.Builder, mb *presets.ModelBuilder) error { + mb.Listing().SearchFunc(func(ctx *web.EventContext, params *presets.SearchParams) (*presets.SearchResult, error) { + u := GetCurrentUser(ctx.R) + qdb := a.DB.WithContext(ctx.R.Context()) + // If the current user doesn't have 'admin' role, do not allow them to view admin and manager roles + if currentRoles := u.GetRoles(); !slices.Contains(currentRoles, RoleAdmin) { + qdb = qdb.Where("name NOT IN (?)", []string{RoleAdmin, RoleManager}) + } + result, err := gorm2op.DataOperator(qdb).Search(ctx, params) + if err != nil { + return nil, errors.Wrap(err, "failed to search roles") + } + return result, nil + }) + return nil + }) + + a.Use(roleBuilder) + return roleBuilder +} + +func (a *Handler) createLoginBuilder(presetsBuilder *presets.Builder) *login.Builder { + loginBuilder := plogin.New(presetsBuilder). + DB(a.DB). + UserModel(&User{}). + Secret(a.Auth.Secret). + OAuthProviders(buildOAuthProviders(&a.Auth)...). + HomeURLFunc(func(_ *http.Request, _ any) string { + return strings.TrimSuffix(a.Prefix, "/") + "/" + }). + Recaptcha(a.Auth.EnableRecaptcha, login.RecaptchaConfig{ + SiteKey: a.Auth.RecaptchaSiteKey, + SecretKey: a.Auth.RecaptchaSecretKey, + }). + WrapBeforeSetPassword(func(in login.HookFunc) login.HookFunc { + return func(r *http.Request, user any, extraVals ...any) error { + if err := in(r, user, extraVals...); err != nil { + return err + } + + u := user.(*User) + if u.GetAccountName() == a.Auth.InitialUserEmail { + return errors.WithStack( + &login.NoticeError{ + Level: login.NoticeLevel_Error, + Message: "Cannot change password for public user", + }, + ) + } + + password := extraVals[0].(string) + if len(password) < 12 { + return errors.WithStack( + &login.NoticeError{ + Level: login.NoticeLevel_Error, + Message: "Password cannot be less than 12 characters", + }, + ) + } + + return nil + } + }). + WrapAfterOAuthComplete(func(in login.HookFunc) login.HookFunc { + return func(r *http.Request, user any, extraVals ...any) error { + if err := in(r, user, extraVals...); err != nil { + return err + } + + gothUser := user.(goth.User) + if gothUser.Email == "" { + return nil + } + + db := a.DB.WithContext(r.Context()) + + // Check if user already exists + if err := db.Where("o_auth_provider = ? AND o_auth_identifier = ?", gothUser.Provider, gothUser.Email). + First(&User{}).Error; errors.Is(err, gorm.ErrRecordNotFound) { + // Create new user from OAuth data + var name string + if at := strings.LastIndex(gothUser.Email, "@"); at > 0 { + name = gothUser.Email[:at] + } else { + name = gothUser.Email + } + + newUser := &User{ + Name: name, + Status: StatusActive, + RegistrationDate: time.Now(), + OAuthInfo: login.OAuthInfo{ + OAuthProvider: gothUser.Provider, + OAuthUserID: gothUser.UserID, + OAuthIdentifier: gothUser.Email, + OAuthAvatar: gothUser.AvatarURL, + }, + } + if err := db.Create(newUser).Error; err != nil { + return errors.Wrap(err, "failed to create user from OAuth") + } + + // Grant manager role to new OAuth user + if err := grantUserRole(r.Context(), db, newUser.ID, RoleManager); err != nil { + return err + } + } + + return nil + } + }). + TOTP(a.Auth.EnableTOTP). + MaxRetryCount(a.Auth.MaxRetryCount) + + loginBuilder.LoginPageFunc(plogin.NewAdvancedLoginPage(func(ctx *web.EventContext, config *plogin.AdvancedLoginPageConfig) (*plogin.AdvancedLoginPageConfig, error) { + msgr := i18n.MustGetModuleMessages(ctx.R, I18nDemoKey, Messages_en_US).(*Messages) + config.TitleLabel = msgr.SystemTitleLabel + return config, nil + })(loginBuilder.ViewHelper(), presetsBuilder)) + + return loginBuilder +} + +func (a *Handler) createLoginSessionBuilder(loginBuilder *login.Builder, activityBuilder *activity.Builder) *plogin.SessionBuilder { + loginSessionBuilder := plogin.NewSessionBuilder(loginBuilder, a.DB). + Activity(activityBuilder.RegisterModel(&User{})). + IsPublicUser(func(u any) bool { + user, ok := u.(*User) + return ok && user.GetAccountName() == a.Auth.InitialUserEmail + }). + AutoMigrate() + a.Use(loginSessionBuilder) + return loginSessionBuilder +} + +// createProfileBuilder creates and configures the profile builder +func (a *Handler) createProfileBuilder(activityBuilder *activity.Builder, loginSessionBuilder *plogin.SessionBuilder) *plogin.ProfileBuilder { + profileBuilder := plogin.NewProfileBuilder( + func(ctx context.Context) (*plogin.Profile, error) { + evCtx := web.MustGetEventContext(ctx) + u := GetCurrentUser(evCtx.R) + if u == nil { + return nil, perm.PermissionDenied //nolint:errhandle + } + notifiCounts, err := activityBuilder.GetNotesCounts(ctx, "", nil) + if err != nil { + return nil, errors.Wrap(err, "failed to get activity notes counts") + } + user := &plogin.Profile{ + ID: fmt.Sprint(u.ID), + Name: u.Name, + Roles: u.GetRoles(), + Status: strcase.ToCamel(u.Status), + Fields: []*plogin.ProfileField{ + {Name: "Email", Value: u.Account}, + {Name: "Company", Value: u.Company}, + }, + NotifCounts: notifiCounts, + } + if u.OAuthAvatar != "" { + user.Avatar = u.OAuthAvatar + } + return user, nil + }, + func(ctx context.Context, newName string) error { + evCtx := web.MustGetEventContext(ctx) + u := GetCurrentUser(evCtx.R) + if u == nil { + return perm.PermissionDenied //nolint:errhandle + } + u.Name = newName + if err := a.DB.Save(u).Error; err != nil { + return errors.Wrap(err, "failed to update user name") + } + return nil + }, + ).SessionBuilder(loginSessionBuilder) + + a.Use(profileBuilder) + return profileBuilder +} + +// buildOAuthProviders creates OAuth provider configurations +func buildOAuthProviders(authConfig *AuthConfig) []*login.Provider { + var providers []*login.Provider + + // Google OAuth provider + if authConfig.GoogleClientKey != "" && authConfig.GoogleClientSecret != "" { + providers = append(providers, &login.Provider{ + Goth: google.New( + authConfig.GoogleClientKey, + authConfig.GoogleClientSecret, + authConfig.BaseURL+"/auth/callback?provider="+OAuthProviderGoogle, + ), + Key: OAuthProviderGoogle, + Text: "LoginProviderGoogleText", + Logo: RawHTML(``), + }) + } + + // Microsoft OAuth provider + if authConfig.MicrosoftClientKey != "" && authConfig.MicrosoftClientSecret != "" { + providers = append(providers, &login.Provider{ + Goth: microsoftonline.New( + authConfig.MicrosoftClientKey, + authConfig.MicrosoftClientSecret, + // TODO: @molon 为什么这里偏偏不用给到 OAuthProviderMicrosoftOnline provider 参数呢? + authConfig.BaseURL+"/auth/callback", + ), + Key: OAuthProviderMicrosoftOnline, + Text: "LoginProviderMicrosoftText", + Logo: RawHTML(``), + }) + } + + // GitHub OAuth provider + if authConfig.GitHubClientKey != "" && authConfig.GitHubClientSecret != "" { + providers = append(providers, &login.Provider{ + Goth: github.New( + authConfig.GitHubClientKey, + authConfig.GitHubClientSecret, + authConfig.BaseURL+"/auth/callback?provider="+OAuthProviderGithub, + ), + Key: OAuthProviderGithub, + Text: "LoginProviderGithubText", + Logo: RawHTML(``), + }) + } + + return providers +} + +func createDefaultRolesIfEmpty(ctx context.Context, db *gorm.DB) error { + db = db.WithContext(ctx) + + var count int64 + if err := db.Model(&role.Role{}).Count(&count).Error; err != nil { + return errors.Wrap(err, "failed to count roles") + } + + if count > 0 { + return nil + } + + var roles []*role.Role + for _, roleName := range DefaultRoles { + roles = append(roles, &role.Role{ + Name: roleName, + }) + } + + if err := db.Create(roles).Error; err != nil { + return errors.Wrap(err, "failed to create default roles") + } + + return nil +} + +func createInitialUserIfEmpty(ctx context.Context, db *gorm.DB, opts *UpsertUserOptions) (*User, error) { + db = db.WithContext(ctx) + + var count int64 + if err := db.Model(&User{}).Where("account = ?", opts.Email).Count(&count).Error; err != nil { + return nil, errors.Wrap(err, "failed to count users") + } + if count > 0 { + return nil, nil + } + + return UpsertUser(ctx, db, opts) +} + +func grantUserRole(ctx context.Context, db *gorm.DB, userID uint, roleName string) error { + db = db.WithContext(ctx) + + var roleID int + if err := db.Model(&role.Role{}).Where("name = ?", roleName).Pluck("id", &roleID).Error; err != nil { + return errors.Wrapf(err, "failed to get role id for role %s", roleName) + } + + if err := db.Table("user_role_join"). + Clauses(clause.OnConflict{DoNothing: true}). + Create(map[string]any{"user_id": userID, "role_id": roleID}).Error; err != nil { + return errors.Wrapf(err, "failed to grant role %s to user %d", roleName, userID) + } + + return nil +} + +type UpsertUserOptions struct { + Email string + Password string + Role []string +} + +func UpsertUser(ctx context.Context, db *gorm.DB, opts *UpsertUserOptions) (*User, error) { + user := &User{ + Name: opts.Email, + Status: StatusActive, + UserPass: login.UserPass{ + Account: opts.Email, + Password: opts.Password, + }, + } + user.EncryptPassword() + + db = db.WithContext(ctx) + + err := db.Clauses( + clause.OnConflict{ + Columns: []clause.Column{{Name: "account"}}, + TargetWhere: clause.Where{Exprs: []clause.Expression{ + gorm.Expr("account <> ''"), + gorm.Expr("deleted_at IS NULL"), + }}, + UpdateAll: true, + }, + clause.Returning{Columns: []clause.Column{{Name: "id"}}}, + ).Create(user).Error + if err != nil { + return nil, errors.Wrap(err, "failed to upsert user") + } + + for _, roleName := range opts.Role { + if err := grantUserRole(ctx, db, user.ID, roleName); err != nil { + return nil, errors.Wrapf(err, "failed to grant role %s to user", roleName) + } + } + + if err := db.Preload("Roles").Where("id = ?", user.ID).First(user).Error; err != nil { + return nil, errors.Wrap(err, "failed to reload user with roles") + } + + return user, nil +} + +func GetCurrentUser(r *http.Request) *User { + u, ok := login.GetCurrentUser(r).(*User) + if !ok { + return nil + } + return u +} diff --git a/starter/auth_test.go b/starter/auth_test.go new file mode 100644 index 000000000..633577ab8 --- /dev/null +++ b/starter/auth_test.go @@ -0,0 +1,358 @@ +package starter_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + . "github.com/qor5/web/v3/multipartestutils" + + "github.com/qor5/admin/v3/starter" + "github.com/qor5/x/v3/gormx" + "github.com/stretchr/testify/require" + "github.com/theplant/inject" +) + +// Coverage focus: auth builder pages and role listing behavior for different roles +func TestAuthChangePasswordAndProfileRename(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + + cases := []TestCase{ + { + Name: "Change Password Page", + Debug: true, + ReqFunc: func() *http.Request { + return httptest.NewRequest("GET", "/auth/change-password", http.NoBody) + }, + ExpectPageBodyContainsInOrder: []string{"Change your password", "Old password", "New password"}, + }, + { + Name: "Profile Rename", + Debug: true, + ReqFunc: func() *http.Request { + // Send a stateful action to rename current user + return NewMultipartBuilder(). + PageURL("/?__execute_event__=__dispatch_stateful_action__"). + AddField("__action__", ` +{ + "compo_type": "*login.ProfileCompo", + "compo": {"id": ""}, + "injector": "__profile__", + "sync_query": false, + "method": "Rename", + "request": {"name": "renamed@example.com"} +} + `). + BuildEventFuncRequest() + }, + EventResponseMatch: func(t *testing.T, er *TestEventResponse) { + var u starter.User + require.NoError(t, db.Where("account = ?", "test@example.com").First(&u).Error) + require.Equal(t, "renamed@example.com", u.Name) + }, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + RunCase(t, c, env.handler) + }) + } +} + +func TestRolesIndex_AdminAndEditorVisibility(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + // Admin should see Admin/Manager roles + { + c := TestCase{ + Name: "Roles Visible For Admin", + Debug: true, + ReqFunc: func() *http.Request { + return httptest.NewRequest("GET", "/roles", http.NoBody) + }, + ExpectPageBodyContainsInOrder: []string{"Viewer", "Editor", "Manager", ">Admin<"}, + } + t.Run(c.Name, func(t *testing.T) { + RunCase(t, c, env.handler) + }) + } + + // Switch current user to Editor by modifying join table, withRoles middleware reloads from DB + { + var cur starter.User + require.NoError(t, db.Where("account = ?", "test@example.com").First(&cur).Error) + // Find Editor role id + var editorRoleID uint + require.NoError(t, db.Table("roles").Select("id").Where("name = ?", starter.RoleEditor).Scan(&editorRoleID).Error) + require.NotZero(t, editorRoleID) + // Reset joins + require.NoError(t, db.Exec("DELETE FROM user_role_join WHERE user_id = ?", cur.ID).Error) + require.NoError(t, db.Exec("INSERT INTO user_role_join(user_id, role_id) VALUES(?, ?)", cur.ID, editorRoleID).Error) + + c := TestCase{ + Name: "Roles Hidden For Editor", + Debug: true, + ReqFunc: func() *http.Request { + return httptest.NewRequest("GET", "/roles", http.NoBody) + }, + ExpectPageBodyContainsInOrder: []string{"Viewer", "Editor"}, + ExpectPageBodyNotContains: []string{"Manager", ">Admin<"}, + } + t.Run(c.Name, func(t *testing.T) { + RunCase(t, c, env.handler) + }) + } +} + +func TestUsersListing_FilterAdminManagerForNonAdmin(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + + // Prepare one Manager user to ensure listing filter can exclude it for non-admin + mgr, err := starter.UpsertUser(context.Background(), db, &starter.UpsertUserOptions{ + Email: "manager@example.com", + Password: "1234567890abcd", + Role: []string{starter.RoleManager}, + }) + require.NoError(t, err) + require.NotNil(t, mgr) + + // Switch current user to Editor + var cur starter.User + require.NoError(t, db.Where("account = ?", "test@example.com").First(&cur).Error) + var editorRoleID uint + require.NoError(t, db.Table("roles").Select("id").Where("name = ?", starter.RoleEditor).Scan(&editorRoleID).Error) + require.NoError(t, db.Exec("DELETE FROM user_role_join WHERE user_id = ?", cur.ID).Error) + require.NoError(t, db.Exec("INSERT INTO user_role_join(user_id, role_id) VALUES(?, ?)", cur.ID, editorRoleID).Error) + + // As Editor, Manager users should be filtered out in listing + c := TestCase{ + Name: "Users listing hides Admin/Manager for non-admin", + Debug: true, + ReqFunc: func() *http.Request { + return httptest.NewRequest("GET", "/users", http.NoBody) + }, + ExpectPageBodyNotContains: []string{"manager@example.com"}, + } + t.Run(c.Name, func(t *testing.T) { + RunCase(t, c, env.handler) + }) +} + +type unloginKey struct{} + +func TestAuthLoginPage(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler, func(handler *starter.Handler) *unloginKey { + handler.WithHandlerHook(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Cookie", "") + next.ServeHTTP(w, r) + }) + }) + return &unloginKey{} + }) + c := TestCase{ + Name: "Login Page", + Debug: true, + ReqFunc: func() *http.Request { + return httptest.NewRequest("GET", "/auth/login", http.NoBody) + }, + ExpectPageBodyContainsInOrder: []string{"Sign in with Google", "Sign in with Microsoft", "Sign in with Github"}, + } + t.Run(c.Name, func(t *testing.T) { + RunCase(t, c, env.handler) + }) +} + +func TestAuthLoigin(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler, func(handler *starter.Handler) *unloginKey { + handler.WithHandlerHook(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Cookie", "") + next.ServeHTTP(w, r) + }) + }) + return &unloginKey{} + }) + form := url.Values{} + form.Set("account", "qor@theplant.jp") + form.Set("password", "admin123456789") + req := httptest.NewRequest("POST", "/auth/userpass/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rr := httptest.NewRecorder() + env.handler.ServeHTTP(rr, req) + + res := rr.Result() + defer res.Body.Close() + + require.Equal(t, http.StatusFound, res.StatusCode) + require.Equal(t, "/", res.Header.Get("Location")) + + var hasAuthCookie bool + for _, c := range res.Cookies() { + if c.Name == "auth" && c.Value != "" { + hasAuthCookie = true + break + } + } + require.True(t, hasAuthCookie) +} + +func TestDoResetPassword_Success(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + + // Prepare a dedicated user for reset flow to avoid affecting other tests + ctx := context.Background() + usr, err := starter.UpsertUser(ctx, db, &starter.UpsertUserOptions{ + Email: "resetuser@example.com", + Password: "origPassword1234", + Role: []string{starter.RoleViewer}, + }) + require.NoError(t, err) + require.NotNil(t, usr) + + // Generate reset token + token, err := usr.GenerateResetPasswordToken(db, &starter.User{}) + require.NoError(t, err) + userID := strconv.Itoa(int(usr.ID)) + + // Call do-reset-password + form := url.Values{} + form.Set("user_id", userID) + form.Set("token", token) + form.Set("password", "newPassword1234") + form.Set("confirm_password", "newPassword1234") + req := httptest.NewRequest("POST", "/auth/do-reset-password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + env.handler.ServeHTTP(rr, req) + res := rr.Result() + defer res.Body.Close() + + require.Equal(t, http.StatusFound, res.StatusCode) + require.Equal(t, "/auth/login", res.Header.Get("Location")) + + // Verify new password works by logging in + loginForm := url.Values{} + loginForm.Set("account", "resetuser@example.com") + loginForm.Set("password", "newPassword1234") + loginReq := httptest.NewRequest("POST", "/auth/userpass/login", strings.NewReader(loginForm.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRR := httptest.NewRecorder() + env.handler.ServeHTTP(loginRR, loginReq) + loginRes := loginRR.Result() + defer loginRes.Body.Close() + + require.Equal(t, http.StatusFound, loginRes.StatusCode) + require.Equal(t, "/", loginRes.Header.Get("Location")) + + var hasAuthCookie bool + for _, c := range loginRes.Cookies() { + if c.Name == "auth" && c.Value != "" { + hasAuthCookie = true + break + } + } + require.True(t, hasAuthCookie) + + // Follow redirect to home with cookies to ensure authenticated access works + homeReq := httptest.NewRequest("GET", "/", http.NoBody) + for _, c := range loginRes.Cookies() { + homeReq.AddCookie(c) + } + homeRR := httptest.NewRecorder() + env.handler.ServeHTTP(homeRR, homeReq) + homeRes := homeRR.Result() + defer homeRes.Body.Close() + require.Equal(t, http.StatusOK, homeRes.StatusCode) +} + +func TestDoResetPassword_FailedByInitialUser(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + + ctx := context.Background() + usr, err := starter.UpsertUser(ctx, db, &starter.UpsertUserOptions{ + Email: "qor@theplant.jp", + Password: "admin123456789", + Role: []string{starter.RoleViewer}, + }) + require.NoError(t, err) + require.NotNil(t, usr) + + // Generate reset token + token, err := usr.GenerateResetPasswordToken(db, &starter.User{}) + require.NoError(t, err) + userID := strconv.Itoa(int(usr.ID)) + + // Call do-reset-password and expect redirect back to reset page + form := url.Values{} + form.Set("user_id", userID) + form.Set("token", token) + form.Set("password", "newPassword1234") + form.Set("confirm_password", "newPassword1234") + req := httptest.NewRequest("POST", "/auth/do-reset-password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + env.handler.ServeHTTP(rr, req) + res := rr.Result() + defer res.Body.Close() + + require.Equal(t, http.StatusFound, res.StatusCode) + loc := res.Header.Get("Location") + require.True(t, strings.HasPrefix(loc, "/auth/reset-password?")) + require.True(t, strings.Contains(loc, "id=")) + require.True(t, strings.Contains(loc, "token=")) +} + +func TestDoResetPassword_FailedByLessPassword(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + + ctx := context.Background() + usr, err := starter.UpsertUser(ctx, db, &starter.UpsertUserOptions{ + Email: "resetuser@theplant.jp", + Password: "admin123456789", + Role: []string{starter.RoleViewer}, + }) + require.NoError(t, err) + require.NotNil(t, usr) + + // Generate reset token + token, err := usr.GenerateResetPasswordToken(db, &starter.User{}) + require.NoError(t, err) + userID := strconv.Itoa(int(usr.ID)) + + // Call do-reset-password + form := url.Values{} + form.Set("user_id", userID) + form.Set("token", token) + form.Set("password", "newPassword") + form.Set("confirm_password", "newPassword") + req := httptest.NewRequest("POST", "/auth/do-reset-password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + env.handler.ServeHTTP(rr, req) + res := rr.Result() + defer res.Body.Close() + + require.Equal(t, http.StatusFound, res.StatusCode) + loc := res.Header.Get("Location") + require.True(t, strings.HasPrefix(loc, "/auth/reset-password?")) + require.True(t, strings.Contains(loc, "id=")) + require.True(t, strings.Contains(loc, "token=")) +} diff --git a/starter/embed/default-conf.yaml b/starter/embed/default-conf.yaml new file mode 100644 index 000000000..d2ea0b91f --- /dev/null +++ b/starter/embed/default-conf.yaml @@ -0,0 +1,43 @@ + +database: + dsn: "" + debug: false + tracing: + excludeQuery: false + excludeQueryVars: false + maxIdleConns: 20 + maxOpenConns: 200 + connMaxLifetime: "30m" + connMaxIdleTime: "10m" + authMethod: "password" # password, iam + +prefix: "" +s3: + accessID: "" # AWS access ID, if not provided, will use AWS default config + accessKey: "" # AWS access key, if not provided, will use AWS default config + bucket: "" + region: "" + endpoint: "" +s3Publish: + accessID: "" # AWS access ID, if not provided, will use AWS default config + accessKey: "" # AWS access key, if not provided, will use AWS default config + bucket: "" + region: "" + endpoint: "" +auth: + secret: "test" + baseURL: "http://localhost:10001" + initialUserEmail: "qor@theplant.jp" + initialUserPassword: "admin123456789" + initialUserRole: "Admin" + googleClientKey: "" + googleClientSecret: "" + microsoftClientKey: "" + microsoftClientSecret: "ts" + githubClientKey: "" + githubClientSecret: "ts" + enableRecaptcha: false + recaptchaSiteKey: "" + recaptchaSecretKey: "" + maxRetryCount: 5 + enableTOTP: false diff --git a/starter/embed/favicon.ico b/starter/embed/favicon.ico new file mode 100644 index 000000000..a9840a5b4 Binary files /dev/null and b/starter/embed/favicon.ico differ diff --git a/starter/handler.go b/starter/handler.go new file mode 100644 index 000000000..4e4a74dbf --- /dev/null +++ b/starter/handler.go @@ -0,0 +1,316 @@ +package starter + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + "sync" + + "github.com/pkg/errors" + "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/l10n" + "github.com/qor5/admin/v3/media" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/admin/v3/presets/gorm2op" + "github.com/qor5/admin/v3/role" + "github.com/qor5/admin/v3/utils" + "github.com/qor5/confx" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/hook" + "github.com/qor5/x/v3/login" + "github.com/qor5/x/v3/perm" + "github.com/qor5/x/v3/s3x" + "github.com/theplant/inject" + "golang.org/x/text/language" + "gorm.io/gorm" + + _ "embed" + + plogin "github.com/qor5/admin/v3/login" + media_oss "github.com/qor5/admin/v3/media/oss" + v "github.com/qor5/x/v3/ui/vuetify" + h "github.com/theplant/htmlgo" +) + +// Config contains all dependencies needed for Handler +type Config struct { + DB *gorm.DB `inject:"" confx:"-"` + + Prefix string `confx:"prefix" validate:"omitempty"` + S3 s3x.Config `confx:"s3"` + S3Publish s3x.Config `confx:"s3Publish"` + Auth AuthConfig `confx:"auth"` +} + +// Handler handles admin with embedded configuration +type Handler struct { + *Config + *inject.Injector + + plugins []presets.Plugin + handlerHook hook.Hook[http.Handler] + warmupOnce sync.Once + handler http.Handler +} + +// NewHandler creates a new Handler with the provided configuration +func NewHandler(cfg *Config) *Handler { + cfg.Prefix = strings.TrimRight(cfg.Prefix, "/") + handler := &Handler{ + Config: cfg, + Injector: inject.New(), + } + _ = handler.Provide(func() *Handler { return handler }) + return handler +} + +// Build initializes all components and sets up the admin interface +func (a *Handler) Build(ctx context.Context, ctors ...any) error { + if err := a.Provide( + a.createPresetsBuilder, + a.createActivityBuilder, + a.createMediaBuilder, + a.createL10nBuilder, + a.createRoleBuilder, + a.createLoginBuilder, + a.createLoginSessionBuilder, + a.createProfileBuilder, + a.createUserModelBuilder, + a.createMux, + ); err != nil { + return err + } + + if len(ctors) > 0 { + if err := a.Provide(ctors...); err != nil { + return err + } + } + + if err := a.ApplyContext(ctx, a.Config); err != nil { + return err + } + + if err := a.autoMigrate(ctx); err != nil { + return err + } + + a.configureMediaStorage() + + if err := a.BuildContext(ctx); err != nil { + return err + } + + if a.Auth.InitialUserEmail != "" && a.Auth.InitialUserPassword != "" && a.Auth.InitialUserRole != "" { + if _, err := createInitialUserIfEmpty(ctx, a.DB, &UpsertUserOptions{ + Email: a.Auth.InitialUserEmail, + Password: a.Auth.InitialUserPassword, + Role: []string{a.Auth.InitialUserRole}, + }); err != nil { + return err + } + } + + return nil +} + +func (a *Handler) WithHandlerHook(hooks ...hook.Hook[http.Handler]) *Handler { + a.handlerHook = hook.Prepend(a.handlerHook, hooks...) + return a +} + +func (a *Handler) Use(plugins ...presets.Plugin) { + a.plugins = append(a.plugins, plugins...) +} + +// autoMigrate performs database migrations +func (a *Handler) autoMigrate(ctx context.Context) error { + db := a.DB.WithContext(ctx) + if err := db.AutoMigrate( + &role.Role{}, + &User{}, + &perm.DefaultDBPolicy{}, + ); err != nil { + return errors.Wrap(err, "failed to auto migrate database") + } + + if err := createDefaultRolesIfEmpty(ctx, db); err != nil { + return errors.Wrap(err, "failed to initialize default roles") + } + return nil +} + +// configureMediaStorage configures S3 storage for media +func (a *Handler) configureMediaStorage() { + media_oss.Storage = s3x.SetupClient(&a.S3, nil) +} + +// createActivityBuilder creates and configures the activity builder +func (a *Handler) createActivityBuilder() *activity.Builder { + activityBuilder := activity.New(a.DB, func(ctx context.Context) (*activity.User, error) { + u := ctx.Value(login.UserKey).(*User) + return &activity.User{ + ID: fmt.Sprint(u.ID), + Name: u.Name, + Avatar: "", + }, nil + }).WrapLogModelInstall(func(in presets.ModelInstallFunc) presets.ModelInstallFunc { + return func(pb *presets.Builder, mb *presets.ModelBuilder) (err error) { + err = in(pb, mb) + if err != nil { + return + } + mb.Listing().WrapSearchFunc(func(in presets.SearchFunc) presets.SearchFunc { + return func(ctx *web.EventContext, params *presets.SearchParams) (result *presets.SearchResult, err error) { + u := GetCurrentUser(ctx.R) + if rs := u.GetRoles(); !slices.Contains(rs, RoleAdmin) { + params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{ + Query: "user_id = ?", + Args: []any{fmt.Sprint(u.ID)}, + }) + } + return in(ctx, params) + } + }) + return + } + }).AutoMigrate() + + a.Use(activityBuilder) + return activityBuilder +} + +// createPresetsBuilder creates and configures the main presets builder +func (a *Handler) createPresetsBuilder() *presets.Builder { + presetsBuilder := presets.New(). + URIPrefix(a.Prefix). + DataOperator(gorm2op.DataOperator(a.DB)) + + // Configure basic UI + presetsBuilder.BrandFunc(func(_ *web.EventContext) h.HTMLComponent { + logo := "https://qor5.com/img/qor-logo.png" + return h.Div( + v.VRow( + v.VCol(h.A(h.Img(logo).Attr("width", "80")).Href("/")), + ), + ).Class("mb-n4 mt-n2") + }).HomePageFunc(func(_ *web.EventContext) (r web.PageResponse, err error) { + r.PageTitle = "Home" + r.Body = h.H1("Home") + return + }).NotFoundPageLayoutConfig(&presets.LayoutConfig{ + NotificationCenterInvisible: true, + }).RightDrawerWidth("700") + + a.configurePermission(presetsBuilder) + a.configureI18n(presetsBuilder) + return presetsBuilder +} + +// configureI18n configures i18n support +func (a *Handler) configureI18n(presetsBuilder *presets.Builder) { + utils.Install(presetsBuilder) + presetsBuilder.GetI18n(). + SupportLanguages(language.English, language.SimplifiedChinese, language.Japanese). + RegisterForModule(language.English, presets.ModelsI18nModuleKey, Messages_en_US_ModelsI18nModuleKey). + RegisterForModule(language.SimplifiedChinese, presets.ModelsI18nModuleKey, Messages_zh_CN_ModelsI18nModuleKey). + RegisterForModule(language.Japanese, presets.ModelsI18nModuleKey, Messages_ja_JP_ModelsI18nModuleKey). + RegisterForModule(language.English, I18nDemoKey, Messages_en_US). + RegisterForModule(language.Japanese, I18nDemoKey, Messages_ja_JP). + RegisterForModule(language.SimplifiedChinese, I18nDemoKey, Messages_zh_CN). + GetSupportLanguagesFromRequestFunc(func(_ *http.Request) []language.Tag { + return presetsBuilder.GetI18n().GetSupportLanguages() + }) +} + +// createMediaBuilder creates and configures the media builder +func (a *Handler) createMediaBuilder() *media.Builder { + mediaBuilder := media.New(a.DB).CurrentUserID(func(ctx *web.EventContext) (id uint) { + u := GetCurrentUser(ctx.R) + if u == nil { + return + } + return u.ID + }).Searcher(func(db *gorm.DB, ctx *web.EventContext) *gorm.DB { + u := GetCurrentUser(ctx.R) + if u == nil { + return db + } + if rs := u.GetRoles(); !slices.Contains(rs, RoleAdmin) && !slices.Contains(rs, RoleManager) { + return db.Where("user_id = ?", u.ID) + } + return db + }).AutoMigrate() + + a.Use(mediaBuilder) + return mediaBuilder +} + +// createL10nBuilder creates and configures the localization builder +func (a *Handler) createL10nBuilder(activityBuilder *activity.Builder) *l10n.Builder { + l10nBuilder := l10n.New(a.DB). + Activity(activityBuilder). + RegisterLocales("China", "cn", "China", l10n.ChinaSvg). + RegisterLocales("Japan", "jp", "Japan", l10n.JapanSvg) + + l10nBuilder.SupportLocalesFunc(func(_ *http.Request) []string { + return l10nBuilder.GetSupportLocaleCodes() + }) + + a.Use(l10nBuilder) + return l10nBuilder +} + +//go:embed embed/favicon.ico +var favicon []byte + +func (a *Handler) createMux(presetsBuilder *presets.Builder, loginSessionBuilder *plogin.SessionBuilder) *http.ServeMux { + mux := http.NewServeMux() + loginSessionBuilder.Mount(mux) + mux.Handle("/", presetsBuilder) + mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(favicon) + }) + return mux +} + +// ServeHTTP implements http.Handler interface +func (a *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.warmupOnce.Do(func() { + if _, err := a.Invoke(func(mux *http.ServeMux, presetsBuilder *presets.Builder, loginSessionBuilder *plogin.SessionBuilder) { + presetsBuilder.Use(a.plugins...) + presetsBuilder.Build() + + handlerHook := hook.Chain( + loginSessionBuilder.Middleware(), + withRoles(a.DB), + securityMiddleware(), + ) + if a.handlerHook != nil { + handlerHook = hook.Prepend(handlerHook, a.handlerHook) + } + a.handler = handlerHook(mux) + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + if a.handler == nil { + http.Error(w, "handler not initialized", http.StatusInternalServerError) + return + } + a.handler.ServeHTTP(w, r) +} + +//go:embed embed/default-conf.yaml +var defaultConfigYAML string + +func InitializeConfig(opts ...confx.Option) (confx.Loader[*Config], error) { + def, err := confx.Read[*Config]("yaml", strings.NewReader(defaultConfigYAML)) + if err != nil { + return nil, errors.Wrap(err, "failed to load default config from embedded YAML") + } + return confx.Initialize(def, opts...) +} diff --git a/starter/messages.go b/starter/messages.go new file mode 100644 index 000000000..6e0bc34d8 --- /dev/null +++ b/starter/messages.go @@ -0,0 +1,732 @@ +package starter + +import ( + "github.com/qor5/x/v3/i18n" +) + +const I18nDemoKey i18n.ModuleKey = "I18nDemoKey" + +type Messages struct { + FilterTabsAll string + FilterTabsHasUnreadNotes string + FilterTabsActive string + DemoTips string + DemoUsernameLabel string + DemoPasswordLabel string + LoginProviderGoogleText string + LoginProviderMicrosoftText string + LoginProviderGithubText string + OAuthCompleteInfoTitle string + OAuthCompleteInfoPositionLabel string + OAuthCompleteInfoAgreeLabel string + OAuthCompleteInfoBackLabel string + Demo string + DBResetTipLabel string + Name string + Email string + Company string + Role string + Status string + ChangePassword string + LoginSessions string + SystemTitleLabel string +} + +var Messages_en_US = &Messages{ + FilterTabsAll: "All", + FilterTabsHasUnreadNotes: "Has Unread Notes", + FilterTabsActive: "Active", + DemoTips: "Please note that the database would be reset every even hour.", + DemoUsernameLabel: "Demo Username: ", + DemoPasswordLabel: "Demo Password: ", + LoginProviderGoogleText: "Login with Google", + LoginProviderMicrosoftText: "Login with Microsoft", + LoginProviderGithubText: "Login with Github", + OAuthCompleteInfoTitle: "Complete your information", + OAuthCompleteInfoPositionLabel: "Position(Optional)", + OAuthCompleteInfoAgreeLabel: "Subscribe to QOR5 newsletter(Optional)", + OAuthCompleteInfoBackLabel: "Back to login", + Demo: "DEMO", + DBResetTipLabel: "Database reset countdown", + Name: "Name", + Email: "Email", + Company: "Company", + Role: "Role", + Status: "Status", + ChangePassword: "Change Password", + LoginSessions: "Login Sessions", + SystemTitleLabel: "Adex System", +} + +var Messages_ja_JP = &Messages{ + FilterTabsAll: "すべて", + FilterTabsHasUnreadNotes: "未読のノートがあります", + FilterTabsActive: "有効", + DemoTips: "データベースは偶数時間ごとにリセットされることに注意してください。", + DemoUsernameLabel: "デモのユーザー名: ", + DemoPasswordLabel: "デモパスワード: ", + LoginProviderGoogleText: "Googleでログイン", + LoginProviderMicrosoftText: "Microsoftでログイン", + LoginProviderGithubText: "Githubでログイン", + OAuthCompleteInfoTitle: "情報を入力してください", + OAuthCompleteInfoPositionLabel: "役職(任意)", + OAuthCompleteInfoAgreeLabel: "QOR5ニュースレターを購読する(任意)", + OAuthCompleteInfoBackLabel: "ログインに戻る", + Demo: "デモ", + DBResetTipLabel: "データベースリセットのカウントダウン", + Name: "名前", + Email: "メール", + Company: "会社", + Role: "役割", + Status: "ステータス", + ChangePassword: "パスワードを変更する", + LoginSessions: "ログインセッション", + SystemTitleLabel: "Adex システム", +} + +var Messages_zh_CN = &Messages{ + FilterTabsAll: "全部", + FilterTabsHasUnreadNotes: "未读备注", + FilterTabsActive: "有效", + DemoTips: "请注意,数据库将每隔偶数小时重置一次。", + DemoUsernameLabel: "演示账户:", + DemoPasswordLabel: "演示密码:", + LoginProviderGoogleText: "使用Google登录", + LoginProviderMicrosoftText: "使用Microsoft登录", + LoginProviderGithubText: "使用Github登录", + OAuthCompleteInfoTitle: "请填写您的信息", + OAuthCompleteInfoPositionLabel: "职位(可选)", + OAuthCompleteInfoAgreeLabel: "订阅QOR5新闻(可选)", + OAuthCompleteInfoBackLabel: "返回登录", + Demo: "演示", + DBResetTipLabel: "数据库重置倒计时", + Name: "姓名", + Email: "邮箱", + Company: "公司", + Role: "角色", + Status: "状态", + ChangePassword: "修改密码", + LoginSessions: "登录会话", + SystemTitleLabel: "Adex 系统", +} + +type Messages_ModelsI18nModuleKey struct { + Admin string + QOR5Example string + Roles string + Users string + + // TODO: @iBakuman 有很多是不需要的 + Posts string + PostsID string + PostsTitle string + PostsHeroImage string + PostsBody string + Example string + Settings string + Post string + PostsBodyImage string + + SeoPost string + SeoVariableTitle string + SeoVariableSiteName string + + PageBuilder string + Pages string + SharedContainers string + DemoContainers string + Templates string + PageCategories string + ECManagement string + ECDashboard string + Orders string + InputDemos string + Products string + NestedFieldDemos string + SiteManagement string + SEO string + UserManagement string + Profile string + FeaturedModelsManagement string + Customers string + ListModels string + MicrositeModels string + Workers string + MediaLibrary string + + PagesID string + PagesTitle string + PagesSlug string + PagesLocale string + PagesNotes string + PagesDraftCount string + PagesPath string + PagesOnline string + PagesVersion string + PagesVersions string + PagesStartAt string + PagesEndAt string + PagesOption string + PagesLive string + + Page string + PagesStatus string + PagesSchedule string + PagesCategoryID string + PagesTemplateSelection string + PagesEditContainer string + + WebHeader string + WebHeadersColor string + Header string + + WebFooter string + WebFootersEnglishUrl string + WebFootersJapaneseUrl string + Footer string + + VideoBanner string + VideoBannersAddTopSpace string + VideoBannersAddBottomSpace string + VideoBannersAnchorID string + VideoBannersVideo string + VideoBannersBackgroundVideo string + VideoBannersMobileBackgroundVideo string + VideoBannersVideoCover string + VideoBannersMobileVideoCover string + VideoBannersHeading string + VideoBannersPopupText string + VideoBannersText string + VideoBannersLinkText string + VideoBannersLink string + + Heading string + HeadingsAddTopSpace string + HeadingsAddBottomSpace string + HeadingsAnchorID string + HeadingsHeading string + HeadingsFontColor string + HeadingsBackgroundColor string + HeadingsLink string + HeadingsLinkText string + HeadingsLinkDisplayOption string + HeadingsText string + + BrandGrid string + BrandGridsAddTopSpace string + BrandGridsAddBottomSpace string + BrandGridsAnchorID string + BrandGridsBrands string + + ListContent string + ListContentsAddTopSpace string + ListContentsAddBottomSpace string + ListContentsAnchorID string + ListContentsBackgroundColor string + ListContentsItems string + ListContentsLink string + ListContentsLinkText string + ListContentsLinkDisplayOption string + + ImageContainer string + ImageContainersAddTopSpace string + ImageContainersAddBottomSpace string + ImageContainersAnchorID string + ImageContainersBackgroundColor string + ImageContainersTransitionBackgroundColor string + ImageContainersImage string + Image string + + InNumber string + InNumbersAddTopSpace string + InNumbersAddBottomSpace string + InNumbersAnchorID string + InNumbersHeading string + InNumbersItems string + InNumbers string + + ContactForm string + ContactFormsAddTopSpace string + ContactFormsAddBottomSpace string + ContactFormsAnchorID string + ContactFormsHeading string + ContactFormsText string + ContactFormsSendButtonText string + ContactFormsFormButtonText string + ContactFormsMessagePlaceholder string + ContactFormsNamePlaceholder string + ContactFormsEmailPlaceholder string + ContactFormsThankyouMessage string + ContactFormsActionUrl string + ContactFormsPrivacyPolicy string + + ActivityActionLogIn string + ActivityActionExtendSession string + + PagesPage string +} + +var Messages_en_US_ModelsI18nModuleKey = &Messages_ModelsI18nModuleKey{ + Posts: "Posts", + PostsID: "ID", + PostsTitle: "Title", + PostsHeroImage: "Hero Image", + PostsBody: "Content", + Example: "QOR5 Demo", + Settings: "SEO Settings", + Post: "Post", + PostsBodyImage: "Content Image", + + SeoPost: "Post", + SeoVariableTitle: "Title", + SeoVariableSiteName: "Site Name", + + Admin: "Admin", + QOR5Example: "QOR5 Example", + Roles: "Role Management", + Users: "User Management", + + PageBuilder: "Page Builder", + Pages: "Pages", + SharedContainers: "Shared Containers", + DemoContainers: "Demo Containers", + Templates: "Templates", + PageCategories: "Page Categories", + ECManagement: "E-commerce Management", + ECDashboard: "E-commerce Dashboard", + Orders: "Orders", + InputDemos: "Input Demos", + Products: "Products", + NestedFieldDemos: "Nested Field Demos", + SiteManagement: "Site Management", + SEO: "SEO", + UserManagement: "User Management", + Profile: "Profile", + FeaturedModelsManagement: "Featured Models Management", + Customers: "Customers", + ListModels: "List Models", + MicrositeModels: "Microsite Models", + Workers: "Workers", + MediaLibrary: "Media Library", + + PagesID: "ID", + PagesTitle: "Title", + PagesSlug: "Slug", + PagesLocale: "Locale", + PagesNotes: "Notes", + PagesDraftCount: "Draft Count", + PagesPath: "Path", + PagesOnline: "Online", + PagesVersion: "Version", + PagesVersions: "Versions", + PagesStartAt: "Start At", + PagesEndAt: "End At", + PagesOption: "Option", + PagesLive: "Live Status", + + Page: "Page", + PagesStatus: "Status", + PagesSchedule: "Schedule", + PagesCategoryID: "Category ID", + PagesTemplateSelection: "Template Selection", + PagesEditContainer: "Edit Container", + + WebHeader: "Web Header", + WebHeadersColor: "Color", + Header: "Header", + + WebFooter: "Web Footer", + WebFootersEnglishUrl: "English URL", + WebFootersJapaneseUrl: "Japanese URL", + Footer: "Footer", + + VideoBanner: "Video Banner", + VideoBannersAddTopSpace: "Add Top Space", + VideoBannersAddBottomSpace: "Add Bottom Space", + VideoBannersAnchorID: "Anchor ID", + VideoBannersVideo: "Video", + VideoBannersBackgroundVideo: "Background Video", + VideoBannersMobileBackgroundVideo: "Mobile Background Video", + VideoBannersVideoCover: "Video Cover", + VideoBannersMobileVideoCover: "Mobile Video Cover", + VideoBannersHeading: "Heading", + VideoBannersPopupText: "Popup Text", + VideoBannersText: "Text", + VideoBannersLinkText: "Link Text", + VideoBannersLink: "Link", + + Heading: "Heading", + HeadingsAddTopSpace: "Add Top Space", + HeadingsAddBottomSpace: "Add Bottom Space", + HeadingsAnchorID: "Anchor ID", + HeadingsHeading: "Heading", + HeadingsFontColor: "Font Color", + HeadingsBackgroundColor: "Background Color", + HeadingsLink: "Link", + HeadingsLinkText: "Link Text", + HeadingsLinkDisplayOption: "Link Display Option", + HeadingsText: "Text", + + BrandGrid: "Brand Grid", + BrandGridsAddTopSpace: "Add Top Space", + BrandGridsAddBottomSpace: "Add Bottom Space", + BrandGridsAnchorID: "Anchor ID", + BrandGridsBrands: "Brands", + + ListContent: "List Content", + ListContentsAddTopSpace: "Add Top Space", + ListContentsAddBottomSpace: "Add Bottom Space", + ListContentsAnchorID: "Anchor ID", + ListContentsBackgroundColor: "Background Color", + ListContentsItems: "Items", + ListContentsLink: "Link", + ListContentsLinkText: "Link Text", + ListContentsLinkDisplayOption: "Link Display Option", + + ImageContainer: "Image Container", + ImageContainersAddTopSpace: "Add Top Space", + ImageContainersAddBottomSpace: "Add Bottom Space", + ImageContainersAnchorID: "Anchor ID", + ImageContainersBackgroundColor: "Background Color", + ImageContainersTransitionBackgroundColor: "Transition Background Color", + ImageContainersImage: "Image", + Image: "Image", + + InNumber: "In Number", + InNumbersAddTopSpace: "Add Top Space", + InNumbersAddBottomSpace: "Add Bottom Space", + InNumbersAnchorID: "Anchor ID", + InNumbersHeading: "Heading", + InNumbersItems: "Items", + InNumbers: "In Numbers", + + ContactForm: "Contact Form", + ContactFormsAddTopSpace: "Add Top Space", + ContactFormsAddBottomSpace: "Add Bottom Space", + ContactFormsAnchorID: "Anchor ID", + ContactFormsHeading: "Heading", + ContactFormsText: "Text", + ContactFormsSendButtonText: "Send Button Text", + ContactFormsFormButtonText: "Form Button Text", + ContactFormsMessagePlaceholder: "Message Placeholder", + ContactFormsNamePlaceholder: "Name Placeholder", + ContactFormsEmailPlaceholder: "Email Placeholder", + ContactFormsThankyouMessage: "Thank You Message", + ContactFormsActionUrl: "Action URL", + ContactFormsPrivacyPolicy: "Privacy Policy", + + ActivityActionLogIn: "Log In", + ActivityActionExtendSession: "Extend Session", + + PagesPage: "Page", +} + +var Messages_zh_CN_ModelsI18nModuleKey = &Messages_ModelsI18nModuleKey{ + Posts: "帖子 示例", + PostsID: "ID", + PostsTitle: "标题", + PostsHeroImage: "主图", + PostsBody: "内容", + Example: "QOR5演示", + Settings: "SEO 设置", + Post: "帖子", + PostsBodyImage: "内容图片", + + SeoPost: "帖子", + SeoVariableTitle: "标题", + SeoVariableSiteName: "站点名称", + + Admin: "管理员", + QOR5Example: "QOR5 示例", + Roles: "权限管理", + Users: "用户管理", + + PageBuilder: "页面管理菜单", + Pages: "页面管理", + SharedContainers: "公用组件", + DemoContainers: "示例组件", + Templates: "模板页面", + PageCategories: "目录管理", + ECManagement: "电子商务管理", + ECDashboard: "电子商务仪表盘", + Orders: "订单管理", + InputDemos: "表单 示例", + Products: "产品管理", + NestedFieldDemos: "嵌套表单 示例", + SiteManagement: "站点管理菜单", + SEO: "SEO 管理", + UserManagement: "用户管理菜单", + Profile: "个人页面", + FeaturedModelsManagement: "特色模块管理菜单", + Customers: "Customers 示例", + ListModels: "发布带排序及分页模块 示例", + MicrositeModels: "Microsite 示例", + Workers: "后台工作进程管理", + MediaLibrary: "媒体库", + + PagesID: "ID", + PagesTitle: "标题", + PagesSlug: "Slug", + PagesLocale: "地区", + PagesNotes: "备注", + PagesDraftCount: "草稿数", + PagesPath: "路径", + PagesOnline: "在线", + PagesVersion: "版本", + PagesVersions: "版本", + PagesStartAt: "开始时间", + PagesEndAt: "结束时间", + PagesOption: "选项", + PagesLive: "发布状态", + + Page: "Page", + PagesStatus: "状态", + PagesSchedule: "PagesSchedule", + PagesCategoryID: "PagesCategoryID", + PagesTemplateSelection: "PagesTemplateSelection", + PagesEditContainer: "PagesEditContainer", + + WebHeader: "WebHeader", + WebHeadersColor: "WebHeadersColor", + Header: "Header", + + WebFooter: "WebFooter", + WebFootersEnglishUrl: "WebFootersEnglishUrl", + WebFootersJapaneseUrl: "WebFootersJapaneseUrl", + Footer: "Footer", + + VideoBanner: "VideoBanner", + VideoBannersAddTopSpace: "VideoBannersAddTopSpace", + VideoBannersAddBottomSpace: "VideoBannersAddBottomSpace", + VideoBannersAnchorID: "VideoBannersAnchorID", + VideoBannersVideo: "VideoBannersVideo", + VideoBannersBackgroundVideo: "VideoBannersBackgroundVideo", + VideoBannersMobileBackgroundVideo: "VideoBannersMobileBackgroundVideo", + VideoBannersVideoCover: "VideoBannersVideoCover", + VideoBannersMobileVideoCover: "VideoBannersMobileVideoCover", + VideoBannersHeading: "VideoBannersHeading", + VideoBannersPopupText: "VideoBannersPopupText", + VideoBannersText: "VideoBannersText", + VideoBannersLinkText: "VideoBannersLinkText", + VideoBannersLink: "VideoBannersLink", + + Heading: "Heading", + HeadingsAddTopSpace: "HeadingsAddTopSpace", + HeadingsAddBottomSpace: "HeadingsAddBottomSpace", + HeadingsAnchorID: "HeadingsAnchorID", + HeadingsHeading: "HeadingsHeading", + HeadingsFontColor: "HeadingsFontColor", + HeadingsBackgroundColor: "HeadingsBackgroundColor", + HeadingsLink: "HeadingsLink", + HeadingsLinkText: "HeadingsLinkText", + HeadingsLinkDisplayOption: "HeadingsLinkDisplayOption", + HeadingsText: "HeadingsText", + + BrandGrid: "BrandGrid", + BrandGridsAddTopSpace: "BrandGridsAddTopSpace", + BrandGridsAddBottomSpace: "BrandGridsAddBottomSpace", + BrandGridsAnchorID: "BrandGridsAnchorID", + BrandGridsBrands: "BrandGridsBrands", + + ListContent: "ListContent", + ListContentsAddTopSpace: "ListContentsAddTopSpace", + ListContentsAddBottomSpace: "ListContentsAddBottomSpace", + ListContentsAnchorID: "ListContentsAnchorID", + ListContentsBackgroundColor: "ListContentsBackgroundColor", + ListContentsItems: "ListContentsItems", + ListContentsLink: "ListContentsLink", + ListContentsLinkText: "ListContentsLinkText", + ListContentsLinkDisplayOption: "ListContentsLinkDisplayOption", + + ImageContainer: "ImageContainer", + ImageContainersAddTopSpace: "ImageContainersAddTopSpace", + ImageContainersAddBottomSpace: "ImageContainersAddBottomSpace", + ImageContainersAnchorID: "ImageContainersAnchorID", + ImageContainersBackgroundColor: "ImageContainersBackgroundColor", + ImageContainersTransitionBackgroundColor: "ImageContainersTransitionBackgroundColor", + ImageContainersImage: "ImageContainersImage", + Image: "Image", + + InNumber: "InNumber", + InNumbersAddTopSpace: "InNumbersAddTopSpace", + InNumbersAddBottomSpace: "InNumbersAddBottomSpace", + InNumbersAnchorID: "InNumbersAnchorID", + InNumbersHeading: "InNumbersHeading", + InNumbersItems: "InNumbersItems", + InNumbers: "InNumbers", + + ContactForm: "ContactForm", + ContactFormsAddTopSpace: "ContactFormsAddTopSpace", + ContactFormsAddBottomSpace: "ContactFormsAddBottomSpace", + ContactFormsAnchorID: "ContactFormsAnchorID", + ContactFormsHeading: "ContactFormsHeading", + ContactFormsText: "ContactFormsText", + ContactFormsSendButtonText: "ContactFormsSendButtonText", + ContactFormsFormButtonText: "ContactFormsFormButtonText", + ContactFormsMessagePlaceholder: "ContactFormsMessagePlaceholder", + ContactFormsNamePlaceholder: "ContactFormsNamePlaceholder", + ContactFormsEmailPlaceholder: "ContactFormsEmailPlaceholder", + ContactFormsThankyouMessage: "ContactFormsThankyouMessage", + ContactFormsActionUrl: "ContactFormsActionUrl", + ContactFormsPrivacyPolicy: "ContactFormsPrivacyPolicy", + + ActivityActionLogIn: "登录", + ActivityActionExtendSession: "延长会话", + + PagesPage: "Page", +} + +var Messages_ja_JP_ModelsI18nModuleKey = &Messages_ModelsI18nModuleKey{ + Posts: "投稿", + PostsID: "投稿ID", + PostsTitle: "投稿タイトル", + PostsHeroImage: "メイン画像", + PostsBody: "コンテンツ", + Example: "QOR5サンプル", + Settings: "設定", + Post: "投稿", + PostsBodyImage: "内容イメージ", + + SeoPost: "SEO 投稿", + SeoVariableTitle: "SEO タイトル", + SeoVariableSiteName: "SEO サイト名", + + Admin: "管理員", + QOR5Example: "QOR5サンプル", + Roles: "ユーザー権限", + Users: "ユーザー", + + PageBuilder: "ページビルダー", + Pages: "ページ", + SharedContainers: "共有コンテナ", + DemoContainers: "デモ用コン店た", + Templates: "テンプレート", + PageCategories: "カテゴリー", + ECManagement: "ECマネジメント", + ECDashboard: "ECダッシュボード", + Orders: "注文", + InputDemos: "入力デモ", + Products: "製品", + SiteManagement: "サイト管理", + NestedFieldDemos: "ネストフィールドデモ", + SEO: "SEO", + UserManagement: "ユーザー管理", + Profile: "プロフィール", + FeaturedModelsManagement: "モデル管理", + Customers: "お客さま", + ListModels: "リストモデル", + MicrositeModels: "マイクロサイトモデル", + Workers: "ワーカーズ", + MediaLibrary: "メディアライブラリ", + + PagesID: "ID", + PagesTitle: "タイトル", + PagesSlug: "スラッグ", + PagesLocale: "ローカル", + PagesNotes: "ノート", + PagesDraftCount: "カウント下書き", + PagesPath: "パス", + PagesOnline: "オンライン", + PagesVersion: "バージョン", + PagesVersions: "バージョン", + PagesStartAt: "開始日時", + PagesEndAt: "終了日時", + PagesOption: "オプション", + PagesLive: "ライブ", + + Page: "ページ", + PagesStatus: "状態", + PagesSchedule: "スケジュール", + PagesCategoryID: "カテゴリーID", + PagesTemplateSelection: "テンプレート選択", + PagesEditContainer: "コンテナ編集", + + WebHeader: "ウェブヘッダー", + WebHeadersColor: "カラー", + Header: "ヘッダー", + + WebFooter: "ウェブ用フッター", + WebFootersEnglishUrl: "英語用URL", + WebFootersJapaneseUrl: "日本語用URL", + Footer: "フッター", + + VideoBanner: "動画バナー", + VideoBannersAddTopSpace: "上方に空白を追加", + VideoBannersAddBottomSpace: "下方に空白を追加", + VideoBannersAnchorID: "アンカーID", + VideoBannersVideo: "動画", + VideoBannersBackgroundVideo: "背景動画", + VideoBannersMobileBackgroundVideo: "モバイル用背景動画", + VideoBannersVideoCover: "動画カバー", + VideoBannersMobileVideoCover: "モバイル用動画カバー", + VideoBannersHeading: "ヘディング", + VideoBannersPopupText: "ポップアップ用テキスト", + VideoBannersText: "テキスト", + VideoBannersLinkText: "リンクテキスト", + VideoBannersLink: "リンク", + + Heading: "ヘディング", + HeadingsAddTopSpace: "上方に空白を追加", + HeadingsAddBottomSpace: "下方に空白を追加", + HeadingsAnchorID: "アンカーID", + HeadingsHeading: "ヘディング", + HeadingsFontColor: "フォント色", + HeadingsBackgroundColor: "背景色", + HeadingsLink: "リンク", + HeadingsLinkText: "リンクテキスト", + HeadingsLinkDisplayOption: "リンク表示オプション", + HeadingsText: "テキスト", + + BrandGrid: "ブランドグリッド", + BrandGridsAddTopSpace: "上方に空白を追加", + BrandGridsAddBottomSpace: "下方に空白を追加", + BrandGridsAnchorID: "アンカーID", + BrandGridsBrands: "ブランド", + + ListContent: "リストコンテンツ", + ListContentsAddTopSpace: "上方に空白を追加", + ListContentsAddBottomSpace: "下方に空白を追加", + ListContentsAnchorID: "アンカーID", + ListContentsBackgroundColor: "背景色", + ListContentsItems: "アイテム", + ListContentsLink: "リンク", + ListContentsLinkText: "リンクテキスト", + ListContentsLinkDisplayOption: "リンク表示オプション", + + ImageContainer: "画像コンテナ", + ImageContainersAddTopSpace: "上方に空白を追加", + ImageContainersAddBottomSpace: "ボタン用空白追加", + ImageContainersAnchorID: "アンカーID", + ImageContainersBackgroundColor: "背景色", + ImageContainersTransitionBackgroundColor: "背景色変更", + ImageContainersImage: "画像", + Image: "画像", + + InNumber: "数字", + InNumbersAddTopSpace: "上方に空白を追加", + InNumbersAddBottomSpace: "下方に空白を追加", + InNumbersAnchorID: "アンカーID", + InNumbersHeading: "ヘディング", + InNumbersItems: "アイテム", + InNumbers: "数字", + + ContactForm: "お問合せフォーム", + ContactFormsAddTopSpace: "上方に空白を追加", + ContactFormsAddBottomSpace: "下方に空白を追加", + ContactFormsAnchorID: "アンカーID", + ContactFormsHeading: "ヘディング", + ContactFormsText: "テキスト", + ContactFormsSendButtonText: "送信ボタン用テキスト", + ContactFormsFormButtonText: "ウェブフォームボタン用テキスト", + ContactFormsMessagePlaceholder: "メッセージ", + ContactFormsNamePlaceholder: "名前", + ContactFormsEmailPlaceholder: "メールアドレス", + ContactFormsThankyouMessage: "サンキューメッセージ", + ContactFormsActionUrl: "アクションURL", + ContactFormsPrivacyPolicy: "プライバシーポリシー", + + ActivityActionLogIn: "ログイン", + ActivityActionExtendSession: "セッション延長", + + PagesPage: "ページ", +} diff --git a/starter/middlewares.go b/starter/middlewares.go new file mode 100644 index 000000000..c79edcdf8 --- /dev/null +++ b/starter/middlewares.go @@ -0,0 +1,45 @@ +package starter + +import ( + "net/http" + + "github.com/qor5/admin/v3/role" + "gorm.io/gorm" +) + +func withRoles(db *gorm.DB) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := GetCurrentUser(r) + if u == nil { + next.ServeHTTP(w, r) + return + } + + var roleIDs []uint + if err := db.Table("user_role_join").Select("role_id").Where("user_id=?", u.ID).Scan(&roleIDs).Error; err != nil { + panic(err) + } + if len(roleIDs) > 0 { + var roles []role.Role + if err := db.Where("id in (?)", roleIDs).Find(&roles).Error; err != nil { + panic(err) + } + u.Roles = roles + } + next.ServeHTTP(w, r) + }) + } +} + +func securityMiddleware() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Add("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + w.Header().Add("Cache-control", "no-cache, no-store, max-age=0, must-revalidate") + w.Header().Add("Pragma", "no-cache") + + next.ServeHTTP(w, req) + }) + } +} diff --git a/starter/model.go b/starter/model.go new file mode 100644 index 000000000..7108a216d --- /dev/null +++ b/starter/model.go @@ -0,0 +1,74 @@ +package starter + +import ( + "time" + + "github.com/qor5/admin/v3/role" + "github.com/qor5/x/v3/login" + "gorm.io/gorm" +) + +const ( + RoleAdmin = "Admin" + RoleManager = "Manager" + RoleEditor = "Editor" + RoleViewer = "Viewer" + + OAuthProviderGoogle = "google" + OAuthProviderMicrosoftOnline = "microsoftonline" + OAuthProviderGithub = "github" + + StatusActive = "active" + StatusInactive = "inactive" +) + +var DefaultRoles = []string{ + RoleAdmin, + RoleManager, + RoleEditor, + RoleViewer, +} + +var OAuthProviders = []string{ + OAuthProviderGoogle, + OAuthProviderMicrosoftOnline, + OAuthProviderGithub, +} + +type User struct { + gorm.Model + + Name string + Company string + Roles []role.Role `gorm:"many2many:user_role_join;"` + Status string + UpdatedAt time.Time + CreatedAt time.Time + RegistrationDate time.Time `gorm:"type:date"` + + login.UserPass + login.OAuthInfo + login.SessionSecure +} + +func (u User) GetName() string { + return u.Name +} + +func (u User) GetID() uint { + return u.ID +} + +func (u User) GetRoles() (rs []string) { + for _, r := range u.Roles { + rs = append(rs, r.Name) + } + if len(rs) == 0 { + rs = []string{RoleViewer} + } + return +} + +func (u User) IsOAuthUser() bool { + return u.OAuthProvider != "" && u.OAuthIdentifier != "" +} diff --git a/starter/pagebuilder.go b/starter/pagebuilder.go new file mode 100644 index 000000000..80f6df98e --- /dev/null +++ b/starter/pagebuilder.go @@ -0,0 +1,99 @@ +package starter + +import ( + "net/http" + "net/url" + + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/l10n" + "github.com/qor5/admin/v3/media" + "github.com/qor5/admin/v3/pagebuilder" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/admin/v3/publish" + "github.com/qor5/admin/v3/seo" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/i18n" + "github.com/qor5/x/v3/oss" + "github.com/qor5/x/v3/s3x" + + vx "github.com/qor5/x/v3/ui/vuetifyx" +) + +var SetupPageBuilderForHandler = []any{ + CreateSEOBuilder, + CreatePublishStorage, + CreatePublisher, + CreatePageBuilder, +} + +// CreateSEOBuilder creates and configures the SEO builder +func CreateSEOBuilder(a *Handler, l10nBuilder *l10n.Builder) *seo.Builder { + return seo.New(a.DB, seo.WithLocales(l10nBuilder.GetSupportLocaleCodes()...)).AutoMigrate() +} + +// CreatePublishStorage configures S3 storage for publishing +func CreatePublishStorage(a *Handler) oss.StorageInterface { + a.S3Publish.ACL = string(types.ObjectCannedACLBucketOwnerFullControl) + return s3x.SetupClient(&a.S3Publish, nil) +} + +// CreatePublisher creates and configures the publisher +func CreatePublisher(a *Handler, publishStorage oss.StorageInterface, l10nBuilder *l10n.Builder, activityBuilder *activity.Builder) *publish.Builder { + publisher := publish.New(a.DB, publishStorage). + ContextValueFuncs(l10nBuilder.ContextValueProvider). + Activity(activityBuilder) + + a.Use(publisher) + return publisher +} + +// CreatePageBuilder creates and configures the page builder +func CreatePageBuilder(a *Handler, presetsBuilder *presets.Builder, mediaBuilder *media.Builder, l10nBuilder *l10n.Builder, activityBuilder *activity.Builder, publisher *publish.Builder, seoBuilder *seo.Builder, mux *http.ServeMux) *pagebuilder.Builder { + pageBuilder := pagebuilder.New("/page_builder", a.DB, presetsBuilder). + Media(mediaBuilder). + L10n(l10nBuilder). + Activity(activityBuilder). + Publisher(publisher). + SEO(seoBuilder). + PreviewContainer(false). + WrapPageInstall(func(in presets.ModelInstallFunc) presets.ModelInstallFunc { + return func(pb *presets.Builder, pm *presets.ModelBuilder) (err error) { + err = in(pb, pm) + if err != nil { + return + } + pmListing := pm.Listing() + pmListing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { + item, err := activityBuilder.MustGetModelBuilder(pm).NewHasUnreadNotesFilterItem(ctx.R.Context(), "") + if err != nil { + panic(err) + } + return []*vx.FilterItem{item} + }) + + pmListing.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab { + msgr := i18n.MustGetModuleMessages(ctx.R, I18nDemoKey, Messages_en_US).(*Messages) + + tab, err := activityBuilder.MustGetModelBuilder(pm).NewHasUnreadNotesFilterTab(ctx.R.Context()) + if err != nil { + panic(err) + } + return []*presets.FilterTab{ + { + Label: msgr.FilterTabsAll, + ID: "all", + Query: url.Values{"all": []string{"1"}}, + }, + tab, + } + }) + return nil + } + }).AutoMigrate() + + mux.Handle("/page_builder/", pageBuilder) + + a.Use(pageBuilder) + return pageBuilder +} diff --git a/starter/pagebuilder_test.go b/starter/pagebuilder_test.go new file mode 100644 index 000000000..3a1524474 --- /dev/null +++ b/starter/pagebuilder_test.go @@ -0,0 +1,43 @@ +package starter_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/qor5/admin/v3/starter" + "github.com/qor5/web/v3/multipartestutils" + "github.com/qor5/x/v3/gormx" + "github.com/theplant/gofixtures" + "github.com/theplant/inject" +) + +var pageBuilderTestData = gofixtures.Data(gofixtures.Sql(` +INSERT INTO public.page_builder_pages (id, created_at, updated_at, deleted_at, title, slug, category_id, seo, status, online_url, scheduled_start_at, scheduled_end_at, actual_start_at, actual_end_at, version, version_name, parent_version, locale_code) VALUES + (10, '2024-05-21 01:54:45.280106 +00:00', '2024-05-21 01:54:57.983233 +00:00', null, '1234567', '/12313', 0, '{"Title":"{{Title}}default","EnabledCustomize":true}', 'draft', '', null, null, null, null, '2024-05-21-v01', '2024-05-21-v01', '', 'China'); +SELECT setval('page_builder_pages_id_seq', 10, true); +`, []string{"page_builder_pages"})) + +func TestPageBuilder(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + dbr, _ := db.DB() + cases := []multipartestutils.TestCase{ + { + Name: "Index Page", + Debug: true, + ReqFunc: func() *http.Request { + pageBuilderTestData.TruncatePut(dbr) + return httptest.NewRequest("GET", "/pages", nil) + }, + ExpectPageBodyContainsInOrder: []string{"1234567"}, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + multipartestutils.RunCase(t, c, env.handler) + }) + } +} diff --git a/starter/perm.go b/starter/perm.go new file mode 100644 index 000000000..843ef80b8 --- /dev/null +++ b/starter/perm.go @@ -0,0 +1,54 @@ +package starter + +import ( + "fmt" + "net/http" + + "github.com/ory/ladon" + "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/x/v3/perm" +) + +func (a *Handler) configurePermission(b *presets.Builder) { + perm.Verbose = true + + b.Permission( + perm.New().Policies( + perm.PolicyFor(perm.Anybody).WhoAre(perm.Allowed).ToDo(perm.Anything).On(perm.Anything), + perm.PolicyFor( + RoleViewer, + RoleEditor, + RoleManager, + ).WhoAre(perm.Denied).ToDo(presets.PermCreate, presets.PermUpdate, presets.PermDelete).On("*:roles:*", "*:users:*"), + perm.PolicyFor(RoleViewer).WhoAre(perm.Denied).ToDo(presets.PermCreate, presets.PermUpdate, presets.PermDelete).On(perm.Anything), + + perm.PolicyFor(RoleManager).WhoAre(perm.Denied).ToDo(perm.Anything). + On("*:activity_logs").On("*:activity_logs:*"). + Given(perm.Conditions{ + "is_authorized": &ladon.BooleanCondition{}, + }), + ).SubjectsFunc(func(r *http.Request) []string { + u := GetCurrentUser(r) + if u == nil { + return nil + } + return u.GetRoles() + }).ContextFunc(func(r *http.Request, objs []any) perm.Context { + c := make(perm.Context) + for _, obj := range objs { + // nolint:gocritic + switch v := obj.(type) { + case *activity.ActivityLog: + u := GetCurrentUser(r) + if fmt.Sprint(u.GetID()) == v.UserID { + c["is_authorized"] = true + } else { + c["is_authorized"] = false + } + } + } + return c + }).DBPolicy(perm.NewDBPolicy(a.DB)), + ) +} diff --git a/starter/provider.go b/starter/provider.go new file mode 100644 index 000000000..2989a3fc3 --- /dev/null +++ b/starter/provider.go @@ -0,0 +1,34 @@ +package starter + +import ( + "context" + "net/http" + + "github.com/theplant/inject" +) + +func setupHandlerFactory(build func(ctx context.Context, handler *Handler, ctors ...any) error, ctors ...any) func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) { + return func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) { + handler := NewHandler(conf) + if err := handler.SetParent(inj); err != nil { + return nil, err + } + if err := build(ctx, handler, ctors...); err != nil { + return nil, err + } + mux.Handle("/", handler) + return handler, nil + } +} + +func SetupHandlerFactory(ctors ...any) func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) { + return setupHandlerFactory(func(ctx context.Context, handler *Handler, ctors ...any) error { + return handler.Build(ctx, ctors...) + }, ctors...) +} + +func SetupTestHandlerFactory(ctors ...any) func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) { + return setupHandlerFactory(func(ctx context.Context, handler *Handler, ctors ...any) error { + return handler.BuildForTest(ctx, ctors...) + }, ctors...) +} diff --git a/starter/setup_test.go b/starter/setup_test.go new file mode 100644 index 000000000..fee1c572f --- /dev/null +++ b/starter/setup_test.go @@ -0,0 +1,79 @@ +package starter_test + +import ( + "context" + "net/http" + "testing" + + "github.com/qor5/admin/v3/starter" + "github.com/qor5/x/v3/gormx" + "github.com/stretchr/testify/require" + "github.com/theplant/inject" + "github.com/theplant/inject/lifecycle" + "gorm.io/gorm" + + _ "embed" +) + +func TestMain(m *testing.M) { + m.Run() +} + +func setupDummyUserOptions() *starter.UpsertUserOptions { + return &starter.UpsertUserOptions{ + Email: "test@example.com", + Password: "test123456789", + Role: []string{starter.RoleAdmin}, + } +} + +func setupTestConfig(ctx context.Context) (*starter.Config, error) { + loader, err := starter.InitializeConfig() + if err != nil { + return nil, err + } + conf, err := loader(ctx, "testdata/config.yaml") + if err != nil { + return nil, err + } + return conf, nil +} + +func setupTestHandlerFactory(ctors ...any) []any { + ctors = append(ctors, + setupDummyUserOptions, + ) + return []any{ + starter.SetupTestHandlerFactory(ctors...), + } +} + +type env struct { + lc *lifecycle.Lifecycle + handler *starter.Handler +} + +func newTestEnv(t *testing.T, ctors ...any) *env { + lc, err := lifecycle.Start(context.Background(), + lifecycle.SetupSignal, + gormx.SetupTestSuiteFactory(), + func(testSuite *gormx.TestSuite) *gorm.DB { + return testSuite.DB() + }, + setupTestConfig, + http.NewServeMux, + setupTestHandlerFactory(ctors...), + ) + require.NoError(t, err) + t.Cleanup(func() { + err := lc.Stop(context.Background()) + require.NoError(t, err) + }) + + handler := inject.MustResolve[*starter.Handler](lc) + + return &env{ + lc: lc, + handler: handler, + } +} diff --git a/starter/testdata/config.yaml b/starter/testdata/config.yaml new file mode 100644 index 000000000..fea010ec2 --- /dev/null +++ b/starter/testdata/config.yaml @@ -0,0 +1,20 @@ +database: + dsn: "placeholder" + +s3: + bucket: "qor5-test-example" + region: "ap-northeast-1" + endpoint: "https://cdn.qor5.theplant-dev.com" +s3Publish: + bucket: "qor5-test-example" + region: "ap-northeast-1" + endpoint: "https://cdn.qor5.theplant-dev.com" +auth: + secret: "test" + googleClientKey: "test" + googleClientSecret: "test" + microsoftClientKey: "test" + microsoftClientSecret: "test" + githubClientKey: "test" + githubClientSecret: "test" + diff --git a/starter/testhelper.go b/starter/testhelper.go new file mode 100644 index 000000000..408a7534b --- /dev/null +++ b/starter/testhelper.go @@ -0,0 +1,134 @@ +package starter + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/qor5/x/v3/login" + "github.com/theplant/inject" + + plogin "github.com/qor5/admin/v3/login" +) + +// signClaims signs JWT claims with the provided secret +func signClaims(claims jwt.Claims, secret string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + return "", errors.New("failed to sign claims") + } + return signed, nil +} + +func createDummyAuthCookies(ctx context.Context, user *User, secret string, sessionBuilder *plogin.SessionBuilder) ([]*http.Cookie, error) { + if user.GetSecure() == "" { + return nil, errors.New("secure salt is empty") + } + + cookies := make([]*http.Cookie, 0, 2) + + userID := fmt.Sprint(user.GetID()) + now := time.Now() + maxAge := 3600 + claims := login.UserClaims{ + UserID: userID, + PassUpdatedAt: user.GetPasswordUpdatedAt(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(maxAge) * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Subject: userID, + ID: uuid.New().String(), + }, + } + + tokenValue, err := signClaims(claims, secret) + if err != nil { + return nil, err + } + cookies = append(cookies, &http.Cookie{ + Name: "auth", + Value: tokenValue, + Path: "/", + MaxAge: 3600, + HttpOnly: true, + }) + + tokenValue, err = signClaims(claims.RegisteredClaims, secret+user.GetSecure()) + if err != nil { + return nil, err + } + cookies = append(cookies, &http.Cookie{ + Name: "qor5_auth_secure", + Value: tokenValue, + Path: "/", + MaxAge: 3600, + HttpOnly: true, + }) + + if sessionBuilder != nil { + r := httptest.NewRequest("GET", "/", nil).WithContext(ctx) + for _, cookie := range cookies { + r.AddCookie(cookie) + } + if err := sessionBuilder.CreateSession(r, userID); err != nil { + return nil, errors.Wrap(err, "failed to create session") + } + } + + return cookies, nil +} + +func (h *Handler) BuildForTest(ctx context.Context, ctors ...any) error { + if err := h.Build(ctx, ctors...); err != nil { + return err + } + + var upsertUserOpts *UpsertUserOptions + if err := h.ResolveContext(ctx, &upsertUserOpts); err != nil && !errors.Is(err, inject.ErrTypeNotProvided) { + return err + } + if upsertUserOpts == nil { + return nil + } + + var sessionBuilder *plogin.SessionBuilder + if err := h.ResolveContext(ctx, &sessionBuilder); err != nil { + return err + } + + user, err := UpsertUser(ctx, h.DB, upsertUserOpts) + if err != nil { + return err + } + + uid := fmt.Sprint(user.GetID()) + if user.GetSecure() == "" { + err := user.UpdateSecure(h.DB, user, uid) + if err != nil { + return err + } + } + + cookies, err := createDummyAuthCookies(ctx, user, h.Auth.Secret, sessionBuilder) + if err != nil { + return err + } + + h.WithHandlerHook(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, cookie := range cookies { + r.AddCookie(cookie) + } + next.ServeHTTP(w, r) + }) + }) + + return nil +} diff --git a/starter/user.go b/starter/user.go new file mode 100644 index 000000000..62ab1f77d --- /dev/null +++ b/starter/user.go @@ -0,0 +1,424 @@ +package starter + +import ( + "fmt" + "net/url" + "slices" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/admin/v3/presets/gorm2op" + "github.com/qor5/admin/v3/role" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/i18n" + "github.com/qor5/x/v3/login" + "github.com/qor5/x/v3/perm" + "gorm.io/gorm" + + plogin "github.com/qor5/admin/v3/login" + v "github.com/qor5/x/v3/ui/vuetify" + vx "github.com/qor5/x/v3/ui/vuetifyx" + h "github.com/theplant/htmlgo" +) + +type UserModelBuilder *presets.ModelBuilder + +// createUserModelBuilder creates and configures the user model builder +func (a *Handler) createUserModelBuilder(presetsBuilder *presets.Builder, activityBuilder *activity.Builder, loginSessionBuilder *plogin.SessionBuilder) UserModelBuilder { + umb := presetsBuilder.Model(&User{}) + defer func() { activityBuilder.RegisterModel(umb) }() + + // ========== Search Configuration ========== + umb.Listing().SearchFunc(func(ctx *web.EventContext, params *presets.SearchParams) (*presets.SearchResult, error) { + u := GetCurrentUser(ctx.R) + qdb := a.DB.WithContext(ctx.R.Context()) + + // If the current user doesn't have 'admin' role, do not allow them to view admin and manager users + // We didn't do this on permission because we are not supporting the permission on listing page + if currentRoles := u.GetRoles(); !slices.Contains(currentRoles, RoleAdmin) { + qdb = qdb.Joins("inner join user_role_join urj on users.id = urj.user_id inner join roles r on r.id = urj.role_id"). + Group("users.id"). + Having("COUNT(CASE WHEN r.name in (?) THEN 1 END) = 0", []string{RoleAdmin, RoleManager}) + } + + result, err := gorm2op.DataOperator(qdb).Search(ctx, params) + if err != nil { + return nil, errors.Wrap(err, "failed to search users") + } + return result, nil + }) + + // ========== Editing Configuration ========== + editing := umb.Editing( + "Type", + "Actions", + "Name", + "OAuthProvider", + "OAuthIdentifier", + "Account", + "Company", + "Roles", + "Status", + ) + + // Configure side panel for activity timeline + editing.SidePanelFunc(func(obj any, ctx *web.EventContext) h.HTMLComponent { + if ctx.R.FormValue(presets.ParamID) == "" { + return nil + } + return activityBuilder.MustGetModelBuilder(umb).NewTimelineCompo(ctx, obj, "_side") + }) + + // Configure validation + editing.ValidateFunc(func(obj any, _ *web.EventContext) (err web.ValidationErrors) { + u := obj.(*User) + if u.OAuthProvider == "" && u.Account == "" { + err.FieldError("Account", "Email is required") + } + return + }) + + // ========== Event Handlers ========== + // Event: Unlock user + umb.RegisterEventFunc("eventUnlockUser", func(ctx *web.EventContext) (r web.EventResponse, err error) { + uid := ctx.R.FormValue("id") + var u User + if err := a.DB.WithContext(ctx.R.Context()).Where("id = ?", uid).First(&u).Error; err != nil { + return r, errors.Wrap(err, "failed to find user") + } + if err = u.UnlockUser(a.DB, &User{}); err != nil { + return r, err + } + presets.ShowMessage(&r, "success", "") + editing.UpdateOverlayContent(ctx, &r, &u, "", nil) + return r, nil + }) + + // Event: Send reset password email + umb.RegisterEventFunc("eventSendResetPasswordEmail", func(ctx *web.EventContext) (r web.EventResponse, err error) { + uid := ctx.R.FormValue("id") + var u User + if err := a.DB.WithContext(ctx.R.Context()).Where("id = ?", uid).First(&u).Error; err != nil { + return r, errors.Wrap(err, "failed to find user") + } + token, err := u.GenerateResetPasswordToken(a.DB, &User{}) + if err != nil { + return r, err + } + r.RunScript = fmt.Sprintf(`alert("http://localhost:9500/auth/reset-password?id=%s&token=%s")`, uid, token) + return r, nil + }) + + // Event: Revoke TOTP + umb.RegisterEventFunc("eventRevokeTOTP", func(ctx *web.EventContext) (r web.EventResponse, err error) { + uid := ctx.R.FormValue("id") + var u *User + if err := a.DB.WithContext(ctx.R.Context()).Where("id = ?", uid).First(&u).Error; err != nil { + return r, errors.Wrap(err, "failed to find user") + } + err = login.RevokeTOTP(u, a.DB, &User{}, fmt.Sprint(u.ID)) + if err != nil { + return r, errors.WithStack(err) + } + err = loginSessionBuilder.ExpireAllSessions(fmt.Sprint(u.ID)) + if err != nil { + return r, errors.Wrap(err, "failed to expire all sessions") + } + presets.ShowMessage(&r, "success", "") + editing.UpdateOverlayContent(ctx, &r, u, "", nil) + return r, nil + }) + + // ========== Field Configurations ========== + editing.Field("Type").ComponentFunc(func(obj any, _ *presets.FieldContext, _ *web.EventContext) h.HTMLComponent { + u := obj.(*User) + if u.ID == 0 { + return nil + } + + var accountType string + if u.IsOAuthUser() { + accountType = "OAuth Account" + } else { + accountType = "Main Account" + } + + return h.Div( + v.VRow( + v.VCol( + h.Text(accountType), + ).Class("text-left deep-orange--text"), + ), + ).Class("mb-2") + }) + + editing.Field("Actions").ComponentFunc(func(obj any, _ *presets.FieldContext, _ *web.EventContext) h.HTMLComponent { + var actionBtns h.HTMLComponents + u := obj.(*User) + + if !u.IsOAuthUser() && u.Account != "" { + actionBtns = append(actionBtns, + v.VBtn("Send Reset Password Email"). + Color("primary"). + Attr("@click", web.Plaid().EventFunc("eventSendResetPasswordEmail"). + Query("id", u.ID).Go()), + ) + } + + if u.GetLocked() { + actionBtns = append(actionBtns, + v.VBtn("Unlock").Color("primary"). + Attr("@click", web.Plaid().EventFunc("eventUnlockUser"). + Query("id", u.ID).Go(), + ), + ) + } + + if u.GetIsTOTPSetup() { + actionBtns = append(actionBtns, + v.VBtn("Revoke TOTP"). + Color("primary"). + Attr("@click", web.Plaid().EventFunc("eventRevokeTOTP"). + Query("id", u.ID).Go()), + ) + } + + if len(actionBtns) == 0 { + return nil + } + return h.Div( + actionBtns..., + ).Class("mb-5 text-right") + }) + + editing.Field("Account").Label("Email").ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent { + return vx.VXField().Attr(web.VField(field.Name, field.Value(obj))...).Label(field.Label).ErrorMessages(field.Errors...) + }).SetterFunc(func(obj any, field *presets.FieldContext, ctx *web.EventContext) (err error) { + u := obj.(*User) + email := ctx.R.FormValue(field.Name) + if email == "" { + return + } + u.Account = email + u.OAuthIdentifier = email + return nil + }) + + editing.Field("OAuthProvider").Label("OAuth Provider").ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent { + u := obj.(*User) + if !u.IsOAuthUser() && u.ID != 0 { + return nil + } else { + return v.VSelect().Attr(web.VField(field.Name, field.Value(obj))...). + Label(field.Label). + Items(OAuthProviders) + } + }) + + editing.Field("OAuthIdentifier").Label("OAuth Identifier").ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent { + u := obj.(*User) + if !u.IsOAuthUser() { + return nil + } else { + return v.VTextField().Attr(web.VField(field.Name, field.Value(obj))...).Label(field.Label).ErrorMessages(field.Errors...).Disabled(true) + } + }) + + editing.Field("Roles"). + ComponentFunc(func(obj any, field *presets.FieldContext, ctx *web.EventContext) h.HTMLComponent { + var selectedItems []v.DefaultOptionItem + var values []string + u, ok := obj.(*User) + if ok && u.ID != 0 { + var userWithRoles User + if err := a.DB.WithContext(ctx.R.Context()).Preload("Roles").Where("id = ?", u.ID).First(&userWithRoles).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + panic(err) + } + } + for _, r := range userWithRoles.Roles { + values = append(values, fmt.Sprint(r.ID)) + selectedItems = append(selectedItems, v.DefaultOptionItem{ + Text: r.Name, + Value: fmt.Sprint(r.ID), + }) + } + } + + var roles []role.Role + a.DB.Find(&roles) + var allRoleItems []v.DefaultOptionItem + for _, r := range roles { + allRoleItems = append(allRoleItems, v.DefaultOptionItem{ + Text: r.Name, + Value: fmt.Sprint(r.ID), + }) + } + + return vx.VXSelect().Label(field.Label).Chips(true). + Items(allRoleItems).ItemTitle("text").ItemValue("value"). + Multiple(true).Attr(presets.VFieldError(field.Name, values, field.Errors)...). + Disabled(field.Disabled) + }). + SetterFunc(func(obj any, field *presets.FieldContext, ctx *web.EventContext) (err error) { + u, ok := obj.(*User) + if !ok { + return + } + if u.GetAccountName() == a.Auth.InitialUserEmail { + return perm.PermissionDenied //nolint:errhandle + } + rids := ctx.R.Form[field.Name] + var roles []role.Role + for _, id := range rids { + uid, err1 := strconv.Atoi(id) + if err1 != nil { + continue + } + roles = append(roles, role.Role{ + Model: gorm.Model{ID: uint(uid)}, + }) + } + u.Roles = roles + return + }) + + // Field: Status + editing.Field("Status"). + ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent { + return vx.VXSelect().Attr(presets.VFieldError(field.Name, field.Value(obj), field.Errors)...). + Label(field.Label). + Items([]string{"active", "inactive"}) + }) + + // ========== Save Logic Configuration ========== + editing.WrapSaveFunc(func(in presets.SaveFunc) presets.SaveFunc { + return func(obj any, id string, ctx *web.EventContext) error { + u := obj.(*User) + if u.GetAccountName() == a.Auth.InitialUserEmail { + return perm.PermissionDenied //nolint:errhandle + } + if u.RegistrationDate.IsZero() { + u.RegistrationDate = time.Now() + } + if err := a.DB.Transaction(func(tx *gorm.DB) error { + ctx.WithContextValue(gorm2op.CtxKeyDB{}, tx) + defer ctx.WithContextValue(gorm2op.CtxKeyDB{}, nil) + // First save the user to ensure we have a valid ID (covers both create and update) + if err := in(obj, id, ctx); err != nil { + return err + } + // Explicitly replace user roles in the join table + var roleIDs []uint + for _, r := range u.Roles { + if r.ID != 0 { + roleIDs = append(roleIDs, r.ID) + } + } + if err := replaceUserRoles(tx, u.ID, roleIDs); err != nil { + return err + } + return nil + }); err != nil { + return errors.Wrap(err, "failed to save user") + } + return nil + } + }) + + // ========== Listing Configuration ========== + listing := umb.Listing("ID", "Name", "Account", "Status", activity.ListFieldNotes).PerPage(10) + listing.Field("Account").Label("Email") + listing.SearchColumns("users.Name", "Account") + + // Configure filter data + listing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { + item, err := activityBuilder.MustGetModelBuilder(umb).NewHasUnreadNotesFilterItem(ctx.R.Context(), "users.") + if err != nil { + panic(err) + } + return []*vx.FilterItem{ + item, + { + Key: "created", + Label: "Create Time", + ItemType: vx.ItemTypeDatetimeRange, + SQLCondition: `users.created_at %s ?`, + }, + { + Key: "name", + Label: "Name", + ItemType: vx.ItemTypeString, + SQLCondition: `users.name %s ?`, + }, + { + Key: "status", + Label: "Status", + ItemType: vx.ItemTypeSelect, + SQLCondition: `users.status %s ?`, + Options: []*vx.SelectItem{ + {Text: "Active", Value: "active"}, + {Text: "Inactive", Value: "inactive"}, + }, + }, + { + Key: "registration_date", + Label: "Registration Date", + ItemType: vx.ItemTypeDate, + SQLCondition: `users.registration_date %s ?`, + Folded: true, + }, + { + Key: "registration_date_range", + Label: "Registration Date Range", + ItemType: vx.ItemTypeDateRange, + SQLCondition: `users.registration_date %s ?`, + Folded: true, + }, + } + }) + + // Configure filter tabs + listing.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab { + msgr := i18n.MustGetModuleMessages(ctx.R, I18nDemoKey, Messages_en_US).(*Messages) + + tab, err := activityBuilder.MustGetModelBuilder(umb).NewHasUnreadNotesFilterTab(ctx.R.Context()) + if err != nil { + panic(err) + } + return []*presets.FilterTab{ + { + Label: msgr.FilterTabsAll, + Query: url.Values{"all": []string{"1"}}, + }, + { + Label: msgr.FilterTabsActive, + Query: url.Values{"status": []string{"active"}}, + }, + tab, + } + }) + + return umb +} + +// replaceUserRoles replaces all roles of a user by role IDs via the join table explicitly. +// This does not rely on GORM association auto-save behavior. +func replaceUserRoles(db *gorm.DB, userID uint, roleIDs []uint) error { + // Clear existing relations + if err := db.Table("user_role_join").Where("user_id = ?", userID).Delete(nil).Error; err != nil { + return errors.Wrap(err, "failed to delete user role") + } + if len(roleIDs) == 0 { + return nil + } + // Bulk insert new relations + rows := make([]map[string]any, 0, len(roleIDs)) + for _, rid := range roleIDs { + rows = append(rows, map[string]any{"user_id": userID, "role_id": rid}) + } + return errors.WithStack(db.Table("user_role_join").Create(&rows).Error) +} diff --git a/starter/uset_test.go b/starter/uset_test.go new file mode 100644 index 000000000..0f8f8fa01 --- /dev/null +++ b/starter/uset_test.go @@ -0,0 +1,153 @@ +package starter_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + . "github.com/qor5/web/v3/multipartestutils" + "github.com/qor5/x/v3/gormx" + "github.com/theplant/inject" + + "github.com/qor5/admin/v3/presets" + "github.com/qor5/admin/v3/presets/actions" + "github.com/qor5/admin/v3/starter" +) + +func TestUsers(t *testing.T) { + env := newTestEnv(t, starter.SetupPageBuilderForHandler) + suite := inject.MustResolve[*gormx.TestSuite](env.lc) + db := suite.DB() + cases := []TestCase{ + { + Name: "Index Users", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + BuildEventFuncRequest() + return req + }, + ExpectPageBodyContainsInOrder: []string{`qor@theplant.jp`}, + }, + { + Name: "Lock Users", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + EventFunc("eventUnlockUser"). + Query(presets.ParamID, "1"). + BuildEventFuncRequest() + return req + }, + ExpectPageBodyContainsInOrder: []string{`success`}, + }, + { + Name: "Send Reset Password Email Users", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + EventFunc("eventSendResetPasswordEmail"). + Query(presets.ParamID, "1"). + BuildEventFuncRequest() + return req + }, + ExpectPageBodyContainsInOrder: []string{`auth/reset-password`}, + }, + { + Name: "Revoke TOTP Users", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + EventFunc("eventRevokeTOTP"). + Query(presets.ParamID, "1"). + BuildEventFuncRequest() + return req + }, + ExpectPageBodyContainsInOrder: []string{`success`}, + }, + { + Name: "Edit User", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + EventFunc(actions.Edit). + Query(presets.ParamID, "1"). + BuildEventFuncRequest() + return req + }, + ExpectPortalUpdate0ContainsInOrder: []string{`Type`, "Actions"}, + }, + { + Name: "Update User", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + EventFunc(actions.Update). + Query(presets.ParamID, "1"). + BuildEventFuncRequest() + return req + }, + ExpectPortalUpdate0ContainsInOrder: []string{`Type`, "Actions"}, + }, + { + Name: "User InValidate", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + EventFunc(actions.Update). + Query(presets.ParamID, "1"). + AddField("Name", "test@theplant.jp"). + AddField("OAuthIdentifier", "test@theplant.jp"). + AddField("status", "active"). + BuildEventFuncRequest() + return req + }, + ExpectPortalUpdate0ContainsInOrder: []string{`test@theplant.jp`, `Email`, `Email is required`, `Company`, "Roles"}, + }, + { + Name: "User Update With Google Provider", + Debug: true, + ReqFunc: func() *http.Request { + req := NewMultipartBuilder(). + PageURL("/users"). + EventFunc(actions.Update). + Query(presets.ParamID, "1"). + AddField("Name", "viwer@theplant.jp"). + AddField("Account", "viwer@theplant.jp"). + AddField("OAuthProvider", "google"). + AddField("OAuthIdentifier", "viwer@theplant.jp"). + AddField("status", "active"). + AddField("Roles", "1"). + AddField("Roles", "2"). + AddField("Roles", "3"). + BuildEventFuncRequest() + return req + }, + ResponseMatch: func(t *testing.T, w *httptest.ResponseRecorder) { + user := &starter.User{} + db.Preload("Roles").First(user, 1) + if user.Account != "viwer@theplant.jp" { + t.Fatalf(`expected "viwer@theplant.jp" but got "%s"`, user.Account) + return + } + if len(user.Roles) != 3 { + t.Fatalf(`expected 3 roles but got %d`, len(user.Roles)) + return + } + }, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + RunCase(t, c, env.handler) + }) + } +}