Files
sm-journal-plugin/classes/media_store.go
Jakub Zych 15fcad7a93 refactor(api): share media storage with the editor upload
- classes.StoreMedia holds the media upload route's storage: extension
  allowlist, 10240 KiB cap, /journal/<folder>/<name> keys with the unique
  suffix, bucket write and public URL
- UploadMedia keeps authentication, permissions, the folder and form checks
  and answers with the same 201 body and messages
- uniqueMediaKey fails instead of overwriting when every numbered name is
  taken; the media path tests move to classes
2026-10-10 12:08:42 +02:00

151 lines
4.9 KiB
Go

package classes
import (
"context"
"errors"
"fmt"
"io"
"path"
"regexp"
"strconv"
"strings"
"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
// 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.
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."}
}
keyPath, err = uniqueMediaKey(ctx, bucket, keyPath)
if err != nil {
return StoredMedia{}, err
}
key := strings.TrimPrefix(keyPath, "/")
w, err := bucket.NewWriter(ctx, key, nil)
if err != nil {
return StoredMedia{}, fmt.Errorf("journal: media writer: %w", err)
}
if _, err := w.Write(data); err != nil {
_ = w.Close()
return StoredMedia{}, fmt.Errorf("journal: write media: %w", err)
}
if err := w.Close(); err != nil {
return StoredMedia{}, fmt.Errorf("journal: close media: %w", err)
}
return StoredMedia{URL: attach.PublicURL(key), Path: keyPath}, 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. 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) (string, error) {
exists := func(p string) (bool, error) {
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)
}