feat(12.2-01): add deferred bindings, guarded upload store and purge

- deferred_bindings migration set under summercms.deferred with backend_user_id
- lagoon.DeferredBind/Unbind/Bindings/Forget/Slaves scoped by DeferredKey
- lagoon.PurgeDeferred with SKIP LOCKED batches and after-commit blob deletes
- attach.Store with the ported image guard, extension and MIME limits
- attach.Relation, attach.HasRelations, attach.BlobKeys, File.ThumbKey
- lagoon README and attachments docs
This commit is contained in:
Jakub Zych
2026-10-02 17:36:43 +02:00
parent 79e2a43095
commit 19f4cf8232
14 changed files with 1280 additions and 15 deletions

View File

@@ -0,0 +1,281 @@
package attach
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"path"
"regexp"
"slices"
"strings"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// sniffBytes is how much of an upload Store reads ahead for the content
// sniff and the image guard.
const sniffBytes = 1 << 20
var (
// ErrTooLarge is returned by Store when the body exceeds Limits.MaxBytes.
ErrTooLarge = errors.New("attach: file is too large")
// ErrFileType is returned by Store when the file name's extension is
// missing, malformed or not in the allowed extension list.
ErrFileType = errors.New("attach: file type is not allowed")
// ErrMIMEType is returned by Store when the content type matches none
// of Limits.MIMETypes.
ErrMIMEType = errors.New("attach: file content type is not allowed")
// ErrNotImage is returned by Store in image mode when the bytes are not
// an image IsAllowedImage accepts.
ErrNotImage = errors.New("attach: file is not an allowed image")
)
// DefaultImageExtensions is the extension list of an image upload when
// Limits.Extensions is empty: jpg, jpeg, png, gif and webp, the formats
// IsAllowedImage and the thumbnailer handle. WinterCMS's image list also
// has avif, bmp and svg; they are left out because nothing here decodes
// them and svg can carry script.
var DefaultImageExtensions = []string{"jpg", "jpeg", "png", "gif", "webp"}
// DefaultFileExtensions is the extension list of a file upload when
// Limits.Extensions is empty: WinterCMS's default list (winter/storm
// Filesystem\Definitions::defaultExtensions) minus the script-capable types
// svg, js, map, css, less, scss, swf and xml. The final list is avi, avif,
// bmp, doc, docx, eot, flv, gif, ico, ics, jpeg, jpg, mkv, mov, mp3, mp4,
// mpeg, ods, odt, ogg, pdf, png, ppt, pptx, rar, ttf, txt, wav, webm, webp,
// wmv, woff, woff2, xls, xlsx and zip.
var DefaultFileExtensions = []string{
"avi", "avif", "bmp", "doc", "docx", "eot", "flv", "gif", "ico", "ics",
"jpeg", "jpg", "mkv", "mov", "mp3", "mp4", "mpeg", "ods", "odt", "ogg",
"pdf", "png", "ppt", "pptx", "rar", "ttf", "txt", "wav", "webm", "webp",
"wmv", "woff", "woff2", "xls", "xlsx", "zip",
}
var extPattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
// Upload is one file to store. FileName is the client's file name: only its
// extension and base name are used (for the allowed-type check and the
// file_name column); no part of it reaches a blob key. Body is read once,
// to the end or to the size limit. Public sets the row's is_public flag.
type Upload struct {
FileName string
Body io.Reader
Public bool
}
// Limits restricts what Store accepts.
//
// MaxBytes is the largest body in bytes; 0 means no limit of its own (the
// caller's request body cap still applies). Extensions lists the allowed
// lower-case extensions without the dot; empty means DefaultImageExtensions
// when Image is set, else DefaultFileExtensions. MIMETypes, when not empty,
// must match the stored content type: an entry containing a slash is a MIME
// pattern such as "image/png" or "image/*", an entry without one is an
// extension. Image applies the image guard (IsAllowedImage) to the content.
type Limits struct {
MaxBytes int64
Extensions []string
MIMETypes []string
Image bool
}
// Store saves an upload as an unattached system_files row.
//
// It accepts the client extension, lower-cased, only when it matches
// [a-z0-9]{1,10} and is allowed by Limits (else ErrFileType). It reads up to
// 1 MiB ahead to sniff the content type from the bytes; in image mode those
// bytes must pass IsAllowedImage (else ErrNotImage), and Limits.MIMETypes is
// checked against the sniffed type, or the extension's registered type when
// the sniff only says application/octet-stream (else ErrMIMEType). The body
// is then streamed into bucket at BlobKey of a server-generated disk name (22
// random lowercase hex characters, a dot and the extension); a body longer
// than Limits.MaxBytes aborts the write, deletes the key and returns
// ErrTooLarge. Finally it inserts the row with empty attachment columns,
// is_public from Upload.Public, the byte size and the content type, and sets
// sort_order to the new id as WinterCMS's Sortable trait does. When the row
// cannot be written the blob is deleted again.
//
// db may be a transaction. The blob is written before the row, so a caller
// whose transaction rolls back after Store returned must delete the
// returned file's BlobKeys itself.
func Store(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, in Upload, lim Limits) (*File, error) {
if ctx == nil {
ctx = context.Background()
}
if db == nil {
return nil, fmt.Errorf("attach: store db is nil")
}
if bucket == nil {
return nil, fmt.Errorf("attach: bucket is nil")
}
if in.Body == nil {
return nil, fmt.Errorf("attach: upload body is nil")
}
if lim.MaxBytes < 0 {
return nil, fmt.Errorf("attach: negative size limit %d", lim.MaxBytes)
}
name := clientBaseName(in.FileName)
ext := strings.ToLower(strings.TrimPrefix(path.Ext(name), "."))
if !extPattern.MatchString(ext) || !slices.Contains(allowedExtensions(lim), ext) {
return nil, fmt.Errorf("%w: %q", ErrFileType, ext)
}
br := bufio.NewReaderSize(in.Body, sniffBytes)
head, err := br.Peek(sniffBytes)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, bufio.ErrBufferFull) {
return nil, fmt.Errorf("attach: read upload: %w", err)
}
if lim.MaxBytes > 0 && int64(len(head)) > lim.MaxBytes {
return nil, ErrTooLarge
}
if lim.Image && !IsAllowedImage(head) {
return nil, ErrNotImage
}
contentType := baseMediaType(http.DetectContentType(head))
if contentType == "application/octet-stream" {
if byExt := baseMediaType(mime.TypeByExtension("." + ext)); byExt != "" {
contentType = byExt
}
}
if len(lim.MIMETypes) > 0 && !mimeAllowed(lim.MIMETypes, contentType, ext) {
return nil, fmt.Errorf("%w: %s", ErrMIMEType, contentType)
}
diskName, err := newDiskName(ext)
if err != nil {
return nil, err
}
key := BlobKey(diskName)
size, err := writeBlob(ctx, bucket, key, br, contentType, lim.MaxBytes)
if err != nil {
return nil, err
}
public := in.Public
f := &File{
DiskName: diskName,
FileName: name,
FileSize: size,
ContentType: contentType,
IsPublic: &public,
}
q := db.Session(&gorm.Session{NewDB: true, Context: ctx})
err = q.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(f).Error; err != nil {
return err
}
f.SortOrder = int(f.ID)
return tx.Model(&File{}).Where("id = ?", f.ID).Update("sort_order", f.SortOrder).Error
})
if err != nil {
_ = deleteKey(context.WithoutCancel(ctx), bucket, key)
return nil, fmt.Errorf("attach: store row: %w", err)
}
return f, nil
}
// writeBlob streams r into key and returns the byte count. With limit > 0 a
// body of more than limit bytes aborts the write and deletes the key.
func writeBlob(ctx context.Context, bucket *blob.Bucket, key string, r io.Reader, contentType string, limit int64) (int64, error) {
writeCtx, cancel := context.WithCancel(ctx)
defer cancel()
w, err := bucket.NewWriter(writeCtx, key, &blob.WriterOptions{ContentType: contentType})
if err != nil {
return 0, fmt.Errorf("attach: blob writer: %w", err)
}
src := r
if limit > 0 {
src = io.LimitReader(r, limit+1)
}
n, copyErr := io.Copy(w, src)
if copyErr == nil && limit > 0 && n > limit {
copyErr = ErrTooLarge
}
if copyErr != nil {
// Cancelling the writer's context before Close discards the write.
cancel()
_ = w.Close()
_ = deleteKey(context.WithoutCancel(ctx), bucket, key)
if errors.Is(copyErr, ErrTooLarge) {
return 0, ErrTooLarge
}
return 0, fmt.Errorf("attach: write upload: %w", copyErr)
}
if err := w.Close(); err != nil {
_ = deleteKey(context.WithoutCancel(ctx), bucket, key)
return 0, fmt.Errorf("attach: write upload: %w", err)
}
return n, nil
}
// clientBaseName is the last element of a client file name, with either
// slash style treated as a separator.
func clientBaseName(name string) string {
name = strings.ReplaceAll(name, `\`, "/")
if i := strings.LastIndex(name, "/"); i >= 0 {
name = name[i+1:]
}
return strings.TrimSpace(name)
}
func allowedExtensions(lim Limits) []string {
if len(lim.Extensions) == 0 {
if lim.Image {
return DefaultImageExtensions
}
return DefaultFileExtensions
}
out := make([]string, 0, len(lim.Extensions))
for _, e := range lim.Extensions {
out = append(out, strings.ToLower(strings.TrimPrefix(strings.TrimSpace(e), ".")))
}
return out
}
func baseMediaType(ct string) string {
if ct == "" {
return ""
}
mt, _, err := mime.ParseMediaType(ct)
if err != nil {
return strings.ToLower(strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]))
}
return mt
}
// mimeAllowed reports whether contentType or ext matches one of patterns.
func mimeAllowed(patterns []string, contentType, ext string) bool {
for _, p := range patterns {
p = strings.ToLower(strings.TrimSpace(p))
if p == "" {
continue
}
if !strings.Contains(p, "/") {
if strings.TrimPrefix(p, ".") == ext {
return true
}
continue
}
pType, pSub, _ := strings.Cut(p, "/")
cType, cSub, _ := strings.Cut(contentType, "/")
if (pType == "*" || pType == cType) && (pSub == "*" || pSub == cSub) {
return true
}
}
return false
}
func newDiskName(ext string) (string, error) {
raw := make([]byte, 11)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("attach: disk name: %w", err)
}
return hex.EncodeToString(raw) + "." + ext, nil
}