Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/app/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions internal/plugins/packages/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
158 changes: 158 additions & 0 deletions internal/plugins/packages/serve.go
Original file line number Diff line number Diff line change
@@ -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)
}
44 changes: 43 additions & 1 deletion internal/plugins/packages/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down
Loading