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. // // Unlike PHP, a retry never files a second task: a submission already sent // with a task id is skipped, the task id is stored as soon as CreateTask // returns it, and a retry of a submission that has one only re-attempts // the screenshot attachment. 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] if sub.Status == models.StatusSent && hasTaskID(&sub) { return nil } 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 } // hasTaskID reports whether an earlier attempt already filed the task. func hasTaskID(sub *models.Submission) bool { return sub.G15OfficeTaskID != nil && *sub.G15OfficeTaskID != "" } func fileTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) { var id string if hasTaskID(sub) { id = *sub.G15OfficeTaskID } else { created, err := createTask(ctx, deps, sub) if err != nil { return "", err } id = created } if err := attachScreenshot(ctx, deps, sub, id); err != nil { return "", err } return id, nil } // createTask files the submission as a new G15Office task and stores the // task id at once, so a later failure (the attachment) is retried against // this task rather than filing another. func createTask(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.") } if err := deps.DB.WithContext(ctx).Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{ "g15office_task_id": id, "updated_at": time.Now(), }).Error; err != nil { return "", err } sub.G15OfficeTaskID = &id return id, nil } // attachScreenshot uploads the submission's screenshot, when it has one, // to the task. func attachScreenshot(ctx context.Context, deps SyncDeps, sub *models.Submission, id string) error { file, err := ScreenshotOf(ctx, deps.DB, sub.ID) if err != nil { return err } if file == nil { return 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() return err } // 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") }