From b4b4135ad3a26d4f71e9a1f93a5d2a8666ef8a3f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 4 Oct 2026 00:01:22 +0200 Subject: [PATCH] feat: golem15.feedback plugin with the widget config route - plugin golem15.feedback (requires golem15.user), its config, lang (en, pl), models and migrations - typed settings singleton golem15_feedback_settings with the WinterCMS defaults and a widget key generated on first creation - GET /_feedback/api/v1/{key}/config behind the constant-time key check and the fail-closed Origin allow-list, with the feedback-config and feedback-submit buckets --- README.md | 90 +++++ config/config.yaml | 8 + controllers/api/feedback_api_controller.go | 122 +++++++ controllers/api/winter_errors.go | 99 ++++++ feedback_test.go | 361 ++++++++++++++++++++ go.mod | 123 +++++++ go.sum | 362 +++++++++++++++++++++ internal/pgtest/pgtest.go | 99 ++++++ lang/en/lang.yaml | 55 ++++ lang/pl/lang.yaml | 55 ++++ models/registry.go | 16 + models/settings.go | 316 ++++++++++++++++++ models/submission.go | 96 ++++++ models/user_preference.go | 54 +++ plugin.go | 98 ++++++ routes.go | 18 + updates/01_feedback.go | 95 ++++++ updates/registry.go | 17 + 18 files changed, 2084 insertions(+) create mode 100644 README.md create mode 100644 config/config.yaml create mode 100644 controllers/api/feedback_api_controller.go create mode 100644 controllers/api/winter_errors.go create mode 100644 feedback_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/pgtest/pgtest.go create mode 100644 lang/en/lang.yaml create mode 100644 lang/pl/lang.yaml create mode 100644 models/registry.go create mode 100644 models/settings.go create mode 100644 models/submission.go create mode 100644 models/user_preference.go create mode 100644 plugin.go create mode 100644 routes.go create mode 100644 updates/01_feedback.go create mode 100644 updates/registry.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a84e26 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# golem15.feedback + +The shared feedback-widget plugin for SummerCMS applications: an embeddable widget's public config and submit API behind a widget key and an Origin allow-list, a per-user "hide the widget" preference, the widget settings, and a job that files every submission as a G15Office task. + +```go +import _ "git.golem15.com/golem15/sm-feedback-plugin" +``` + +Module `git.golem15.com/golem15/sm-feedback-plugin`, package `feedback`, plugin ID `golem15.feedback`. + +## Overview + +The plugin is a compiled SummerCMS plugin ported from the WinterCMS `Golem15.Feedback` plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`. It requires `golem15.user`. + +The widget routes are public. The widget key in the URL is a public identifier (it ships in the embed snippet), not a secret: the real gate is the Origin check against the operator's allowed-origins list, backed by per-IP rate limits. + +The plugin ID, the table names, the config keys and the settings code are a contract shared by every application that mounts the plugin: change them only in a backwards-compatible way. + +## Features + +- **Widget config.** `GET /_feedback/api/v1/{key}/config` answers the widget's position, whether visitors may hide it, its colors and its labels (`?lang=en` selects the English labels, anything else the Polish ones). A disabled widget, an empty key or a key that differs (compared in constant time) is 404 `{"error":true,"message":"Not found"}`. +- **Origin gate.** A request without an `Origin` header passes. An `Origin` passes only when its URL host, lower-cased, is on the allowed-origins list (one entry per line; a line is reduced to its URL host when it has one). An empty list refuses every `Origin` with 403 `{"error":true,"message":"Origin not allowed"}`, so a widget that was never configured cannot be embedded anywhere. +- **Rate limits.** The named buckets `feedback-config` (60 a minute) and `feedback-submit` (10 a minute) count per client IP behind the configured trusted proxies. +- **Settings.** `models.Settings` is the singleton row of `golem15_feedback_settings`. `models.Instance` creates it on first access with the WinterCMS defaults and a new widget key (`wk_` plus 32 random letters and digits). + +## Usage + +Mount the plugin in an application as a git submodule at `plugins/golem15/feedback`: + +```sh +git submodule add git@git.golem15.com:golem15/sm-feedback-plugin.git plugins/golem15/feedback +``` + +Add it to the application's `go.work` and `go.mod`: + +```text +use ./plugins/golem15/feedback + +require git.golem15.com/golem15/sm-feedback-plugin v0.0.0 + +replace git.golem15.com/golem15/sm-feedback-plugin => ./plugins/golem15/feedback +``` + +List it in `summer.yaml` after `golem15.user` and run `summer build`: + +```yaml +module: git.golem15.com/golem15/acme +binary: acme +plugins: + - id: golem15.user + module: git.golem15.com/golem15/sm-user-plugin + - id: golem15.feedback + module: git.golem15.com/golem15/sm-feedback-plugin +``` + +The application's `http.cors.paths` must list `_feedback/api/*`: the widget calls the API from other origins. + +The plugin's own `go.mod` replaces the framework with `../../../../summercms.go` and the user plugin with `../user`, so it expects the framework checkout next to the application checkout and the user plugin mounted beside it. + +## API reference + +- `feedback`: `Plugin`, the `golem15.feedback` plugin (config, lang, migrations, models, routes, buckets); `BucketConfig`, `BucketSubmit`. +- `controllers/api`: `Config`, `KeyMatches`, `OriginAllowed`. +- `models`: `Settings` (table `golem15_feedback_settings`), `SettingsID`, `SettingsCode`, `DefaultSettings`, `Instance`, `NewWidgetKey`, `Labels`, `URLHost`, `ASCIILower`; `Submission` (table `golem15_feedback_submissions`), `StatusPending`, `StatusSent`, `StatusFailed`, `TypeBug`, `TypeFeature`, `TypeOther`, `ScreenshotField`, `G15OfficeTypeMap`; `UserPreference` (table `golem15_feedback_user_preferences`), `IsWidgetHidden`, `SetWidgetHidden`; `Register`, `All`. +- `updates`: the migrations (`202610030201_create_feedback_submissions`, `202610030202_create_feedback_user_preferences`, `202610030203_create_feedback_settings`), `Register`, `All`. + +## Configuration + +Defaults live in `config/config.yaml` and are merged under `golem15.feedback.*`. + +| Key | Default | Meaning | +|-----|---------|---------| +| `golem15.feedback.g15_office.base_url` | empty | The G15Office base URL submissions are filed under. | +| `golem15.feedback.g15_office.token` | empty | The G15Office API token. Never commit it. | +| `golem15.feedback.g15_office.project` | empty | The G15Office project the tasks are created in. | + +Override them with `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL`, `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__TOKEN` and `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__PROJECT`. + +## Dependencies + +- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `compass`, `lagoon`, `pact`, `party`, `surf`, `wire`). +- The user plugin, `git.golem15.com/golem15/sm-user-plugin`. +- GORM and gormigrate, as pinned in `go.mod`. +- testcontainers-go for the Postgres-backed tests. + +## Testing + +```sh +go test ./... # starts Postgres with testcontainers (needs Docker) +``` diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..fc65bf8 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,8 @@ +g15_office: + # The G15Office (support portal) connection every submission is filed + # through. Nothing is sent while any of the three is empty. Override them + # with SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL, __TOKEN and + # __PROJECT; never commit the token. + base_url: "" + token: "" + project: "" diff --git a/controllers/api/feedback_api_controller.go b/controllers/api/feedback_api_controller.go new file mode 100644 index 0000000..2e1961d --- /dev/null +++ b/controllers/api/feedback_api_controller.go @@ -0,0 +1,122 @@ +// Package api holds the golem15.feedback HTTP handlers: the public widget +// routes (config and submit) and the JWT preference route. +package api + +import ( + "crypto/subtle" + "net/http" + + "git.golem15.com/golem15/sm-feedback-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "gorm.io/gorm" +) + +// configBody is FeedbackApiController::config's answer, in PHP's key order. +type configBody struct { + Success bool `json:"success"` + Data configData `json:"data"` +} + +type configData struct { + Position *string `json:"position"` + AllowHide bool `json:"allowHide"` + Colors configColors `json:"colors"` + Labels models.Labels `json:"labels"` +} + +type configColors struct { + Primary *string `json:"primary"` + Accent *string `json:"accent"` +} + +// Config handles GET /_feedback/api/v1/{key}/config: the widget's position, +// colors and labels (English for ?lang=en, Polish otherwise) once the key +// and the Origin pass the widget gate. +func Config(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, ok := gate(w, r, app) + if !ok { + return + } + writeJSON(w, http.StatusOK, configBody{ + Success: true, + Data: configData{ + Position: settings.Position, + AllowHide: settings.AllowHide, + Colors: configColors{Primary: settings.ColorPrimary, Accent: settings.ColorAccent}, + Labels: settings.LabelsForLocale(r.URL.Query().Get("lang")), + }, + }) + } +} + +// gate loads the settings (creating them on first access) and runs the +// widget checks of both public routes in PHP's order: the key (404 +// "Not found") then the Origin (403 "Origin not allowed"). +func gate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.Settings, bool) { + gdb := database(app) + if gdb == nil { + writeOpaque500(w) + return nil, false + } + settings, err := models.Instance(r.Context(), gdb) + if err != nil { + writeOpaque500(w) + return nil, false + } + if !KeyMatches(settings, r.PathValue("key")) { + writeError(w, http.StatusNotFound, "Not found") + return nil, false + } + if !OriginAllowed(r.Header.Get("Origin"), settings) { + writeError(w, http.StatusForbidden, "Origin not allowed") + return nil, false + } + return settings, true +} + +// KeyMatches is FeedbackApiController::keyMatches: the widget is enabled, +// has a key, and key equals it, compared in constant time (hash_equals). +func KeyMatches(settings *models.Settings, key string) bool { + if settings == nil || !settings.Enabled || settings.WidgetKey == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(settings.WidgetKey), []byte(key)) == 1 +} + +// OriginAllowed is FeedbackApiController::isOriginAllowed. No Origin (curl, +// a server, a same-origin request) passes. An Origin passes only when its +// URL host, lower-cased, is on the settings' allowed-origins list; an +// Origin without a host fails, and an empty list fails every Origin, so a +// widget that was never configured cannot be embedded anywhere. +func OriginAllowed(origin string, settings *models.Settings) bool { + if origin == "" { + return true + } + host, ok := models.URLHost(origin) + if !ok || host == "" { + return false + } + allowed := settings.AllowedOriginHosts() + if len(allowed) == 0 { + return false + } + host = models.ASCIILower(host) + for _, a := range allowed { + if a == host { + return true + } + } + return false +} + +func database(app *backpack.App) *gorm.DB { + if app == nil { + return nil + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok || gdb == nil { + return nil + } + return gdb +} diff --git a/controllers/api/winter_errors.go b/controllers/api/winter_errors.go new file mode 100644 index 0000000..f4e7959 --- /dev/null +++ b/controllers/api/winter_errors.go @@ -0,0 +1,99 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "sort" + + "git.golem15.com/golem15/summercms/modules/wire" +) + +// writeJSON answers like Laravel's response()->json behind WinterCMS's +// json.response middleware: the JSON body with Cache-Control +// "no-cache, private". The plugin keeps its own writers because a plugin in +// its own repository cannot import an application's controllers. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Cache-Control", "no-cache, private") + wire.WriteJSON(w, status, v) +} + +// writeError is the widget routes' {"error":true,"message":...} envelope. +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, errorBody{Error: true, Message: message}) +} + +type errorBody struct { + Error bool `json:"error"` + Message string `json:"message"` +} + +// writeOpaque500 is the house opaque 500 for a failure that is not the +// client's (no database, a failed write). +func writeOpaque500(w http.ResponseWriter) { + wire.WriteOpaque500(w) +} + +// orderedErrors marshals a Laravel message bag with its keys in rule order +// (lagoon.ErrorKeys), the remaining keys sorted after them. +type orderedErrors struct { + errs map[string][]string + order []string +} + +func (o orderedErrors) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + seen := map[string]bool{} + first := true + write := func(k string) error { + msgs, ok := o.errs[k] + if !ok || seen[k] { + return nil + } + seen[k] = true + if !first { + buf.WriteByte(',') + } + first = false + kb, err := marshalNoEscape(k) + if err != nil { + return err + } + vb, err := marshalNoEscape(msgs) + if err != nil { + return err + } + buf.Write(kb) + buf.WriteByte(':') + buf.Write(vb) + return nil + } + for _, k := range o.order { + if err := write(k); err != nil { + return nil, err + } + } + rest := make([]string, 0, len(o.errs)) + for k := range o.errs { + rest = append(rest, k) + } + sort.Strings(rest) + for _, k := range rest { + if err := write(k); err != nil { + return nil, err + } + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +func marshalNoEscape(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} diff --git a/feedback_test.go b/feedback_test.go new file mode 100644 index 0000000..7b3f720 --- /dev/null +++ b/feedback_test.go @@ -0,0 +1,361 @@ +package feedback + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/sm-feedback-plugin/controllers/api" + "git.golem15.com/golem15/sm-feedback-plugin/internal/pgtest" + "git.golem15.com/golem15/sm-feedback-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/surf" + "gorm.io/gorm" +) + +// testJWTSecret is the test-only golem15.user JWT secret. +const testJWTSecret = "feedback-test-jwt-secret-0123456789abcdef" + +// testWidgetKey is the widget key the tests store. +const testWidgetKey = "wk_TestKey0123456789abcdefghijklmnop" + +func testConfig(t testing.TB) *compass.Config { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: feedback-test\n"), 0o644); err != nil { + t.Fatal(err) + } + httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 16777216\ncors:\n paths:\n - _feedback/api/*\n allowed_methods: [\"*\"]\n allowed_origins: [\"*\"]\n allowed_headers: [\"*\"]\n" + if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{ + "SUMMER_ENV=development", + "SUMMER_GOLEM15__USER__JWT__SECRET=" + testJWTSecret, + }}) + if err != nil { + t.Fatal(err) + } + return cfg +} + +// newHandler assembles golem15.user and golem15.feedback over the shared +// test database, with a public uploads bucket in a temp directory. +func newHandler(t *testing.T) (http.Handler, *backpack.App, *gorm.DB) { + t.Helper() + gdb := pgtest.DB(t) + cfg := testConfig(t) + if err := cfg.Set("storage.uploads.bucket_url", "file://"+t.TempDir()); err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, pgtest.SQL(t), gdb); err != nil { + t.Fatal(err) + } + bucket, err := attach.OpenBucket(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = bucket.Close() }) + if err := attach.Publish(app, bucket); err != nil { + t.Fatal(err) + } + plugins, err := party.Activate(app, pgtest.PluginIDs) + if err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + return h, app, gdb +} + +// resetSettings stores the settings row: the defaults, testWidgetKey and +// the given allowed origins, then applies mutate. +func resetSettings(t *testing.T, gdb *gorm.DB, origins string, mutate func(*models.Settings)) *models.Settings { + t.Helper() + if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil { + t.Fatal(err) + } + s := models.DefaultSettings() + s.WidgetKey = testWidgetKey + if origins != "" { + s.AllowedOrigins = &origins + } + if mutate != nil { + mutate(&s) + } + if err := gdb.Create(&s).Error; err != nil { + t.Fatal(err) + } + return &s +} + +func do(t *testing.T, h http.Handler, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("body %q: %v", rec.Body.String(), err) + } + return out +} + +// TestFeedbackPluginBoot activates the plugin after golem15.user, which it +// requires, and checks its ID, config defaults, lang namespace, buckets +// and the settings row's first-access defaults. +func TestFeedbackPluginBoot(t *testing.T) { + cfg := testConfig(t) + app := backpack.New(cfg) + if _, err := party.Activate(app, []string{"golem15.feedback"}); err == nil { + t.Fatal("golem15.feedback activated without golem15.user") + } + app = backpack.New(testConfig(t)) + plugins, err := party.Activate(app, []string{"golem15.user", "golem15.feedback"}) + if err != nil { + t.Fatal(err) + } + if len(plugins) != 2 || plugins[1].ID() != "golem15.feedback" { + t.Fatalf("activated %v", plugins) + } + p := plugins[1].(*Plugin) + if got := p.Requires(); len(got) != 1 || got[0] != "golem15.user" { + t.Fatalf("Requires() = %v", got) + } + for _, key := range []string{"base_url", "token", "project"} { + v, ok := app.Config.Lookup("golem15.feedback.g15_office." + key) + if !ok || v != "" { + t.Fatalf("golem15.feedback.g15_office.%s = %#v (set %v), want empty", key, v, ok) + } + } + tr, ok := app.Lookup[*phrasebook.Translator]() + if !ok { + t.Fatal("no translator") + } + if got := tr.Get(context.Background(), "golem15.feedback::lang.settings.menu_label", nil); got != "Feedback Widget" { + t.Fatalf("menu_label = %q", got) + } + buckets := p.Buckets() + if b := buckets[BucketConfig]; b.Max != 60 || b.Decay.Seconds() != 60 || b.Key == nil { + t.Fatalf("config bucket = %+v", b) + } + if b := buckets[BucketSubmit]; b.Max != 10 || b.Decay.Seconds() != 60 || b.Key == nil { + t.Fatalf("submit bucket = %+v", b) + } + + gdb := pgtest.DB(t) + if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil { + t.Fatal(err) + } + first, err := models.Instance(t.Context(), gdb) + if err != nil { + t.Fatal(err) + } + if !first.Enabled || first.AllowHide || *first.Position != "bottom-right" || *first.G15OfficeTaskPriority != "normal" || + *first.LabelTitlePL != "Zgłoś opinię" || *first.LabelSuccessEN != "Thanks for the feedback!" || first.ColorPrimary != nil { + t.Fatalf("defaults = %+v", first) + } + if !strings.HasPrefix(first.WidgetKey, "wk_") || len(first.WidgetKey) != 35 { + t.Fatalf("widget key %q", first.WidgetKey) + } + again, err := models.Instance(t.Context(), gdb) + if err != nil { + t.Fatal(err) + } + if again.WidgetKey != first.WidgetKey { + t.Fatal("the widget key changed on the second access") + } + other, err := models.NewWidgetKey() + if err != nil || other == first.WidgetKey { + t.Fatalf("two generated keys are equal (%v)", err) + } +} + +// TestFeedbackConfig drives GET {key}/config through the assembled router: +// the key gate (constant-time, disabled widget, wrong key), the Origin gate +// (no Origin passes, an empty allow-list fails closed, hosts compare +// lower-cased) and the locale of the labels. +func TestFeedbackConfig(t *testing.T) { + h, _, gdb := newHandler(t) + get := func(key, origin, query string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/_feedback/api/v1/"+key+"/config"+query, nil) + req.Header.Set("Accept", "application/json") + if origin != "" { + req.Header.Set("Origin", origin) + } + return do(t, h, req) + } + wantError := func(rec *httptest.ResponseRecorder, status int, message string) { + t.Helper() + if rec.Code != status { + t.Fatalf("status %d want %d: %s", rec.Code, status, rec.Body.String()) + } + if body := rec.Body.String(); body != `{"error":true,"message":"`+message+`"}` { + t.Fatalf("body %s", body) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" { + t.Fatalf("Cache-Control %q", cc) + } + } + + resetSettings(t, gdb, "https://Widget.Example.test/\nlocalhost:3000\n\n other.test ", func(s *models.Settings) { + primary := "#6366f1" + s.ColorPrimary = &primary + s.AllowHide = true + }) + + t.Run("no-origin", func(t *testing.T) { + rec := get(testWidgetKey, "", "") + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + want := `{"success":true,"data":{"position":"bottom-right","allowHide":true,"colors":{"primary":"#6366f1","accent":null},"labels":{"title":"Zgłoś opinię","placeholder":"Co poszło nie tak?","success":"Dzięki za zgłoszenie!"}}}` + if rec.Body.String() != want { + t.Fatalf("body %s", rec.Body.String()) + } + }) + t.Run("lang-en", func(t *testing.T) { + labels := decode(t, get(testWidgetKey, "", "?lang=en"))["data"].(map[string]any)["labels"].(map[string]any) + if labels["title"] != "Send feedback" || labels["placeholder"] != "What went wrong?" || labels["success"] != "Thanks for the feedback!" { + t.Fatalf("labels %v", labels) + } + labels = decode(t, get(testWidgetKey, "", "?lang=de"))["data"].(map[string]any)["labels"].(map[string]any) + if labels["title"] != "Zgłoś opinię" { + t.Fatalf("lang=de labels %v", labels) + } + }) + t.Run("origins", func(t *testing.T) { + for _, origin := range []string{"https://widget.example.test", "http://WIDGET.example.test:8080", "http://localhost:3000", "https://other.test"} { + if rec := get(testWidgetKey, origin, ""); rec.Code != http.StatusOK { + t.Fatalf("origin %s: status %d", origin, rec.Code) + } + } + for _, origin := range []string{"https://evil.test", "null", "widget.example.test", "https://widget.example.test.evil.test"} { + wantError(get(testWidgetKey, origin, ""), http.StatusForbidden, "Origin not allowed") + } + }) + t.Run("key", func(t *testing.T) { + wantError(get("wk_wrong", "", ""), http.StatusNotFound, "Not found") + wantError(get(testWidgetKey+"x", "", ""), http.StatusNotFound, "Not found") + wantError(get(testWidgetKey[:len(testWidgetKey)-1], "", ""), http.StatusNotFound, "Not found") + // The key gate runs before the Origin gate. + wantError(get("wk_wrong", "https://evil.test", ""), http.StatusNotFound, "Not found") + }) + t.Run("disabled", func(t *testing.T) { + resetSettings(t, gdb, "https://widget.example.test", func(s *models.Settings) { s.Enabled = false }) + wantError(get(testWidgetKey, "", ""), http.StatusNotFound, "Not found") + }) + t.Run("empty-allow-list-fails-closed", func(t *testing.T) { + resetSettings(t, gdb, "", nil) + wantError(get(testWidgetKey, "https://widget.example.test", ""), http.StatusForbidden, "Origin not allowed") + if rec := get(testWidgetKey, "", ""); rec.Code != http.StatusOK { + t.Fatalf("no Origin with an empty list: %d", rec.Code) + } + }) + t.Run("first-access-creates-the-row", func(t *testing.T) { + if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil { + t.Fatal(err) + } + wantError(get("wk_guess", "", ""), http.StatusNotFound, "Not found") + var s models.Settings + if err := gdb.Take(&s, models.SettingsID).Error; err != nil { + t.Fatal(err) + } + if rec := get(s.WidgetKey, "", ""); rec.Code != http.StatusOK { + t.Fatalf("generated key: %d", rec.Code) + } + }) +} + +// TestKeyMatches pins the key gate: only the exact stored key of an +// enabled widget matches; an empty stored key never does. +func TestKeyMatches(t *testing.T) { + s := &models.Settings{Enabled: true, WidgetKey: "wk_abc"} + cases := map[string]bool{"wk_abc": true, "wk_ab": false, "wk_abcd": false, "WK_ABC": false, "": false} + for key, want := range cases { + if got := api.KeyMatches(s, key); got != want { + t.Errorf("KeyMatches(%q) = %v", key, got) + } + } + if api.KeyMatches(&models.Settings{Enabled: false, WidgetKey: "wk_abc"}, "wk_abc") { + t.Error("a disabled widget matched") + } + if api.KeyMatches(&models.Settings{Enabled: true}, "") { + t.Error("an empty key matched") + } + if api.KeyMatches(nil, "wk_abc") { + t.Error("nil settings matched") + } +} + +// TestURLHost compares URLHost with PHP's parse_url($s, PHP_URL_HOST) on +// the shapes an Origin header or an allowed-origins line takes (the PHP +// results were taken from php -r). +func TestURLHost(t *testing.T) { + cases := []struct { + in string + want string + ok bool + }{ + {"localhost:3000", "localhost", true}, + {"example.com", "", false}, + {"example.com/", "", false}, + {"https://Example.COM/", "Example.COM", true}, + {"//a.test", "a.test", true}, + {"https://[::1]:8080", "[::1]", true}, + {"https://a.test:3000/x", "a.test", true}, + {"null", "", false}, + {"http://user:pw@b.test", "b.test", true}, + {"a.test:abc", "", false}, + {"a.test:3000/path", "a.test", true}, + {"a.test:3000?x", "", false}, + {"mailto:x@y.com", "", false}, + {"https://a.test:abc", "", false}, + {"https://a.test:", "a.test", true}, + {"https://a.test?x=1", "a.test", true}, + {"https://a.test#f", "a.test", true}, + {"HTTPS://B.TEST", "B.TEST", true}, + {"https://user@[::1]", "[::1]", true}, + {"a:1:2", "", false}, + {"https://a.test:99999", "", false}, + {"https://", "", false}, + } + for _, c := range cases { + got, ok := models.URLHost(c.in) + if got != c.want || ok != c.ok { + t.Errorf("URLHost(%q) = %q, %v; want %q, %v", c.in, got, ok, c.want, c.ok) + } + } +} + +// TestAllowedOriginHosts is Settings::getAllowedOriginHosts: lines split on +// CR/LF, trimmed, blanks and "0" dropped, hosts lower-cased, duplicates +// removed, a line without a URL host kept as written. +func TestAllowedOriginHosts(t *testing.T) { + raw := "https://Example.com/\r\nexample.com\n\n //Example.com \n0\nexample.com/\nlocalhost:3000\r\rOTHER.test" + s := &models.Settings{AllowedOrigins: &raw} + got := strings.Join(s.AllowedOriginHosts(), ",") + if want := "example.com,example.com/,localhost,other.test"; got != want { + t.Fatalf("hosts %q want %q", got, want) + } + if hosts := (&models.Settings{}).AllowedOriginHosts(); len(hosts) != 0 { + t.Fatalf("nil origins gave %v", hosts) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5526d40 --- /dev/null +++ b/go.mod @@ -0,0 +1,123 @@ +module git.golem15.com/golem15/sm-feedback-plugin + +go 1.27.0 + +require ( + git.golem15.com/golem15/sm-user-plugin v0.0.0 + git.golem15.com/golem15/summercms v0.0.0 + github.com/go-gormigrate/gormigrate/v2 v2.1.7 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + gorm.io/gorm v1.31.2 +) + +require ( + 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/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // 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/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.4 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/inconshreveable/mousetrap v1.1.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.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect + github.com/knadh/koanf/providers/confmap v1.0.1 // indirect + github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect + github.com/knadh/koanf/v2 v2.3.6 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/lib/pq v1.12.3 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/riverqueue/river v0.47.0 // indirect + github.com/riverqueue/river/riverdriver v0.47.0 // indirect + github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect + github.com/riverqueue/river/rivershared v0.47.0 // indirect + github.com/riverqueue/river/rivertype v0.47.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/testcontainers/testcontainers-go v0.44.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/wneessen/go-mail v0.8.1 // indirect + github.com/yuin/goldmark v1.8.6 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + gocloud.dev v0.46.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/image v0.46.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.272.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gorm.io/driver/postgres v1.6.3 // indirect +) + +replace git.golem15.com/golem15/summercms => ../../../../summercms.go + +replace git.golem15.com/golem15/sm-user-plugin => ../user diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ecad56f --- /dev/null +++ b/go.sum @@ -0,0 +1,362 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= +cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +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= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +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/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg= +github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +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.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +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.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +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/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= +github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= +github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +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.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc= +github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ= +github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60= +github.com/riverqueue/river/riverdriver v0.47.0/go.mod h1:NOXl0fUiF1AT/TaQOjdx2A/c0Davn+SKbW8nAXWjfC4= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 h1:4wckhMlLN08F76/GWxGF6vlrbs5HCMuV01dpUnvOGI4= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0/go.mod h1:EME1EGr7RU/vMUf61mpf9YXhsZJ+GlFk4AHrIHT+YdE= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 h1:5N9nvemhQwbUElMxASw4oEaYJ/v6hiS5Y9VcOQfdC5g= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0/go.mod h1:ZboiXXZKC4+fTkxBxGRVmAsCuUu0NPYliqbWYuQAZyw= +github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOtPKQZAmThkjFpLs= +github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418= +github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0= +github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= +gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +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.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0= +gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/pgtest/pgtest.go b/internal/pgtest/pgtest.go new file mode 100644 index 0000000..eade368 --- /dev/null +++ b/internal/pgtest/pgtest.go @@ -0,0 +1,99 @@ +// Package pgtest starts one Postgres container per test binary for the +// plugin's database tests and migrates golem15.user, golem15.feedback and +// the framework tables (system_files, the job queue) into it. The test +// binary must link the golem15.feedback plugin (its package or an +// importer of it), which registers itself in init. +package pgtest + +import ( + "bytes" + "context" + "database/sql" + "os" + "path/filepath" + "sync" + "testing" + "time" + + _ "git.golem15.com/golem15/sm-user-plugin" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "gorm.io/gorm" +) + +// PluginIDs are the plugins the database is migrated for. +var PluginIDs = []string{"golem15.user", "golem15.feedback"} + +var ( + once sync.Once + gdb *gorm.DB + sqlDB *sql.DB + err error +) + +// DB returns the shared, migrated database. Tests that need an empty +// table delete its rows first. +func DB(t testing.TB) *gorm.DB { + t.Helper() + once.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + if err = lagoon.PublishEncryptionKeys(nil, bytes.Repeat([]byte("F"), 32), nil); err != nil { + return + } + var ctr *postgres.PostgresContainer + ctr, err = postgres.Run(ctx, "postgres:16-alpine", + postgres.WithDatabase("feedback"), + postgres.WithUsername("feedback"), + postgres.WithPassword("feedback"), + postgres.BasicWaitStrategies(), + ) + if err != nil { + return + } + var dsn string + if dsn, err = ctr.ConnectionString(ctx, "sslmode=disable"); err != nil { + return + } + if sqlDB, err = sql.Open("pgx", dsn); err != nil { + return + } + if gdb, err = lagoon.Use(ctx, sqlDB); err != nil { + return + } + var dir string + if dir, err = os.MkdirTemp("", "feedback-pgtest-"); err != nil { + return + } + defer os.RemoveAll(dir) + if err = os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: feedback-pgtest\n"), 0o644); err != nil { + return + } + var cfg *compass.Config + if cfg, err = compass.Open(compass.Options{Dir: dir, Environ: []string{ + "SUMMER_ENV=development", + // A throwaway secret: golem15.user refuses to boot without one. + "SUMMER_GOLEM15__USER__JWT__SECRET=pgtest-only-jwt-secret-0123456789abcdef", + }}); err != nil { + return + } + var plugins []party.Plugin + if plugins, err = party.Activate(backpack.New(cfg), PluginIDs); err != nil { + return + } + err = lagoon.Migrate(gdb, plugins) + }) + if err != nil { + t.Fatalf("pgtest: %v", err) + } + return gdb +} + +// SQL is the database's *sql.DB. +func SQL(t testing.TB) *sql.DB { + DB(t) + return sqlDB +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml new file mode 100644 index 0000000..5ef12fa --- /dev/null +++ b/lang/en/lang.yaml @@ -0,0 +1,55 @@ +plugin: + name: Feedback + description: Embeddable feedback widget — screenshot + message, filed as a g15office task. + tab: Feedback + menu_label: Feedback +settings: + menu_label: Feedback Widget + settings_description: Configure the embeddable feedback widget and its g15office connection. + manage_settings: Manage Feedback Widget Settings + tab_general: General + tab_appearance: Appearance + tab_g15office: g15office + enabled: Widget enabled + widget_key: Widget key + widget_key_comment: Public identifier embedded in the snippet (data-key). Not a secret — access is gated by the allowed-origins list below. + allow_hide: Allow visitors to hide the widget + allow_hide_comment: Adds a "Hide widget" link inside the panel. Only enable this once the frontend has a way for the visitor to re-enable it (e.g. a Help page toggle) — otherwise a visitor who hides it has no way back. + position: Position + position_bottom_right: Bottom right + position_bottom_left: Bottom left + position_middle_right: Middle right (edge tab) + position_middle_left: Middle left (edge tab) + allowed_origins: Allowed origins + allowed_origins_comment: One domain per line, e.g. example.com. Submissions from any other Origin are rejected. + color_primary: Primary color + color_primary_comment: "A CSS color, e.g. #6366f1." + color_accent: Accent color + color_accent_comment: "A CSS color, e.g. #ffffff." + label_title_pl: Widget title (pl) + label_title_en: Widget title (en) + label_placeholder_pl: Message placeholder (pl) + label_placeholder_en: Message placeholder (en) + label_success_pl: Success message (pl) + label_success_en: Success message (en) + g15office_task_status: g15office task status + g15office_task_status_comment: Leave blank to use the project's default status. + g15office_task_priority: g15office task priority + priority_low: Low + priority_normal: Normal + priority_high: High + priority_urgent: Urgent +submissions: + menu_label: Submissions + list_title: Feedback submissions + id: ID + type: Type + status: Status + email: Reporter email + page_url: Page + message: Message + user_agent: User agent + console_log: Console log + g15office_task_id: g15office task + g15office_error: g15office error + created_at: Sent at diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml new file mode 100644 index 0000000..52dbef5 --- /dev/null +++ b/lang/pl/lang.yaml @@ -0,0 +1,55 @@ +plugin: + name: Opinie + description: Osadzany widget opinii/zgłoszeń — zrzut ekranu + wiadomość, zapisywane jako zadanie w g15office. + tab: Opinie + menu_label: Opinie +settings: + menu_label: Widget opinii + settings_description: Konfiguracja widgetu opinii i połączenia z g15office. + manage_settings: Zarządzaj ustawieniami widgetu opinii + tab_general: Ogólne + tab_appearance: Wygląd + tab_g15office: g15office + enabled: Widget włączony + widget_key: Klucz widgetu + widget_key_comment: Publiczny identyfikator osadzany w snippetcie (data-key). Nie jest sekretem — dostęp ogranicza lista dozwolonych domen poniżej. + allow_hide: Pozwól odwiedzającym ukryć widget + allow_hide_comment: Dodaje link "Ukryj widget" wewnątrz panelu. Włącz tylko wtedy, gdy frontend ma sposób na ponowne włączenie widgetu (np. przełącznik na stronie Pomoc) — w przeciwnym razie odwiedzający, który go ukryje, nie będzie mógł go z powrotem włączyć. + position: Pozycja + position_bottom_right: Prawy dolny róg + position_bottom_left: Lewy dolny róg + position_middle_right: Środek, prawa krawędź + position_middle_left: Środek, lewa krawędź + allowed_origins: Dozwolone domeny + allowed_origins_comment: Jedna domena na linię, np. example.com. Zgłoszenia z innej domeny (Origin) są odrzucane. + color_primary: Kolor główny + color_primary_comment: "Kolor CSS, np. #6366f1." + color_accent: Kolor akcentu + color_accent_comment: "Kolor CSS, np. #ffffff." + label_title_pl: Tytuł widgetu (pl) + label_title_en: Tytuł widgetu (en) + label_placeholder_pl: Placeholder wiadomości (pl) + label_placeholder_en: Placeholder wiadomości (en) + label_success_pl: Komunikat po wysłaniu (pl) + label_success_en: Komunikat po wysłaniu (en) + g15office_task_status: Status zadania w g15office + g15office_task_status_comment: Zostaw puste, aby użyć domyślnego statusu projektu. + g15office_task_priority: Priorytet zadania w g15office + priority_low: Niski + priority_normal: Normalny + priority_high: Wysoki + priority_urgent: Pilny +submissions: + menu_label: Zgłoszenia + list_title: Zgłoszenia z widgetu opinii + id: ID + type: Typ + status: Status + email: E-mail zgłaszającego + page_url: Strona + message: Wiadomość + user_agent: User agent + console_log: Log konsoli + g15office_task_id: Zadanie g15office + g15office_error: Błąd g15office + created_at: Wysłano diff --git a/models/registry.go b/models/registry.go new file mode 100644 index 0000000..b49c0aa --- /dev/null +++ b/models/registry.go @@ -0,0 +1,16 @@ +// Package models holds the golem15.feedback GORM models. It is a leaf +// package: it imports no other package of the plugin. +package models + +var all []any + +// Register appends GORM models, so a model file adds itself without +// editing plugin.go. +func Register(models ...any) { + all = append(all, models...) +} + +// All returns every model registered by this package. +func All() []any { + return all +} diff --git a/models/settings.go b/models/settings.go new file mode 100644 index 0000000..e555999 --- /dev/null +++ b/models/settings.go @@ -0,0 +1,316 @@ +package models + +import ( + "context" + "crypto/rand" + "errors" + "math/big" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// SettingsID is the primary key of the one settings row. +const SettingsID = 1 + +// SettingsCode is the WinterCMS settings code of the plugin +// (system_settings.item), which feedback:import-settings reads. +const SettingsCode = "golem15_feedback_settings" + +// Settings is the golem15_feedback_settings singleton (id 1), the typed Go +// form of the WinterCMS SettingsModel stored under SettingsCode. Defaults +// are Settings::initSettingsData; WidgetKey is generated once, when the row +// is first created (wk_ plus 32 random letters and digits). +type Settings struct { + ID uint `gorm:"column:id;primaryKey"` + Enabled bool `gorm:"column:enabled"` + WidgetKey string `gorm:"column:widget_key"` + AllowHide bool `gorm:"column:allow_hide"` + Position *string `gorm:"column:position"` + AllowedOrigins *string `gorm:"column:allowed_origins"` + ColorPrimary *string `gorm:"column:color_primary"` + ColorAccent *string `gorm:"column:color_accent"` + LabelTitlePL *string `gorm:"column:label_title_pl"` + LabelTitleEN *string `gorm:"column:label_title_en"` + LabelPlaceholderPL *string `gorm:"column:label_placeholder_pl"` + LabelPlaceholderEN *string `gorm:"column:label_placeholder_en"` + LabelSuccessPL *string `gorm:"column:label_success_pl"` + LabelSuccessEN *string `gorm:"column:label_success_en"` + G15OfficeTaskStatus *string `gorm:"column:g15office_task_status"` + G15OfficeTaskPriority *string `gorm:"column:g15office_task_priority"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +// TableName is golem15_feedback_settings. +func (Settings) TableName() string { return "golem15_feedback_settings" } + +// Fillable is every column the settings form writes. +func (Settings) Fillable() []string { + return []string{ + "enabled", "widget_key", "allow_hide", "position", "allowed_origins", + "color_primary", "color_accent", + "label_title_pl", "label_title_en", "label_placeholder_pl", "label_placeholder_en", + "label_success_pl", "label_success_en", + "g15office_task_status", "g15office_task_priority", + } +} + +// Rules are Settings::$rules: every one nullable, the enums checked with in. +func (Settings) Rules() map[string]string { + return map[string]string{ + "enabled": "nullable|boolean", + "allow_hide": "nullable|boolean", + "allowed_origins": "nullable|string", + "color_primary": "nullable|string|max:32", + "color_accent": "nullable|string|max:32", + "position": "nullable|in:bottom-right,bottom-left,middle-right,middle-left", + "label_title_pl": "nullable|string|max:120", + "label_title_en": "nullable|string|max:120", + "label_placeholder_pl": "nullable|string|max:200", + "label_placeholder_en": "nullable|string|max:200", + "label_success_pl": "nullable|string|max:200", + "label_success_en": "nullable|string|max:200", + "g15office_task_status": "nullable|string|max:100", + "g15office_task_priority": "nullable|in:low,normal,high,urgent", + } +} + +// BeforeSave keeps a widget key on the row: a row created without one (the +// admin form's first save, Instance) or saved with an emptied one gets a +// fresh key. The key is public (it ships in the embed snippet) but must be +// unguessable and unique per install. +func (s *Settings) BeforeSave(*gorm.DB) error { + if strings.TrimSpace(s.WidgetKey) != "" { + return nil + } + key, err := NewWidgetKey() + if err != nil { + return err + } + s.WidgetKey = key + return nil +} + +// DefaultSettings returns the row Settings::initSettingsData builds, without +// a widget key (BeforeSave generates it). +func DefaultSettings() Settings { + str := func(s string) *string { return &s } + return Settings{ + ID: SettingsID, + Enabled: true, + AllowHide: false, + Position: str("bottom-right"), + G15OfficeTaskPriority: str("normal"), + LabelTitlePL: str("Zgłoś opinię"), + LabelTitleEN: str("Send feedback"), + LabelPlaceholderPL: str("Co poszło nie tak?"), + LabelPlaceholderEN: str("What went wrong?"), + LabelSuccessPL: str("Dzięki za zgłoszenie!"), + LabelSuccessEN: str("Thanks for the feedback!"), + } +} + +// Instance is Settings::instance(): the settings row, created with the +// defaults (and a new widget key) on first access, as WinterCMS persists +// initSettingsData. Two first accesses at once keep one row. +func Instance(ctx context.Context, db *gorm.DB) (*Settings, error) { + if db == nil { + return nil, errors.New("feedback: database is not configured") + } + q := db.WithContext(ctx) + read := func() (*Settings, error) { + var rows []Settings + if err := q.Where("id = ?", SettingsID).Limit(1).Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + return &rows[0], nil + } + s, err := read() + if err != nil || s != nil { + return s, err + } + fresh := DefaultSettings() + if err := q.Clauses(clause.OnConflict{DoNothing: true}).Create(&fresh).Error; err != nil { + return nil, err + } + s, err = read() + if err == nil && s == nil { + err = errors.New("feedback: the settings row was not created") + } + return s, err +} + +// widgetKeyAlphabet is Str::random's alphabet. +const widgetKeyAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// NewWidgetKey returns wk_ plus 32 random letters and digits from +// crypto/rand ('wk_' . Str::random(32)). +func NewWidgetKey() (string, error) { + var b strings.Builder + b.WriteString("wk_") + max := big.NewInt(int64(len(widgetKeyAlphabet))) + for i := 0; i < 32; i++ { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + b.WriteByte(widgetKeyAlphabet[n.Int64()]) + } + return b.String(), nil +} + +// Labels is the label triple of the config response. +type Labels struct { + Title *string `json:"title"` + Placeholder *string `json:"placeholder"` + Success *string `json:"success"` +} + +// LabelsForLocale is Settings::labelsForLocale: the English labels for +// "en", the Polish ones for anything else. +func (s *Settings) LabelsForLocale(locale string) Labels { + if locale == "en" { + return Labels{Title: s.LabelTitleEN, Placeholder: s.LabelPlaceholderEN, Success: s.LabelSuccessEN} + } + return Labels{Title: s.LabelTitlePL, Placeholder: s.LabelPlaceholderPL, Success: s.LabelSuccessPL} +} + +// AllowedOriginHosts is Settings::getAllowedOriginHosts: one entry per line +// of allowed_origins, trimmed, blank lines (and a lone "0", which PHP's +// filter() drops) skipped, each reduced to its URL host when it has one +// (PHP parse_url) or kept as written, lower-cased, without duplicates, in +// order. https://example.com/ and //example.com give example.com, while +// example.com/ stays example.com/ and so matches no Origin, as in PHP. +func (s *Settings) AllowedOriginHosts() []string { + if s == nil || s.AllowedOrigins == nil { + return nil + } + lines := strings.FieldsFunc(*s.AllowedOrigins, func(r rune) bool { return r == '\r' || r == '\n' }) + out := make([]string, 0, len(lines)) + seen := map[string]bool{} + for _, line := range lines { + line = strings.Trim(line, " \t\n\r\x00\x0B") + if line == "" || line == "0" { + continue + } + host, ok := URLHost(line) + if !ok || host == "" { + host = line + } + host = ASCIILower(host) + if seen[host] { + continue + } + seen[host] = true + out = append(out, host) + } + return out +} + +// URLHost is parse_url($s, PHP_URL_HOST) for the inputs an Origin header or +// an allowed-origins line holds. With a scheme (or a leading //) the host +// is the authority without user info and port; an IPv6 literal keeps its +// brackets; a port that is not a number up to 65535 makes the whole URL +// invalid. Without a scheme, PHP reads "host:port" (optionally followed by +// a path) as a host and a port. ok is false when PHP returns null or false. +func URLHost(s string) (string, bool) { + rest, hasAuthority := "", false + if strings.HasPrefix(s, "//") { + rest, hasAuthority = s[2:], true + } else if i := strings.Index(s, "://"); i > 0 && validScheme(s[:i]) { + rest, hasAuthority = s[i+3:], true + } + if !hasAuthority { + return hostPortOnly(s) + } + authority := rest + if i := strings.IndexAny(authority, "/?#"); i >= 0 { + authority = authority[:i] + } + if i := strings.LastIndex(authority, "@"); i >= 0 { + authority = authority[i+1:] + } + host, port := authority, "" + if strings.HasPrefix(authority, "[") { + end := strings.Index(authority, "]") + if end < 0 { + return "", false + } + host, port = authority[:end+1], strings.TrimPrefix(authority[end+1:], ":") + if authority[end+1:] != "" && !strings.HasPrefix(authority[end+1:], ":") { + return "", false + } + } else if i := strings.LastIndex(authority, ":"); i >= 0 { + host, port = authority[:i], authority[i+1:] + } + if port != "" && !validPort(port) { + return "", false + } + if host == "" { + return "", false + } + return host, true +} + +// ASCIILower is PHP 8 strtolower: ASCII letters only. +func ASCIILower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + 'a' - 'A' + } + } + return string(b) +} + +func hostPortOnly(s string) (string, bool) { + i := strings.Index(s, ":") + if i <= 0 { + return "", false + } + host, rest := s[:i], s[i+1:] + if strings.ContainsAny(host, "/?#") { + return "", false + } + port := rest + if j := strings.Index(rest, "/"); j >= 0 { + port = rest[:j] + } + if port == "" || !validPort(port) { + return "", false + } + return host, true +} + +func validScheme(s string) bool { + for i, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + case i > 0 && (r >= '0' && r <= '9' || r == '+' || r == '-' || r == '.'): + default: + return false + } + } + return s != "" +} + +func validPort(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + n, err := strconv.Atoi(s) + return err == nil && n <= 65535 +} + +func init() { + Register(Settings{}) +} diff --git a/models/submission.go b/models/submission.go new file mode 100644 index 0000000..cbb0d6c --- /dev/null +++ b/models/submission.go @@ -0,0 +1,96 @@ +package models + +import ( + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon/attach" +) + +// Submission statuses (FeedbackSubmission::STATUS_*). +const ( + StatusPending = "pending" + StatusSent = "sent" + StatusFailed = "failed" +) + +// Submission types (FeedbackSubmission::TYPE_*). +const ( + TypeBug = "bug" + TypeFeature = "feature" + TypeOther = "other" +) + +// ScreenshotField is the attachOne relation of the screenshot. +const ScreenshotField = "screenshot" + +// G15OfficeTypeMap is FeedbackSubmission::G15OFFICE_TYPE_MAP: the widget +// type to the g15office task type name ("other" is the generic Task). +var G15OfficeTypeMap = map[string]string{ + TypeBug: "Bug", + TypeFeature: "Feature", + TypeOther: "Task", +} + +// Submission is one feedback report (golem15_feedback_submissions), the Go +// form of FeedbackSubmission. Its screenshot is a public attachOne file. +type Submission struct { + ID uint `gorm:"column:id;primaryKey"` + Message string `gorm:"column:message"` + Type string `gorm:"column:type"` + Email *string `gorm:"column:email"` + PageURL string `gorm:"column:page_url"` + UserAgent *string `gorm:"column:user_agent"` + ConsoleLog *string `gorm:"column:console_log"` + Status string `gorm:"column:status"` + G15OfficeTaskID *string `gorm:"column:g15office_task_id"` + G15OfficeError *string `gorm:"column:g15office_error"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +var ( + _ attach.Owner = Submission{} + _ attach.HasRelations = Submission{} +) + +// TableName is golem15_feedback_submissions. +func (Submission) TableName() string { return "golem15_feedback_submissions" } + +// MorphName is the PHP class stored in system_files.attachment_type. +func (Submission) MorphName() string { return `Golem15\Feedback\Models\FeedbackSubmission` } + +// AttachRelations declares the public attachOne screenshot. +func (Submission) AttachRelations() []attach.Relation { + return []attach.Relation{{Name: ScreenshotField, Public: true}} +} + +// Fillable is FeedbackSubmission::$fillable. +func (Submission) Fillable() []string { + return []string{ + "message", "type", "email", "page_url", "user_agent", "console_log", + "status", "g15office_task_id", "g15office_error", + } +} + +// Rules are FeedbackSubmission::$rules. +func (Submission) Rules() map[string]string { + return map[string]string{ + "message": "required|string|max:5000", + "type": "required|in:bug,feature,other", + "email": "nullable|email|max:255", + "page_url": "required|string|max:2000", + } +} + +// G15OfficeTaskType is FeedbackSubmission::g15officeTaskType: the task type +// of the submission's widget type, Task for an unknown one. +func (s Submission) G15OfficeTaskType() string { + if t, ok := G15OfficeTypeMap[s.Type]; ok { + return t + } + return G15OfficeTypeMap[TypeOther] +} + +func init() { + Register(Submission{}) +} diff --git a/models/user_preference.go b/models/user_preference.go new file mode 100644 index 0000000..557c015 --- /dev/null +++ b/models/user_preference.go @@ -0,0 +1,54 @@ +package models + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UserPreference is one user's widget preference +// (golem15_feedback_user_preferences, one row per user who ever changed +// it). The hidden column is the signal, not the row: a row may hold +// hidden=false after the user showed the widget again. +type UserPreference struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + Hidden bool `gorm:"column:hidden"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +// TableName is golem15_feedback_user_preferences. +func (UserPreference) TableName() string { return "golem15_feedback_user_preferences" } + +// IsWidgetHidden is UserPreference::isWidgetHidden: the user's hidden flag, +// false without a row. +func IsWidgetHidden(ctx context.Context, db *gorm.DB, userID uint) (bool, error) { + var p UserPreference + err := db.WithContext(ctx).Select("hidden").Where("user_id = ?", userID).Take(&p).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return p.Hidden, nil +} + +// SetWidgetHidden is UserPreference::setWidgetHidden (updateOrCreate on +// user_id): one atomic upsert of the user's row. +func SetWidgetHidden(ctx context.Context, db *gorm.DB, userID uint, hidden bool) error { + now := time.Now() + row := UserPreference{UserID: userID, Hidden: hidden, CreatedAt: now, UpdatedAt: now} + return db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"hidden", "updated_at"}), + }).Create(&row).Error +} + +func init() { + Register(UserPreference{}) +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..b99c02d --- /dev/null +++ b/plugin.go @@ -0,0 +1,98 @@ +// Package feedback is the golem15.feedback plugin: the embeddable feedback +// widget of SummerCMS applications. It ports the WinterCMS Golem15.Feedback +// plugin: the widget's public config and submit routes behind a widget key +// and an Origin allow-list, the per-user "hide the widget" preference, the +// settings screen, and the job that files every submission as a G15Office +// task with its screenshot. +package feedback + +import ( + "embed" + "io/fs" + "net/http" + "time" + + "git.golem15.com/golem15/sm-feedback-plugin/models" + "git.golem15.com/golem15/sm-feedback-plugin/updates" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) +) + +//go:embed config +var configFS embed.FS + +//go:embed lang +var langFS embed.FS + +// Bucket names of the widget routes (routes.php RateLimiter::for). +const ( + BucketConfig = "feedback-config" + BucketSubmit = "feedback-submit" +) + +// Plugin is the golem15.feedback plugin. +type Plugin struct { + app *backpack.App +} + +// ID is the plugin ID, also its config and lang namespace. +func (p *Plugin) ID() string { return "golem15.feedback" } + +// Requires is golem15.user: the preference route is JWT-guarded and the +// preference rides on the user payload. +func (p *Plugin) Requires() []string { return []string{"golem15.user"} } + +// Register keeps the application. +func (p *Plugin) Register(app *backpack.App) error { + p.app = app + return nil +} + +// Boot keeps the application. +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + return nil +} + +// ConfigFS ships config/config.yaml, merged under golem15.feedback.*. +func (p *Plugin) ConfigFS() fs.FS { return configFS } + +// LangFS ships the golem15.feedback lang namespace (en, pl). +func (p *Plugin) LangFS() fs.FS { return langFS } + +// Migrations creates the submissions, preferences and settings tables. +func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } + +// Models is Submission, UserPreference and Settings. +func (p *Plugin) Models() []any { return models.All() } + +// Buckets are the widget routes' limits by client IP (behind the trusted +// proxies): 60 config reads and 10 submissions a minute. +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + byIP := func(r *http.Request) string { return surf.ClientIP(r, trusted) } + return map[string]surf.Bucket{ + BucketConfig: {Max: 60, Decay: time.Minute, Key: byIP}, + BucketSubmit: {Max: 10, Decay: time.Minute, Key: byIP}, + } +} + +func init() { + party.Register(&Plugin{}) +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..a91c7c0 --- /dev/null +++ b/routes.go @@ -0,0 +1,18 @@ +package feedback + +import ( + "git.golem15.com/golem15/sm-feedback-plugin/controllers/api" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Routes mounts the widget API. The public group has no auth: the widget +// key in the path and the Origin allow-list are its gate, backed by the +// per-IP buckets. CORS preflights (OPTIONS) on _feedback/api/* are answered +// by surf's path-scoped CORS layer, which the application's http.cors.paths +// must list, so no OPTIONS route is registered. +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/_feedback/api/v1", nil, func(g pact.Router) { + g.Get("/{key}/config", api.Config(p.app), "throttle:"+BucketConfig) + }) + return nil +} diff --git a/updates/01_feedback.go b/updates/01_feedback.go new file mode 100644 index 0000000..44a1579 --- /dev/null +++ b/updates/01_feedback.go @@ -0,0 +1,95 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// The WinterCMS plugin's three migrations (create_feedback_submissions_table, +// add_type_and_locale_labels, create_user_preferences_table) give the two +// tables below; the type column is folded into the create. String columns +// are TEXT, as in every Go port (PHP's 255-character user_agent column +// would refuse the 500 characters its own validation allows on Postgres). +var submissionsMigration = &gormigrate.Migration{ + ID: "202610030201_create_feedback_submissions", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_feedback_submissions ( + id SERIAL PRIMARY KEY, + message TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'bug', + email TEXT, + page_url TEXT NOT NULL, + user_agent TEXT, + console_log TEXT, + status TEXT NOT NULL DEFAULT 'pending', + g15office_task_id TEXT, + g15office_error TEXT, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +)`, + `CREATE INDEX golem15_feedback_submissions_status_index ON golem15_feedback_submissions (status)`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_feedback_submissions`).Error + }, +} + +var userPreferencesMigration = &gormigrate.Migration{ + ID: "202610030202_create_feedback_user_preferences", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE golem15_feedback_user_preferences ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + hidden BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + CONSTRAINT golem15_feedback_user_preferences_user_id_unique UNIQUE (user_id) +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_feedback_user_preferences`).Error + }, +} + +// settingsMigration creates the typed settings singleton (D-18): the +// WinterCMS plugin kept these values in its system_settings row +// golem15_feedback_settings, which feedback:import-settings copies once. +var settingsMigration = &gormigrate.Migration{ + ID: "202610030203_create_feedback_settings", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE golem15_feedback_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + widget_key TEXT NOT NULL, + allow_hide BOOLEAN NOT NULL DEFAULT FALSE, + position TEXT, + allowed_origins TEXT, + color_primary TEXT, + color_accent TEXT, + label_title_pl TEXT, + label_title_en TEXT, + label_placeholder_pl TEXT, + label_placeholder_en TEXT, + label_success_pl TEXT, + label_success_en TEXT, + g15office_task_status TEXT, + g15office_task_priority TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_feedback_settings`).Error + }, +} + +func init() { + Register(submissionsMigration, userPreferencesMigration, settingsMigration) +} diff --git a/updates/registry.go b/updates/registry.go new file mode 100644 index 0000000..025300c --- /dev/null +++ b/updates/registry.go @@ -0,0 +1,17 @@ +// Package updates holds the golem15.feedback migrations. +package updates + +import "github.com/go-gormigrate/gormigrate/v2" + +var all []*gormigrate.Migration + +// Register appends gormigrate steps, so a migration file adds itself +// without editing plugin.go. +func Register(ms ...*gormigrate.Migration) { + all = append(all, ms...) +} + +// All returns every migration registered by this package, in order. +func All() []*gormigrate.Migration { + return all +}