- 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
317 lines
10 KiB
Go
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{})
|
|
}
|