- 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
155 lines
5.1 KiB
Go
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")
|
|
}
|