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