diff --git a/apps/web/src/pages/home/composables/useMediaGallery.ts b/apps/web/src/pages/home/composables/useMediaGallery.ts index 8333311d..cef48f7c 100644 --- a/apps/web/src/pages/home/composables/useMediaGallery.ts +++ b/apps/web/src/pages/home/composables/useMediaGallery.ts @@ -58,7 +58,7 @@ function normalizeSrc(src: string): string { if (!src || src.startsWith('data:')) return src try { const u = new URL(src, window.location.origin) - return u.pathname + u.search + return u.pathname } catch { return src } diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 492dbe79..e68f0671 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -10,6 +10,7 @@ import ( "net/http" "os" stdpath "path" + "path/filepath" "strings" "time" @@ -81,6 +82,8 @@ import ( sessionpkg "github.com/memohai/memoh/internal/session" "github.com/memohai/memoh/internal/settings" "github.com/memohai/memoh/internal/storage/providers/containerfs" + "github.com/memohai/memoh/internal/storage/providers/fallback" + "github.com/memohai/memoh/internal/storage/providers/localfs" ttspkg "github.com/memohai/memoh/internal/tts" ttsedge "github.com/memohai/memoh/internal/tts/adapter/edge" "github.com/memohai/memoh/internal/version" @@ -672,8 +675,14 @@ func provideSessionHandler(log *slog.Logger, sessionService *sessionpkg.Service, return handlers.NewSessionHandler(log, sessionService, botService, accountService) } -func provideMediaService(log *slog.Logger, manager *workspace.Manager) *media.Service { - provider := containerfs.New(manager) +func provideMediaService(log *slog.Logger, manager *workspace.Manager, cfg config.Config) *media.Service { + primary := containerfs.New(manager) + dataRoot := cfg.Workspace.DataRoot + if dataRoot == "" { + dataRoot = config.DefaultDataRoot + } + secondary := localfs.New(filepath.Join(dataRoot, "media")) + provider := fallback.New(primary, secondary) return media.NewService(log, provider) } diff --git a/cmd/memoh/serve.go b/cmd/memoh/serve.go index ebde402b..74613953 100644 --- a/cmd/memoh/serve.go +++ b/cmd/memoh/serve.go @@ -9,6 +9,7 @@ import ( "net/http" "os" stdpath "path" + "path/filepath" "strings" "time" @@ -82,6 +83,8 @@ import ( sessionpkg "github.com/memohai/memoh/internal/session" "github.com/memohai/memoh/internal/settings" "github.com/memohai/memoh/internal/storage/providers/containerfs" + "github.com/memohai/memoh/internal/storage/providers/fallback" + "github.com/memohai/memoh/internal/storage/providers/localfs" ttspkg "github.com/memohai/memoh/internal/tts" ttsedge "github.com/memohai/memoh/internal/tts/adapter/edge" "github.com/memohai/memoh/internal/version" @@ -576,8 +579,14 @@ func (h *memohAuthHandler) Register(e *echo.Echo) { e.POST("/api/auth/refresh", h.inner.Refresh) } -func provideMediaService(log *slog.Logger, manager *workspace.Manager) *media.Service { - provider := containerfs.New(manager) +func provideMediaService(log *slog.Logger, manager *workspace.Manager, cfg config.Config) *media.Service { + primary := containerfs.New(manager) + dataRoot := cfg.Workspace.DataRoot + if dataRoot == "" { + dataRoot = config.DefaultDataRoot + } + secondary := localfs.New(filepath.Join(dataRoot, "media")) + provider := fallback.New(primary, secondary) return media.NewService(log, provider) } diff --git a/internal/storage/providers/fallback/provider.go b/internal/storage/providers/fallback/provider.go new file mode 100644 index 00000000..51818151 --- /dev/null +++ b/internal/storage/providers/fallback/provider.go @@ -0,0 +1,81 @@ +// Package fallback implements storage.Provider that tries a primary provider +// first (e.g. containerfs) and falls back to a secondary (e.g. localfs) on +// failure. Reads check both providers so assets stored by either are reachable. +package fallback + +import ( + "context" + "io" + + "github.com/memohai/memoh/internal/storage" +) + +// Provider delegates to primary and falls back to secondary on write errors. +type Provider struct { + primary storage.Provider + secondary storage.Provider +} + +// New creates a fallback provider. +func New(primary, secondary storage.Provider) *Provider { + return &Provider{primary: primary, secondary: secondary} +} + +func (p *Provider) Put(ctx context.Context, key string, reader io.Reader) error { + err := p.primary.Put(ctx, key, reader) + if err == nil { + return nil + } + if seeker, ok := reader.(io.Seeker); ok { + if _, seekErr := seeker.Seek(0, io.SeekStart); seekErr != nil { + return err + } + } + return p.secondary.Put(ctx, key, reader) +} + +func (p *Provider) Open(ctx context.Context, key string) (io.ReadCloser, error) { + rc, err := p.primary.Open(ctx, key) + if err == nil { + return rc, nil + } + return p.secondary.Open(ctx, key) +} + +func (p *Provider) Delete(ctx context.Context, key string) error { + err := p.primary.Delete(ctx, key) + if err == nil { + return nil + } + return p.secondary.Delete(ctx, key) +} + +func (p *Provider) AccessPath(key string) string { + return p.primary.AccessPath(key) +} + +// ListPrefix delegates to both providers and deduplicates. +func (p *Provider) ListPrefix(ctx context.Context, prefix string) ([]string, error) { + keys, _ := tryListPrefix(ctx, p.primary, prefix) + secondaryKeys, _ := tryListPrefix(ctx, p.secondary, prefix) + seen := make(map[string]struct{}, len(keys)) + for _, k := range keys { + seen[k] = struct{}{} + } + for _, k := range secondaryKeys { + if _, ok := seen[k]; !ok { + keys = append(keys, k) + } + } + if len(keys) == 0 { + return nil, nil + } + return keys, nil +} + +func tryListPrefix(ctx context.Context, p storage.Provider, prefix string) ([]string, error) { + if lister, ok := p.(storage.PrefixLister); ok { + return lister.ListPrefix(ctx, prefix) + } + return nil, nil +} diff --git a/internal/storage/providers/localfs/provider.go b/internal/storage/providers/localfs/provider.go new file mode 100644 index 00000000..939842f7 --- /dev/null +++ b/internal/storage/providers/localfs/provider.go @@ -0,0 +1,77 @@ +// Package localfs implements storage.Provider backed by the host filesystem. +// Files are stored under {root}/{routingKey}. +package localfs + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// Provider stores media assets on the host filesystem. +type Provider struct { + root string +} + +// New creates a local filesystem storage provider rooted at dir. +func New(root string) *Provider { + return &Provider{root: root} +} + +func (p *Provider) Put(_ context.Context, key string, reader io.Reader) error { + dest := p.resolve(key) + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + f, err := os.Create(dest) //nolint:gosec // path is constructed from trusted storage key + if err != nil { + return fmt.Errorf("create: %w", err) + } + defer func() { _ = f.Close() }() + if _, err := io.Copy(f, reader); err != nil { + return fmt.Errorf("write: %w", err) + } + return nil +} + +func (p *Provider) Open(_ context.Context, key string) (io.ReadCloser, error) { + return os.Open(p.resolve(key)) +} + +func (p *Provider) Delete(_ context.Context, key string) error { + return os.Remove(p.resolve(key)) +} + +func (p *Provider) AccessPath(key string) string { + return p.resolve(key) +} + +// ListPrefix returns all keys sharing a common prefix (directory listing). +func (p *Provider) ListPrefix(_ context.Context, prefix string) ([]string, error) { + dir := filepath.Dir(p.resolve(prefix)) + base := filepath.Base(prefix) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var keys []string + for _, e := range entries { + if e.IsDir() { + continue + } + if strings.HasPrefix(e.Name(), base) { + rel, _ := filepath.Rel(p.root, filepath.Join(dir, e.Name())) + if rel != "" { + keys = append(keys, filepath.ToSlash(rel)) + } + } + } + return keys, nil +} + +func (p *Provider) resolve(key string) string { + return filepath.Join(p.root, filepath.FromSlash(key)) +}