Jakub Zych 5a8b7f816f fix(14): WR-02 derive the screenshot extension from the sniffed image type
The public screenshot kept the client file's extension, so a valid image
named x.html or x.svg was stored and served under that type. The disk
name now carries the client extension only when it names the sniffed
type, else the type's own; file_name keeps only the client's base name.
2026-10-04 07:59:59 +02:00

golem15.feedback

The shared feedback-widget plugin for SummerCMS applications: an embeddable widget's public config and submit API behind a widget key and an Origin allow-list, a per-user "hide the widget" preference, the widget settings, and a job that files every submission as a G15Office task.

import _ "git.golem15.com/golem15/sm-feedback-plugin"

Module git.golem15.com/golem15/sm-feedback-plugin, package feedback, plugin ID golem15.feedback.

Overview

The plugin is a compiled SummerCMS plugin ported from the WinterCMS Golem15.Feedback plugin. Its init registers it with the framework, so an application loads it by listing it in summer.yaml; summer build then emits the blank import above into the application's plugins.gen.go. It requires golem15.user.

The widget routes are public. The widget key in the URL is a public identifier (it ships in the embed snippet), not a secret: the real gate is the Origin check against the operator's allowed-origins list, backed by per-IP rate limits.

The plugin ID, the table names, the config keys and the settings code are a contract shared by every application that mounts the plugin: change them only in a backwards-compatible way.

