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:
16
models/registry.go
Normal file
16
models/registry.go
Normal 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
316
models/settings.go
Normal 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
96
models/submission.go
Normal 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
54
models/user_preference.go
Normal 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{})
|
||||
}
|
||||
Reference in New Issue
Block a user