Files
sm-feedback-plugin/models/settings.go
Jakub Zych b4b4135ad3 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
2026-10-04 00:01:22 +02:00

317 lines
10 KiB
Go

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