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