Files
sm-feedback-plugin/classes/sync_g15office.go
Jakub Zych af5d77c997 feat: G15Office job, widget settings and submissions admin, embed.js and the settings importer
- SyncG15Office files each submission as a G15Office task with its screenshot through a fetchguard client limited to the configured host; a River job with 3 attempts, PHP's messages on failure, nothing sent when unconfigured
- the replay of PHP's recorded G15Office exchange (classes/testdata) asserts the task JSON, headers and the screenshot part
- settings screen (cabana field types only), read-only submissions list, manage_settings permission and navigation
- embed.js served byte for byte at /plugins/golem15/feedback/assets/js/embed.js
- feedback:import-settings copies the WinterCMS settings row once
2026-10-04 00:22:08 +02:00

155 lines
5.1 KiB
Go

package classes
import (
"context"
"errors"
"regexp"
"strings"
"time"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// The G15Office sync job's contract: SubmitFeedback queues one per stored
// submission, the worker files it (SyncFeedbackToG15Office).
const (
// SyncG15OfficeKind is the River kind of the job.
SyncG15OfficeKind = "golem15.feedback.sync_g15office"
// SyncG15OfficeQueue is the job's queue.
SyncG15OfficeQueue = "feedback"
// SyncG15OfficeLabel is the summer_jobs label, the PHP job class.
SyncG15OfficeLabel = `Golem15\Feedback\Jobs\SyncFeedbackToG15Office`
// SyncG15OfficeAttempts is SyncFeedbackToG15Office::$tries.
SyncG15OfficeAttempts = 3
)
// SyncG15OfficeArgs is the job's argument: the submission to file.
type SyncG15OfficeArgs struct {
SubmissionID uint `json:"submission_id"`
}
// Kind is the River job kind.
func (SyncG15OfficeArgs) Kind() string { return SyncG15OfficeKind }
// SyncDeps are what one run of the job needs, resolved per run.
type SyncDeps struct {
DB *gorm.DB
Bucket *blob.Bucket
Client *G15OfficeClient
}
// SyncG15Office is SyncFeedbackToG15Office::handle: it files the submission
// as a G15Office task (title, Markdown description, type, the settings'
// task status and priority) and attaches its screenshot. Success stores
// status sent and the task id; any failure stores status failed and the
// error and returns the error, so the job is retried. A submission that is
// gone is skipped. An unconfigured client fails before any request, so
// nothing leaves the application.
func SyncG15Office(ctx context.Context, deps SyncDeps, args SyncG15OfficeArgs) error {
if deps.DB == nil {
return errors.New("feedback: database is not configured")
}
db := deps.DB.WithContext(ctx)
var rows []models.Submission
if err := db.Where("id = ?", args.SubmissionID).Limit(1).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
sub := rows[0]
id, err := fileTask(ctx, deps, &sub)
if err != nil {
msg := err.Error()
if uerr := db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
"status": models.StatusFailed, "g15office_error": msg, "updated_at": time.Now(),
}).Error; uerr != nil {
return errors.Join(err, uerr)
}
return err
}
return db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
"status": models.StatusSent, "g15office_task_id": id, "g15office_error": nil, "updated_at": time.Now(),
}).Error
}
func fileTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) {
settings, err := models.Instance(ctx, deps.DB)
if err != nil {
return "", err
}
fields := TaskFields{
Title: TaskTitle(sub.Message),
Description: TaskDescription(sub),
Type: sub.G15OfficeTaskType(),
Priority: "normal",
}
if s := settings.G15OfficeTaskStatus; s != nil && phpTruthy(*s) {
fields.Status = *s
}
if p := settings.G15OfficeTaskPriority; p != nil && phpTruthy(*p) {
fields.Priority = *p
}
task, err := deps.Client.CreateTask(ctx, fields)
if err != nil {
return "", err
}
id := taskID(task)
if id == "" {
return "", errors.New("g15office did not return a task id.")
}
file, err := ScreenshotOf(ctx, deps.DB, sub.ID)
if err != nil {
return "", err
}
if file != nil {
if deps.Bucket == nil {
return "", errors.New("feedback: upload bucket is not configured")
}
r, err := deps.Bucket.NewReader(ctx, attach.BlobKey(file.DiskName), nil)
if err != nil {
return "", err
}
_, err = deps.Client.AttachFile(ctx, id, file.FileName, file.ContentType, r)
_ = r.Close()
if err != nil {
return "", err
}
}
return id, nil
}
// whitespaceRun is PCRE's \s+ without the u flag: ASCII space, tab, LF, VT, FF
// and CR (Go's \s leaves out VT).
var whitespaceRun = regexp.MustCompile(`[\t\n\v\f\r ]+`)
// TaskTitle is SyncFeedbackToG15Office::buildTitle: "Feedback: " and the
// message with whitespace runs collapsed, trimmed and cut to 80 columns.
func TaskTitle(message string) string {
collapsed := strings.Trim(whitespaceRun.ReplaceAllString(message, " "), " \t\n\r\x00\x0B")
return "Feedback: " + StrLimit(collapsed, 80, "...")
}
// TaskDescription is SyncFeedbackToG15Office::buildDescription: the message,
// a rule, one bullet per metadata line (page, then the reporter email and
// the user agent when set) and the console log in a code fence, cut to
// 4000 columns, blocks separated by blank lines.
func TaskDescription(sub *models.Submission) string {
blocks := []string{sub.Message, "---"}
meta := []string{"- **Page:** " + sub.PageURL}
if sub.Email != nil && phpTruthy(*sub.Email) {
meta = append(meta, "- **Reporter email:** "+*sub.Email)
}
if sub.UserAgent != nil && phpTruthy(*sub.UserAgent) {
meta = append(meta, "- **User agent:** "+*sub.UserAgent)
}
blocks = append(blocks, strings.Join(meta, "\n"))
if sub.ConsoleLog != nil && phpTruthy(*sub.ConsoleLog) {
blocks = append(blocks, "**Console log:**\n```\n"+StrLimit(*sub.ConsoleLog, 4000, "… (truncated)")+"\n```")
}
return strings.Join(blocks, "\n\n")
}