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
This commit is contained in:
Jakub Zych
2026-10-04 00:01:22 +02:00
commit b4b4135ad3
18 changed files with 2084 additions and 0 deletions

90
README.md Normal file
View File

@@ -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)
```

8
config/config.yaml Normal file
View File

@@ -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: ""

View File

@@ -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
}

View File

@@ -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
}

361
feedback_test.go Normal file
View File

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

123
go.mod Normal file
View File

@@ -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

362
go.sum Normal file
View File

@@ -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=

99
internal/pgtest/pgtest.go Normal file
View File

@@ -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
}

55
lang/en/lang.yaml Normal file
View File

@@ -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

55
lang/pl/lang.yaml Normal file
View File

@@ -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

16
models/registry.go Normal file
View File

@@ -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
}

316
models/settings.go Normal file
View File

@@ -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{})
}

96
models/submission.go Normal file
View File

@@ -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{})
}

54
models/user_preference.go Normal file
View File

@@ -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{})
}

98
plugin.go Normal file
View File

@@ -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{})
}

18
routes.go Normal file
View File

@@ -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
}

95
updates/01_feedback.go Normal file
View File

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

17
updates/registry.go Normal file
View File

@@ -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
}