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

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