From ea6824c1334d4bcfde5e845cc3151af0307f8411 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 20:52:59 +0000 Subject: [PATCH] Add dynamic package file serving for installed packages Fixes Foundry VTT module discovery by serving files from any package installed via the package manager. Generic route at /packages/serve/:type/:slug/* plus backwards-compatible /foundry-module/* alias. Includes path traversal prevention, in-memory cache with invalidation on install/remove, permissive CORS, and rate limiting. https://claude.ai/code/session_01A5jpDgqUvW6iLXXSj49F27 --- internal/app/routes.go | 6 + internal/plugins/packages/routes.go | 14 +++ internal/plugins/packages/serve.go | 158 +++++++++++++++++++++++++++ internal/plugins/packages/service.go | 44 +++++++- 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 internal/plugins/packages/serve.go diff --git a/internal/app/routes.go b/internal/app/routes.go index 72d52861..d177cd6c 100644 --- a/internal/app/routes.go +++ b/internal/app/routes.go @@ -1218,6 +1218,12 @@ func (a *App) RegisterRoutes() { packages.ConfigureSettings(pkgService, settingsRepo) pkgHandler := packages.NewHandler(pkgService) pkgOwnerHandler := packages.NewOwnerHandler(pkgService) + // Public package file serving — always available so Foundry VTT can + // fetch module.json even when the admin UI is degraded. + pkgServeHandler := packages.NewServeHandler(pkgService) + packages.SetOnServeInvalidate(pkgService, pkgServeHandler.InvalidateCache) + packages.RegisterPublicRoutes(e, pkgServeHandler, middleware.RateLimit(300, time.Minute)) + if a.PluginHealth.IsHealthy("packages") { packages.RegisterRoutes(adminGroup, pkgHandler) diff --git a/internal/plugins/packages/routes.go b/internal/plugins/packages/routes.go index 9fc5de63..18213291 100644 --- a/internal/plugins/packages/routes.go +++ b/internal/plugins/packages/routes.go @@ -41,6 +41,20 @@ func RegisterRoutes(admin *echo.Group, h *Handler) { g.POST("/settings", h.SaveSecuritySettings) } +// RegisterPublicRoutes mounts unauthenticated routes for serving files from +// installed packages. External clients (e.g., Foundry VTT) fetch manifests +// and scripts from these endpoints. Rate limiting is applied at the group level. +func RegisterPublicRoutes(e *echo.Echo, sh *ServeHandler, rl echo.MiddlewareFunc) { + // Generic route: /packages/serve/:type/:slug/filepath + g := e.Group("/packages/serve") + g.Use(rl) + g.GET("/:type/:slug/*", sh.ServePackageFile) + + // Backwards-compatible alias for Foundry VTT module discovery. + // Foundry expects module.json at a stable URL like /foundry-module/module.json. + e.GET("/foundry-module/*", sh.ServeFoundryAlias, rl) +} + // RegisterOwnerRoutes mounts the owner-facing system submission routes. // These routes require authentication but NOT admin privileges. func RegisterOwnerRoutes(authenticated *echo.Group, oh *OwnerHandler) { diff --git a/internal/plugins/packages/serve.go b/internal/plugins/packages/serve.go new file mode 100644 index 00000000..13cbaa3d --- /dev/null +++ b/internal/plugins/packages/serve.go @@ -0,0 +1,158 @@ +// Package packages — serve.go provides HTTP handlers for serving files from +// installed packages. Any package type (Foundry module, system, etc.) installed +// via the package manager has its files automatically servable at a public URL. +// +// Security: path traversal prevention via filepath.Clean + prefix check, +// per-IP rate limiting (applied at route level), and permissive CORS for +// external VTT clients. +package packages + +import ( + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/labstack/echo/v4" +) + +// ServeHandler serves static files from installed packages. +type ServeHandler struct { + svc PackageService + + mu sync.RWMutex + cache map[string]string // "type/slug" -> install path +} + +// NewServeHandler creates a handler for serving package files. +func NewServeHandler(svc PackageService) *ServeHandler { + return &ServeHandler{ + svc: svc, + cache: make(map[string]string), + } +} + +// InvalidateCache clears the cached install paths, forcing a fresh lookup +// on the next request. Call this after install, update, or uninstall. +func (h *ServeHandler) InvalidateCache() { + h.mu.Lock() + h.cache = make(map[string]string) + h.mu.Unlock() +} + +// resolvePath looks up the install path for a package type+slug combo. +// Results are cached in memory to avoid repeated DB queries. +func (h *ServeHandler) resolvePath(pkgType, slug string) string { + key := pkgType + "/" + slug + + h.mu.RLock() + if p, ok := h.cache[key]; ok { + h.mu.RUnlock() + return p + } + h.mu.RUnlock() + + // Cache miss — query the service. + p := h.svc.InstalledPackagePath(PackageType(pkgType), slug) + + h.mu.Lock() + h.cache[key] = p + h.mu.Unlock() + + return p +} + +// ServePackageFile handles GET /packages/serve/:type/:slug/* +// It resolves the package install path and serves the requested file. +func (h *ServeHandler) ServePackageFile(c echo.Context) error { + pkgType := c.Param("type") + slug := c.Param("slug") + filePath := c.Param("*") + + if pkgType == "" || slug == "" || filePath == "" { + return c.NoContent(http.StatusNotFound) + } + + installPath := h.resolvePath(pkgType, slug) + if installPath == "" { + return c.NoContent(http.StatusNotFound) + } + + return h.serveFile(c, installPath, filePath) +} + +// ServeFoundryAlias handles GET /foundry-module/* for backwards compatibility. +// It resolves the active Foundry module's install path automatically. +func (h *ServeHandler) ServeFoundryAlias(c echo.Context) error { + filePath := c.Param("*") + if filePath == "" { + return c.NoContent(http.StatusNotFound) + } + + installPath := h.resolvePath(string(PackageTypeFoundryModule), "chronicle-foundry") + if installPath == "" { + // Fallback: try resolving any foundry-module type package. + installPath = h.svc.FoundryModulePath() + if installPath == "" { + return c.NoContent(http.StatusNotFound) + } + } + + return h.serveFile(c, installPath, filePath) +} + +// serveFile safely resolves and serves a file from the given base directory. +// Prevents path traversal by cleaning the path and verifying it stays within +// the base directory. +func (h *ServeHandler) serveFile(c echo.Context, baseDir, requestedPath string) error { + // Clean the requested path to prevent traversal. + cleaned := filepath.Clean(requestedPath) + + // Reject any path that tries to escape the base directory. + if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + slog.Warn("package serve: path traversal attempt blocked", + slog.String("path", requestedPath), + slog.String("ip", c.RealIP()), + ) + return c.NoContent(http.StatusBadRequest) + } + + fullPath := filepath.Join(baseDir, cleaned) + + // Double-check: resolved path must be under the base directory. + absBase, err := filepath.Abs(baseDir) + if err != nil { + return c.NoContent(http.StatusInternalServerError) + } + absPath, err := filepath.Abs(fullPath) + if err != nil { + return c.NoContent(http.StatusInternalServerError) + } + if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) && absPath != absBase { + slog.Warn("package serve: resolved path outside base directory", + slog.String("resolved", absPath), + slog.String("base", absBase), + slog.String("ip", c.RealIP()), + ) + return c.NoContent(http.StatusBadRequest) + } + + // Verify the file exists and is not a directory. + info, err := os.Stat(fullPath) + if err != nil || info.IsDir() { + return c.NoContent(http.StatusNotFound) + } + + // Reject symlinks to prevent escaping the install directory. + if info.Mode()&os.ModeSymlink != 0 { + return c.NoContent(http.StatusForbidden) + } + + // Set permissive CORS for external VTT clients. + c.Response().Header().Set("Access-Control-Allow-Origin", "*") + c.Response().Header().Set("Cache-Control", "public, max-age=3600") + + return c.File(fullPath) +} diff --git a/internal/plugins/packages/service.go b/internal/plugins/packages/service.go index 7ed229ed..56acf40c 100644 --- a/internal/plugins/packages/service.go +++ b/internal/plugins/packages/service.go @@ -103,6 +103,11 @@ type PackageService interface { // ReconcileOrphanedInstalls detects package directories on disk that have // no corresponding DB record (e.g. after a database wipe). ReconcileOrphanedInstalls(ctx context.Context) ([]OrphanedInstall, error) + + // InstalledPackagePath returns the on-disk install path for the active + // (installed + approved) package matching the given type and slug. + // Returns empty string if no matching package is installed. + InstalledPackagePath(pkgType PackageType, slug string) string } // packageService implements PackageService. @@ -111,8 +116,9 @@ type packageService struct { github *GitHubClient settings SettingsReader settingsWriter SettingsWriter - mediaDir string // Root media directory (e.g., ./media). + mediaDir string // Root media directory (e.g., ./media). onSystemInstall func() // Called after a system package is installed. + onServeInvalidate func() // Called after install/remove to invalidate serve cache. } // NewPackageService creates a new package service with the given dependencies. @@ -132,6 +138,14 @@ func SetOnSystemInstall(svc PackageService, fn func()) { } } +// SetOnServeInvalidate wires a callback invoked after a package is installed, +// updated, or removed. Used to invalidate the serve handler's path cache. +func SetOnServeInvalidate(svc PackageService, fn func()) { + if s, ok := svc.(*packageService); ok { + s.onServeInvalidate = fn + } +} + // ConfigureSettings wires settings reader/writer into the package service. // Called from routes.go after both services are initialized. func ConfigureSettings(svc PackageService, settings SettingsReader) { @@ -280,6 +294,12 @@ func (s *packageService) RemovePackage(ctx context.Context, id string) error { slog.String("id", id), slog.String("slug", pkg.Slug), ) + + // Invalidate serve cache so removed packages stop being served. + if s.onServeInvalidate != nil { + s.onServeInvalidate() + } + return nil } @@ -411,6 +431,11 @@ func (s *packageService) InstallVersion(ctx context.Context, packageID, version s.onSystemInstall() } + // Invalidate serve cache so the new install path is picked up. + if s.onServeInvalidate != nil { + s.onServeInvalidate() + } + return nil } @@ -584,6 +609,23 @@ func (s *packageService) FoundryModulePath() string { return "" } +// InstalledPackagePath returns the on-disk install path for the active +// (installed + approved) package matching the given type and slug. +// Returns empty string if no matching package is installed. +func (s *packageService) InstalledPackagePath(pkgType PackageType, slug string) string { + ctx := context.Background() + packages, err := s.repo.ListPackages(ctx) + if err != nil { + return "" + } + for _, pkg := range packages { + if pkg.Type == pkgType && pkg.Slug == slug && pkg.InstallPath != "" && pkg.Status == StatusApproved { + return pkg.InstallPath + } + } + return "" +} + // --- Submission/Approval Workflow --- // SubmitPackage lets a campaign owner submit a repo URL for review.