package storage import ( "context" "errors" "fmt" "io" "io/fs" "mime" "net/http" "net/url" "os" "path/filepath" "strings" ) // errInvalidKey is returned for object keys that are empty, absolute or attempt // to escape the storage root. var errInvalidKey = errors.New("fs: invalid object key") // FS is a Backend backed by a local directory tree. Blobs live under Root at the // path formed by the opaque key. Because a local directory has no external URL, // FS serves blobs through the app using the shared Signer. type FS struct { root string signer *Signer } // NewFS builds a local file-system storage backend rooted at root. signer issues // and validates the app URLs used to serve blobs. func NewFS(root string, signer *Signer) (*FS, error) { if strings.TrimSpace(root) == "" { return nil, errors.New("fs: root is required") } if signer == nil { return nil, errors.New("fs: signer is required") } abs, err := filepath.Abs(root) if err != nil { return nil, fmt.Errorf("fs: resolve root: %w", err) } if err := os.MkdirAll(abs, 0o755); err != nil { return nil, fmt.Errorf("fs: create root: %w", err) } return &FS{root: abs, signer: signer}, nil } func (f *FS) Bucket() string { return f.root } func (f *FS) EnsureBucket(context.Context) error { return os.MkdirAll(f.root, 0o755) } func (f *FS) Ping(context.Context) error { info, err := os.Stat(f.root) if err != nil { return err } if !info.IsDir() { return errors.New("fs: root is not a directory") } return nil } // objectPath maps an opaque key to an absolute path inside the root, rejecting // absolute keys and any key that resolves outside the root. func (f *FS) objectPath(key string) (string, error) { if key == "" || filepath.IsAbs(key) { return "", errInvalidKey } clean := filepath.Clean(key) if clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { return "", errInvalidKey } p := filepath.Join(f.root, clean) rel, err := filepath.Rel(f.root, p) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { return "", errInvalidKey } return p, nil } func (f *FS) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error { p, err := f.objectPath(key) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return fmt.Errorf("fs: mkdir: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(p), ".upload-*") if err != nil { return fmt.Errorf("fs: temp file: %w", err) } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() if _, err := io.Copy(tmp, body); err != nil { _ = tmp.Close() return fmt.Errorf("fs: write: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("fs: close: %w", err) } if err := os.Rename(tmpName, p); err != nil { return fmt.Errorf("fs: rename: %w", err) } return nil } func (f *FS) Get(_ context.Context, key string) ([]byte, error) { p, err := f.objectPath(key) if err != nil { return nil, err } data, err := os.ReadFile(p) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrObjectNotFound } return nil, err } return data, nil } // Open streams a blob from disk for processing without buffering it fully. func (f *FS) Open(_ context.Context, key string) (io.ReadCloser, error) { p, err := f.objectPath(key) if err != nil { return nil, err } file, err := os.Open(p) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrObjectNotFound } return nil, err } return file, nil } func (f *FS) Delete(_ context.Context, keys ...string) error { var errs []error for _, key := range keys { p, err := f.objectPath(key) if err != nil { errs = append(errs, err) continue } if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { errs = append(errs, err) } } return errors.Join(errs...) } // URL returns a signed app URL served by this process. func (f *FS) URL(_ context.Context, key string) (string, error) { if _, err := f.objectPath(key); err != nil { return "", err } return f.signer.URL(key), nil } // List returns every blob under prefix as a key relative to the storage root. func (f *FS) List(_ context.Context, prefix string) ([]ObjectInfo, error) { var out []ObjectInfo err := filepath.WalkDir(f.root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } rel, err := filepath.Rel(f.root, path) if err != nil { return err } rel = filepath.ToSlash(rel) if prefix != "" && !strings.HasPrefix(rel, prefix) { return nil } info, err := d.Info() if err != nil { return err } out = append(out, ObjectInfo{Key: rel, Size: info.Size(), ModTime: info.ModTime()}) return nil }) if err != nil { return nil, err } return out, nil } // ServeObject validates the signed URL and serves the blob. It supports range // requests and sets the content type from the key extension. It writes the // response itself and returns nil on success. func (f *FS) ServeObject(w http.ResponseWriter, r *http.Request, key string) error { if unescaped, err := url.PathUnescape(key); err == nil { key = unescaped } if err := f.signer.VerifyRequest(key, r); err != nil { return err } p, err := f.objectPath(key) if err != nil { return ErrSignedURLInvalid } file, err := os.Open(p) if err != nil { if errors.Is(err, os.ErrNotExist) { return ErrObjectNotFound } return err } defer file.Close() info, err := file.Stat() if err != nil { return err } w.Header().Set("Content-Type", contentTypeForKey(key)) http.ServeContent(w, r, info.Name(), info.ModTime(), file) return nil } var imageContentTypes = map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".heic": "image/heic", ".heif": "image/heif", ".tiff": "image/tiff", ".tif": "image/tiff", ".bmp": "image/bmp", ".avif": "image/avif", } func contentTypeForKey(key string) string { ext := strings.ToLower(filepath.Ext(key)) if ct, ok := imageContentTypes[ext]; ok { return ct } if ct := mime.TypeByExtension(ext); ct != "" { return ct } return "application/octet-stream" }