Features

  • Widget config. GET /_feedback/api/v1/{key}/config answers the widget's position, whether visitors may hide it, its colors and its labels (?lang=en selects the English labels, anything else the Polish ones). A disabled widget, an empty key or a key that differs (compared in constant time) is 404 {"error":true,"message":"Not found"}.
  • Origin gate. A request without an Origin header passes. An Origin passes only when its URL host, lower-cased, is on the allowed-origins list (one entry per line; a line is reduced to its URL host when it has one). An empty list refuses every Origin with 403 {"error":true,"message":"Origin not allowed"}, so a widget that was never configured cannot be embedded anywhere.
  • Submissions. POST /_feedback/api/v1/{key}/submit (multipart, from the widget) passes the same key and Origin gate, then validates the report as the WinterCMS plugin does: message (required, up to 5000 characters), type (bug, feature or other), email (optional, up to 255), page_url (required, up to 2000), user_agent (up to 500), console_log (up to 20000) and screenshot (an optional JPEG, PNG, GIF or WebP of up to 10240 KB). A failure is 422 {"error":true,"message":"Validation failed","details":{...}}. A screenshot whose bytes do not decode as one of those images (classes.IsAllowedImage) is 422 with details.screenshot = ["The file is not a valid image."]. The submission (status pending), its public screenshot (attachOne screenshot) and its G15Office job are written in one transaction, and the answer is 202 {"success":true}: the visitor never waits on G15Office.
  • Hiding the widget. PUT /_feedback/api/v1/me/hidden (behind golem15.user's jwt.auth) stores the signed-in user's preference: hidden must be a Laravel boolean (true, false, 1, 0, "1", "0"), else 422 {"error":"Validation failed","errors":{...}}. The answer is {"hidden":bool}. Every golem15.user payload (fetch, login, update) carries feedback_widget_hidden through a GetApiArrayEvent listener: the stored value, false without a row.
  • CORS. The widget's preflights are answered by the framework's path-scoped CORS layer, so the plugin registers no OPTIONS route.
  • Rate limits. The named buckets feedback-config (60 a minute) and feedback-submit (10 a minute) count per client IP behind the configured trusted proxies.
  • Settings. models.Settings is the singleton row of golem15_feedback_settings. models.Instance creates it on first access with the WinterCMS defaults and a new widget key (wk_ plus 32 random letters and digits). Operators edit it under Settings › Feedback Widget (code feedback, permission golem15.feedback.manage_settings): the WinterCMS form with cabana's field types, the colors as text fields and the widget key read-only. A row saved without a key gets a new one.
  • G15Office tasks. Every submission queues a classes.SyncG15OfficeArgs job (kind golem15.feedback.sync_g15office, queue feedback, 3 attempts, a summer_jobs row labelled with the WinterCMS job class). classes.SyncG15Office files it with classes.G15OfficeClient: POST <base>/_support/api/v1/projects/<project>/tasks with the title (Feedback: and the message collapsed and cut to 80 columns), a Markdown description (the message, the page, the reporter email and user agent, the console log cut to 4000 columns), the task type (bug → Bug, feature → Feature, other → Task), the configured task status and priority; then POST <base>/_support/api/v1/tasks/<id>/attachments with the screenshot as the multipart field file. Requests carry Authorization: Bearer <token> and Accept: application/json and go through a fetchguard.Client limited to the base URL's host (https only). Success marks the submission sent with the task id; a failure marks it failed with the error and the job is retried. Without a base URL, token and project nothing is sent: the job fails with the WinterCMS "not configured" message.
  • Submissions list. Admin › Feedback › Submissions (controller golem15.feedback.submissions) is a read-only list: type, status, reporter email, page, message, task id and date, all as plain text.
  • Widget script. GET /plugins/golem15/feedback/assets/js/embed.js serves the WinterCMS plugin's embed.js byte for byte (application/javascript, with an ETag), at the path embedding pages already use.
  • Settings import. feedback:import-settings copies a WinterCMS installation's widget settings, the widget key included, into the singleton once.

Usage

Mount the plugin in an application as a git submodule at plugins/golem15/feedback:

git submodule add git@git.golem15.com:golem15/sm-feedback-plugin.git plugins/golem15/feedback

Add it to the application's go.work and go.mod:

use ./plugins/golem15/feedback

require git.golem15.com/golem15/sm-feedback-plugin v0.0.0

replace git.golem15.com/golem15/sm-feedback-plugin => ./plugins/golem15/feedback

List it in summer.yaml after golem15.user and run summer build:

module: git.golem15.com/golem15/acme
binary: acme
plugins:
  - id: golem15.user
    module: git.golem15.com/golem15/sm-user-plugin
  - id: golem15.feedback
    module: git.golem15.com/golem15/sm-feedback-plugin

The application's http.cors.paths must list _feedback/api/*: the widget calls the API from other origins. A page embeds the widget with its key:

<script src="https://backend.example.com/plugins/golem15/feedback/assets/js/embed.js" data-key="wk_..." data-lang="en" defer></script>

Set the G15Office connection with the three SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__* variables (see Configuration) and run a queue worker: serve runs one in-process when queue.work_in_serve is on, queue:work in its own process. After moving an installation from WinterCMS, run feedback:import-settings once so embed snippets keep their widget key.

The plugin's own go.mod replaces the framework with ../../../../summercms.go and the user plugin with ../user, so it expects the framework checkout next to the application checkout and the user plugin mounted beside it.

API reference

  • feedback: Plugin, the golem15.feedback plugin (config, lang, migrations, models, routes, buckets, jobs, commands, admin controllers, permissions, navigation, settings); BucketConfig, BucketSubmit, EmbedJSPath, EmbedJS, SettingsCode.
  • controllers/api: Config, Submit, SubmitRules, MeHidden, MeHiddenRules, KeyMatches, OriginAllowed.
  • classes: IsAllowedImage, SniffImageMIME, AllowedImageMIMEs; Screenshot, StoreSubmission, ScreenshotOf; the job contract SyncG15OfficeArgs, SyncG15OfficeKind, SyncG15OfficeQueue, SyncG15OfficeLabel, SyncG15OfficeAttempts; the job SyncG15Office, SyncDeps, TaskTitle, TaskDescription, StrLimit, StrWidth; the client G15OfficeClient, NewG15OfficeClient, NewG15OfficeClientWith, TaskFields, ErrNotConfigured, G15OfficeTimeout, ConfigBaseURL, ConfigToken, ConfigProject.
  • controllers: SubmissionsAdminController, AdminControllers, PermissionManageSettings.
  • console: ImportSettings, Import, IsNothingToImport, ErrSettingsCustomised.
  • models: Settings (table golem15_feedback_settings), SettingsID, SettingsCode, DefaultSettings, Instance, NewWidgetKey, Labels, URLHost, ASCIILower; Submission (table golem15_feedback_submissions), StatusPending, StatusSent, StatusFailed, TypeBug, TypeFeature, TypeOther, ScreenshotField, G15OfficeTypeMap; UserPreference (table golem15_feedback_user_preferences), IsWidgetHidden, SetWidgetHidden; Register, All.
  • updates: the migrations (202610030201_create_feedback_submissions, 202610030202_create_feedback_user_preferences, 202610030203_create_feedback_settings), Register, All.

Configuration

Defaults live in config/config.yaml and are merged under golem15.feedback.*.

Key Default Meaning
golem15.feedback.g15_office.base_url empty The G15Office base URL submissions are filed under (https; requests may reach only its host).
golem15.feedback.g15_office.token empty The G15Office API token. Never commit it.
golem15.feedback.g15_office.project empty The G15Office project the tasks are created in.

Override them with SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL, SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__TOKEN and SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__PROJECT.

CLI commands

Command Purpose
feedback:import-settings [--force] Copy the WinterCMS system_settings row golem15_feedback_settings (widget key, origins, colors, labels, G15Office status and priority) into golem15_feedback_settings. It refuses settings that were already changed unless --force is given, and reports a missing system_settings table or row without failing.

Dependencies

  • The SummerCMS framework, git.golem15.com/golem15/summercms (modules backpack, bonfire, bouncer, cabana, compass, conga, fetchguard, lagoon, pact, party, phrasebook, surf, wire; tide in tests).
  • gocloud.dev blob storage for the screenshots, through the application's uploads bucket.
  • The user plugin, git.golem15.com/golem15/sm-user-plugin.
  • GORM and gormigrate, as pinned in go.mod.
  • testcontainers-go for the Postgres-backed tests.

Testing

go test ./...   # starts Postgres with testcontainers (needs Docker)

classes/testdata/g15office-task.upstream.yaml is the G15Office exchange the WinterCMS job made, recorded through the framework's upstream recording proxy; TestSyncG15Office replays it offline and fails on any difference in the requests (URL, headers, the task JSON, the screenshot part).

Description
No description provided
Readme 188 KiB
Languages
Go 83.7%
JavaScript 16.3%