Files
sm-feedback-plugin/classes/submission_store.go
Jakub Zych 92e1b4e09c feat: feedback submissions with screenshots, the hide-widget preference and its user payload key
- POST /_feedback/api/v1/{key}/submit validates as PHP, refuses non-image screenshots (ImageContentGuard copy) and stores the submission, its public screenshot and the G15Office job in one transaction
- PUT /_feedback/api/v1/me/hidden (jwt.auth) stores the caller's preference; feedback_widget_hidden joins every golem15.user payload
- preflights stay with the framework CORS layer
2026-10-04 00:10:15 +02:00

124 lines
4.3 KiB
Go

package classes
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"mime"
"path/filepath"
"strconv"
"strings"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// Screenshot is a validated screenshot upload: the client's file name and
// the bytes, which passed IsAllowedImage.
type Screenshot struct {
Filename string
Data []byte
}
// StoreSubmission saves a new submission (status pending), queues its
// G15Office job (SyncG15OfficeArgs, a summer_jobs row labelled
// SyncG15OfficeLabel) and, when there is one, stores the screenshot as a
// public file attached to the submission's screenshot relation, all in one
// transaction: when any step fails nothing is kept, no job is queued and
// the screenshot's blob is deleted again.
func StoreSubmission(ctx context.Context, app *backpack.App, gdb *gorm.DB, bucket *blob.Bucket, sub *models.Submission, shot *Screenshot) error {
if gdb == nil {
return errors.New("feedback: database is not configured")
}
if shot != nil && bucket == nil {
return errors.New("feedback: upload bucket is not configured")
}
jobs, err := conga.From(app)
if err != nil {
return err
}
sub.Status = models.StatusPending
var keys []string
err = lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(sub).Error; err != nil {
return err
}
if _, err := jobs.Dispatch(ctx, tx.Session(&gorm.Session{NewDB: true, Context: ctx}), SyncG15OfficeArgs{SubmissionID: sub.ID}, conga.DispatchOpts{
Label: SyncG15OfficeLabel,
Queue: SyncG15OfficeQueue,
MaxAttempts: SyncG15OfficeAttempts,
}); err != nil {
return err
}
if shot == nil {
return nil
}
f, err := storeScreenshot(ctx, tx.Session(&gorm.Session{NewDB: true, Context: ctx}), bucket, sub.ID, shot)
if f != nil {
keys = attach.BlobKeys(*f)
}
return err
})
if err != nil && len(keys) > 0 {
_ = attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys)
}
return err
}
// storeScreenshot writes the screenshot under a fresh Winter-style disk
// name (22 hex characters plus the client file's extension, lower-cased)
// and inserts its system_files row attached to the submission, public and
// sorted by its own id as WinterCMS's Sortable trait does. The returned
// file is set once the blob is written, so a failed row can be cleaned up.
func storeScreenshot(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, submissionID uint, shot *Screenshot) (*attach.File, error) {
raw := make([]byte, 11)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
diskName := hex.EncodeToString(raw)
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(shot.Filename), ".")); ext != "" {
diskName += "." + ext
}
contentType := SniffImageMIME(shot.Data)
if mt, _, err := mime.ParseMediaType(contentType); err == nil {
contentType = mt
}
if err := bucket.WriteAll(ctx, attach.BlobKey(diskName), shot.Data, &blob.WriterOptions{ContentType: contentType}); err != nil {
return nil, err
}
public := true
f := &attach.File{
DiskName: diskName,
FileName: shot.Filename,
FileSize: int64(len(shot.Data)),
ContentType: contentType,
IsPublic: &public,
Field: models.ScreenshotField,
AttachmentType: models.Submission{}.MorphName(),
AttachmentID: strconv.FormatUint(uint64(submissionID), 10),
}
if err := db.Create(f).Error; err != nil {
return f, err
}
f.SortOrder = int(f.ID)
return f, db.Model(&attach.File{}).Where("id = ?", f.ID).Update("sort_order", f.SortOrder).Error
}
// ScreenshotOf returns the submission's attached screenshot, or nil.
func ScreenshotOf(ctx context.Context, db *gorm.DB, submissionID uint) (*attach.File, error) {
var files []attach.File
err := db.WithContext(ctx).
Where("attachment_type = ? AND attachment_id = ? AND field = ?", models.Submission{}.MorphName(), strconv.FormatUint(uint64(submissionID), 10), models.ScreenshotField).
Order("sort_order, id").Limit(1).Find(&files).Error
if err != nil || len(files) == 0 {
return nil, err
}
return &files[0], nil
}