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 }