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