- 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
282 lines
9.4 KiB
Go
282 lines
9.4 KiB
Go
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
|
|
}
|