uniqueMediaKey checked Exists and the write followed without a precondition, so two uploads of the same name (clipboard images are all image.png) could both pick the free key and the second replaced the first. StoreMedia now reserves the chosen key in-process while it writes, and writes with IfNotExist; a key another process stored after the check moves the upload on to the next free name.
225 lines
7.6 KiB
Go
225 lines
7.6 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"gocloud.dev/blob"
|
|
"gocloud.dev/gcerrors"
|
|
)
|
|
|
|
// MediaMaxBytes is the largest journal media upload, 10240 KiB.
|
|
const MediaMaxBytes = 10240 << 10
|
|
|
|
// MediaFolderPattern is the characters a media upload folder may hold. A
|
|
// folder also holds at most 100 characters and no "..".
|
|
var MediaFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
|
|
|
|
// mediaMaxSuffix is how many numbered names uniqueMediaKey tries.
|
|
const mediaMaxSuffix = 1000
|
|
|
|
// mediaWriteAttempts is how many free names StoreMedia tries to write when
|
|
// another writer takes the chosen one first.
|
|
const mediaWriteAttempts = 10
|
|
|
|
// mediaReservations are the media keys a StoreMedia call in this process
|
|
// has chosen and is writing, per bucket, so two concurrent uploads of the
|
|
// same name never pick the same free key.
|
|
var mediaReservations = struct {
|
|
sync.Mutex
|
|
keys map[mediaReservation]bool
|
|
}{keys: map[mediaReservation]bool{}}
|
|
|
|
type mediaReservation struct {
|
|
bucket *blob.Bucket
|
|
key string
|
|
}
|
|
|
|
// mediaBeforeWrite, when set, runs after a key is chosen and before it is
|
|
// written. Tests use it to stand in for a writer in another process.
|
|
var mediaBeforeWrite func(ctx context.Context, bucket *blob.Bucket, key string)
|
|
|
|
// MediaError is an upload StoreMedia refuses. Field is the request field the
|
|
// message belongs to ("file" or "folder") and Message is the validation
|
|
// message of the public upload route.
|
|
type MediaError struct {
|
|
Field string
|
|
Message string
|
|
}
|
|
|
|
// Error returns Message.
|
|
func (e *MediaError) Error() string { return e.Message }
|
|
|
|
// StoredMedia is a stored media object: its public URL and its path
|
|
// (/journal/<folder>/<name>).
|
|
type StoredMedia struct {
|
|
URL string
|
|
Path string
|
|
}
|
|
|
|
// validMediaFolder applies the upload folder rule.
|
|
func validMediaFolder(folder string) bool {
|
|
return len(folder) <= 100 && MediaFolderPattern.MatchString(folder) && !strings.Contains(folder, "..")
|
|
}
|
|
|
|
// StoreMedia stores an uploaded image in bucket under
|
|
// /journal/<folder>/<name>, with a numbered suffix when the name is taken,
|
|
// and returns its public URL and path. It is the storage of POST
|
|
// /_journal/api/v1/media/upload and of the Blog Editor's image upload. The
|
|
// file name's base is kept; its extension must be jpg, jpeg, png, gif or
|
|
// webp, and the body may hold at most MediaMaxBytes. A refused upload is a
|
|
// *MediaError with the public route's message; any other error (no bucket,
|
|
// a read or write failure) is not. It never replaces an existing object:
|
|
// concurrent calls in this process reserve the key they chose, and the
|
|
// write is conditional (IfNotExist), so a key another process stored after
|
|
// the existence check moves the upload to the next free name.
|
|
func StoreMedia(ctx context.Context, bucket *blob.Bucket, folder, filename string, body io.Reader) (StoredMedia, error) {
|
|
if !validMediaFolder(folder) {
|
|
return StoredMedia{}, &MediaError{Field: "folder", Message: "The folder format is invalid."}
|
|
}
|
|
name := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
|
switch strings.ToLower(strings.TrimPrefix(path.Ext(name), ".")) {
|
|
case "jpg", "jpeg", "png", "gif", "webp":
|
|
default:
|
|
return StoredMedia{}, &MediaError{Field: "file", Message: "The file must be an image."}
|
|
}
|
|
if bucket == nil {
|
|
return StoredMedia{}, errors.New("journal: media bucket is not configured")
|
|
}
|
|
keyPath := mediaObjectPath(folder, name)
|
|
data, err := io.ReadAll(io.LimitReader(body, MediaMaxBytes+1))
|
|
if err != nil {
|
|
return StoredMedia{}, fmt.Errorf("journal: read media upload: %w", err)
|
|
}
|
|
if len(data) > MediaMaxBytes {
|
|
return StoredMedia{}, &MediaError{Field: "file", Message: "The file may not be greater than 10240 kilobytes."}
|
|
}
|
|
for attempt := 0; attempt < mediaWriteAttempts; attempt++ {
|
|
chosen, release, err := reserveMediaKey(ctx, bucket, keyPath)
|
|
if err != nil {
|
|
return StoredMedia{}, err
|
|
}
|
|
key := strings.TrimPrefix(chosen, "/")
|
|
if mediaBeforeWrite != nil {
|
|
mediaBeforeWrite(ctx, bucket, key)
|
|
}
|
|
err = writeNewMedia(ctx, bucket, key, data)
|
|
release()
|
|
if gcerrors.Code(err) == gcerrors.FailedPrecondition {
|
|
// Another writer (another process) stored this key after the
|
|
// existence check; take the next free name.
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return StoredMedia{}, err
|
|
}
|
|
return StoredMedia{URL: attach.PublicURL(key), Path: chosen}, nil
|
|
}
|
|
return StoredMedia{}, fmt.Errorf("journal: no free media name for %s after %d attempts", keyPath, mediaWriteAttempts)
|
|
}
|
|
|
|
// reserveMediaKey chooses a free key for pathName (uniqueMediaKey, skipping
|
|
// keys this process is writing) and reserves it until release is called.
|
|
func reserveMediaKey(ctx context.Context, bucket *blob.Bucket, pathName string) (string, func(), error) {
|
|
mediaReservations.Lock()
|
|
defer mediaReservations.Unlock()
|
|
reserved := func(p string) bool {
|
|
return mediaReservations.keys[mediaReservation{bucket: bucket, key: p}]
|
|
}
|
|
chosen, err := uniqueMediaKey(ctx, bucket, pathName, reserved)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
slot := mediaReservation{bucket: bucket, key: chosen}
|
|
mediaReservations.keys[slot] = true
|
|
release := func() {
|
|
mediaReservations.Lock()
|
|
delete(mediaReservations.keys, slot)
|
|
mediaReservations.Unlock()
|
|
}
|
|
return chosen, release, nil
|
|
}
|
|
|
|
// writeNewMedia writes data under key only when no object exists there
|
|
// (blob.WriterOptions.IfNotExist): an existing object is never replaced,
|
|
// and the error then has the gcerrors.FailedPrecondition code.
|
|
func writeNewMedia(ctx context.Context, bucket *blob.Bucket, key string, data []byte) error {
|
|
w, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{IfNotExist: true})
|
|
if err != nil {
|
|
return fmt.Errorf("journal: media writer: %w", err)
|
|
}
|
|
if _, err := w.Write(data); err != nil {
|
|
_ = w.Close()
|
|
return fmt.Errorf("journal: write media: %w", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("journal: close media: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mediaObjectPath is /journal/<folder>/<fileName> with empty, "." and ".."
|
|
// folder segments dropped and a leading "journal" segment not repeated.
|
|
func mediaObjectPath(folder, fileName string) string {
|
|
segments := make([]string, 0, 4)
|
|
for _, part := range strings.Split(strings.Trim(folder, "/"), "/") {
|
|
if part == "" || part == "." || part == ".." {
|
|
continue
|
|
}
|
|
segments = append(segments, part)
|
|
}
|
|
if len(segments) > 0 && strings.EqualFold(segments[0], "journal") {
|
|
segments = segments[1:]
|
|
}
|
|
folder = strings.Join(segments, "/")
|
|
if folder == "" {
|
|
return "/journal/" + fileName
|
|
}
|
|
return "/journal/" + folder + "/" + fileName
|
|
}
|
|
|
|
// uniqueMediaKey returns pathName, or pathName with "-1", "-2", ... before
|
|
// its extension when an object already exists there or reserved reports the
|
|
// path as taken. It never returns the path of an existing object: when every
|
|
// numbered name is taken it fails.
|
|
func uniqueMediaKey(ctx context.Context, bucket *blob.Bucket, pathName string, reserved func(string) bool) (string, error) {
|
|
exists := func(p string) (bool, error) {
|
|
if reserved != nil && reserved(p) {
|
|
return true, nil
|
|
}
|
|
ok, err := bucket.Exists(ctx, strings.TrimPrefix(p, "/"))
|
|
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
|
|
return false, err
|
|
}
|
|
return ok, nil
|
|
}
|
|
taken, err := exists(pathName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !taken {
|
|
return pathName, nil
|
|
}
|
|
ext := path.Ext(pathName)
|
|
base := strings.TrimSuffix(pathName, ext)
|
|
for n := 1; n < mediaMaxSuffix; n++ {
|
|
candidate := base + "-" + strconv.Itoa(n) + ext
|
|
taken, err := exists(candidate)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !taken {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("journal: no free media name for %s", pathName)
|
|
}
|