Files
sm-feedback-plugin/controllers/api/feedback_api_controller.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

123 lines
3.5 KiB
Go

// Package api holds the golem15.feedback HTTP handlers: the public widget
// routes (config and submit) and the JWT preference route.
package api
import (
"crypto/subtle"
"net/http"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"gorm.io/gorm"
)
// configBody is FeedbackApiController::config's answer, in PHP's key order.
type configBody struct {
Success bool `json:"success"`
Data configData `json:"data"`
}
type configData struct {
Position *string `json:"position"`
AllowHide bool `json:"allowHide"`
Colors configColors `json:"colors"`
Labels models.Labels `json:"labels"`
}
type configColors struct {
Primary *string `json:"primary"`
Accent *string `json:"accent"`
}
// Config handles GET /_feedback/api/v1/{key}/config: the widget's position,
// colors and labels (English for ?lang=en, Polish otherwise) once the key
// and the Origin pass the widget gate.
func Config(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, ok := gate(w, r, app)
if !ok {
return
}
writeJSON(w, http.StatusOK, configBody{
Success: true,
Data: configData{
Position: settings.Position,
AllowHide: settings.AllowHide,
Colors: configColors{Primary: settings.ColorPrimary, Accent: settings.ColorAccent},
Labels: settings.LabelsForLocale(r.URL.Query().Get("lang")),
},
})
}
}
// gate loads the settings (creating them on first access) and runs the
// widget checks of both public routes in PHP's order: the key (404
// "Not found") then the Origin (403 "Origin not allowed").
func gate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.Settings, bool) {
gdb := database(app)
if gdb == nil {
writeOpaque500(w)
return nil, false
}
settings, err := models.Instance(r.Context(), gdb)
if err != nil {
writeOpaque500(w)
return nil, false
}
if !KeyMatches(settings, r.PathValue("key")) {
writeError(w, http.StatusNotFound, "Not found")
return nil, false
}
if !OriginAllowed(r.Header.Get("Origin"), settings) {
writeError(w, http.StatusForbidden, "Origin not allowed")
return nil, false
}
return settings, true
}
// KeyMatches is FeedbackApiController::keyMatches: the widget is enabled,
// has a key, and key equals it, compared in constant time (hash_equals).
func KeyMatches(settings *models.Settings, key string) bool {
if settings == nil || !settings.Enabled || settings.WidgetKey == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(settings.WidgetKey), []byte(key)) == 1
}
// OriginAllowed is FeedbackApiController::isOriginAllowed. No Origin (curl,
// a server, a same-origin request) passes. An Origin passes only when its
// URL host, lower-cased, is on the settings' allowed-origins list; an
// Origin without a host fails, and an empty list fails every Origin, so a
// widget that was never configured cannot be embedded anywhere.
func OriginAllowed(origin string, settings *models.Settings) bool {
if origin == "" {
return true
}
host, ok := models.URLHost(origin)
if !ok || host == "" {
return false
}
allowed := settings.AllowedOriginHosts()
if len(allowed) == 0 {
return false
}
host = models.ASCIILower(host)
for _, a := range allowed {
if a == host {
return true
}
}
return false
}
func database(app *backpack.App) *gorm.DB {
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil
}
return gdb
}