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//). 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//, 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// 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) }