persona-community-3/pkg/storage/memory.go
jordan f53b908499
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-23 11:10:35 +00:00

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