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.
163 lines
5.6 KiB
Go
163 lines
5.6 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"mime"
|
|
"path/filepath"
|
|
"slices"
|
|
"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
|
|
}
|
|
|
|
// screenshotExtensions are the disk-name extensions each allowed image
|
|
// type may carry; the first is used when the client's is not one of them.
|
|
var screenshotExtensions = map[string][]string{
|
|
"image/jpeg": {"jpg", "jpeg"},
|
|
"image/png": {"png"},
|
|
"image/gif": {"gif"},
|
|
"image/webp": {"webp"},
|
|
}
|
|
|
|
// screenshotExtension is the disk-name extension for a screenshot whose
|
|
// bytes sniffed as contentType: the client file's extension, lower-cased,
|
|
// when it names that type, else the type's own. The public uploads
|
|
// directory is served by extension, so a valid image named x.html or x.svg
|
|
// must never keep its client extension.
|
|
func screenshotExtension(contentType, fileName string) (string, error) {
|
|
allowed, ok := screenshotExtensions[contentType]
|
|
if !ok {
|
|
return "", errors.New("feedback: screenshot is not an allowed image")
|
|
}
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(fileName), "."))
|
|
if slices.Contains(allowed, ext) {
|
|
return ext, nil
|
|
}
|
|
return allowed[0], nil
|
|
}
|
|
|
|
// clientBaseName is the last path segment of a client file name, trimmed
|
|
// (attach.Store's cleaning of Upload.FileName).
|
|
func clientBaseName(name string) string {
|
|
name = strings.ReplaceAll(name, `\`, "/")
|
|
if i := strings.LastIndex(name, "/"); i >= 0 {
|
|
name = name[i+1:]
|
|
}
|
|
return strings.TrimSpace(name)
|
|
}
|
|
|
|
// storeScreenshot writes the screenshot under a fresh Winter-style disk
|
|
// name (22 hex characters plus an extension that matches the sniffed image
|
|
// type) 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) {
|
|
contentType := SniffImageMIME(shot.Data)
|
|
if mt, _, err := mime.ParseMediaType(contentType); err == nil {
|
|
contentType = mt
|
|
}
|
|
fileName := clientBaseName(shot.Filename)
|
|
ext, err := screenshotExtension(contentType, fileName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw := make([]byte, 11)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return nil, err
|
|
}
|
|
diskName := hex.EncodeToString(raw) + "." + ext
|
|
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: 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
|
|
}
|