148 lines
4.0 KiB
Go
148 lines
4.0 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type memObject struct {
|
|
data []byte
|
|
contentType string
|
|
createdAt time.Time
|
|
}
|
|
|
|
// MemoryStore implements Store using in-memory storage.
|
|
// Mount ServeHTTP at /storage/ to serve stored objects.
|
|
type MemoryStore struct {
|
|
objects map[string]*memObject
|
|
mu sync.RWMutex
|
|
baseURL string // e.g., "http://localhost:8001/storage"
|
|
}
|
|
|
|
// NewMemoryStore creates an in-memory store. baseURL is the URL prefix for serving objects
|
|
// (e.g., "http://localhost:8001/storage").
|
|
func NewMemoryStore(baseURL string) *MemoryStore {
|
|
return &MemoryStore{
|
|
objects: make(map[string]*memObject),
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
}
|
|
}
|
|
|
|
func (s *MemoryStore) Upload(_ context.Context, path string, data []byte, contentType string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.objects[path] = &memObject{
|
|
data: append([]byte(nil), data...),
|
|
contentType: contentType,
|
|
createdAt: time.Now(),
|
|
}
|
|
return fmt.Sprintf("%s/%s", s.baseURL, path), nil
|
|
}
|
|
|
|
func (s *MemoryStore) UploadPresigned(_ context.Context, path string, contentType string) (*PresignedUpload, error) {
|
|
// In dev mode, presigned uploads go through the same /storage/ endpoint.
|
|
return &PresignedUpload{
|
|
URL: fmt.Sprintf("%s/%s", s.baseURL, path),
|
|
Headers: map[string]string{"Content-Type": contentType},
|
|
Method: "PUT",
|
|
Expires: time.Now().Add(15 * time.Minute),
|
|
}, nil
|
|
}
|
|
|
|
func (s *MemoryStore) GetURL(_ context.Context, path string) (string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if _, ok := s.objects[path]; !ok {
|
|
return "", fmt.Errorf("storage: object not found: %s", path)
|
|
}
|
|
return fmt.Sprintf("%s/%s", s.baseURL, path), nil
|
|
}
|
|
|
|
func (s *MemoryStore) Delete(_ context.Context, path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.objects, path)
|
|
return nil
|
|
}
|
|
|
|
func (s *MemoryStore) List(_ context.Context, prefix string) ([]MediaObject, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var result []MediaObject
|
|
for path, obj := range s.objects {
|
|
if strings.HasPrefix(path, prefix) {
|
|
result = append(result, MediaObject{
|
|
Path: path,
|
|
URL: fmt.Sprintf("%s/%s", s.baseURL, path),
|
|
ContentType: obj.contentType,
|
|
Size: int64(len(obj.data)),
|
|
CreatedAt: obj.createdAt,
|
|
})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ServeHTTP serves stored objects and accepts PUT uploads for the dev presigned URL flow.
|
|
// This handler is dev-only — in production, clients upload directly to GCS via presigned URLs.
|
|
// PUT requests are limited to 100MB to prevent accidental OOM in development.
|
|
// Mount at /storage/ in the application router.
|
|
func (s *MemoryStore) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Strip /storage/ prefix to get the object path.
|
|
path := strings.TrimPrefix(r.URL.Path, "/storage/")
|
|
if path == "" {
|
|
http.Error(w, "path required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.mu.RLock()
|
|
obj, ok := s.objects[path]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", obj.contentType)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(obj.data)))
|
|
_, _ = w.Write(obj.data)
|
|
|
|
case http.MethodPut:
|
|
// Limit upload size to 100MB (dev mode only — production uses GCS presigned URLs with their own limits).
|
|
const maxUploadSize = 100 << 20
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
data, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
http.Error(w, "file too large (max 100MB)", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
http.Error(w, "read body failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ct := r.Header.Get("Content-Type")
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
s.mu.Lock()
|
|
s.objects[path] = &memObject{
|
|
data: data,
|
|
contentType: ct,
|
|
createdAt: time.Now(),
|
|
}
|
|
s.mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|