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:
122
controllers/api/feedback_api_controller.go
Normal file
122
controllers/api/feedback_api_controller.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// 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
|
||||
}
|
||||
99
controllers/api/winter_errors.go
Normal file
99
controllers/api/winter_errors.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// writeJSON answers like Laravel's response()->json behind WinterCMS's
|
||||
// json.response middleware: the JSON body with Cache-Control
|
||||
// "no-cache, private". The plugin keeps its own writers because a plugin in
|
||||
// its own repository cannot import an application's controllers.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Cache-Control", "no-cache, private")
|
||||
wire.WriteJSON(w, status, v)
|
||||
}
|
||||
|
||||
// writeError is the widget routes' {"error":true,"message":...} envelope.
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, errorBody{Error: true, Message: message})
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// writeOpaque500 is the house opaque 500 for a failure that is not the
|
||||
// client's (no database, a failed write).
|
||||
func writeOpaque500(w http.ResponseWriter) {
|
||||
wire.WriteOpaque500(w)
|
||||
}
|
||||
|
||||
// orderedErrors marshals a Laravel message bag with its keys in rule order
|
||||
// (lagoon.ErrorKeys), the remaining keys sorted after them.
|
||||
type orderedErrors struct {
|
||||
errs map[string][]string
|
||||
order []string
|
||||
}
|
||||
|
||||
func (o orderedErrors) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
seen := map[string]bool{}
|
||||
first := true
|
||||
write := func(k string) error {
|
||||
msgs, ok := o.errs[k]
|
||||
if !ok || seen[k] {
|
||||
return nil
|
||||
}
|
||||
seen[k] = true
|
||||
if !first {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
first = false
|
||||
kb, err := marshalNoEscape(k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vb, err := marshalNoEscape(msgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(kb)
|
||||
buf.WriteByte(':')
|
||||
buf.Write(vb)
|
||||
return nil
|
||||
}
|
||||
for _, k := range o.order {
|
||||
if err := write(k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rest := make([]string, 0, len(o.errs))
|
||||
for k := range o.errs {
|
||||
rest = append(rest, k)
|
||||
}
|
||||
sort.Strings(rest)
|
||||
for _, k := range rest {
|
||||
if err := write(k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func marshalNoEscape(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
Reference in New Issue
Block a user