From 9622af513fb397dfa0c47d2e003953902a7ea5f0 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 30 Jul 2026 15:39:57 -0700 Subject: [PATCH 1/2] fix(serve): serve static files, admin paths, and req.user The dev server was missing three pieces of wiring the production host sets up, so most of what http.serve plugins do could not be exercised locally. Mount public/ as PublicFS when loading a project directory, the same way the manager's loadByPath does. Without it every static file and directory index answered 404, so a plugin's own pages were unreachable. Set RequireAdmin. The plugin server returns 404 for any manifest-declared admin path when it is nil, which meant the whole UI of file-manager, admin-demo and theme-hub was unreachable. Locally it is an open pass-through, because the URL this dev server prints is the plugin root and for those plugins that is an admin path, so requiring a credential would make the advertised URL unopenable in a browser. Owncast wires the same hook to its admin Basic Auth middleware. Set GetRequestUser so req.user is populated. The Bearer user: header the dev server already documents had no effect on HTTP handlers before. Also reorder two imports, which is what gofmt wants and is unrelated to the fix. --- host-runtime/cmd/owncast-plugin-serve/main.go | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/host-runtime/cmd/owncast-plugin-serve/main.go b/host-runtime/cmd/owncast-plugin-serve/main.go index 1d72259..5abb469 100644 --- a/host-runtime/cmd/owncast-plugin-serve/main.go +++ b/host-runtime/cmd/owncast-plugin-serve/main.go @@ -33,8 +33,8 @@ import ( "time" extism "github.com/extism/go-sdk" - "github.com/owncast/owncast/services/plugins/kv" plugin "github.com/owncast/owncast/services/plugins" + "github.com/owncast/owncast/services/plugins/kv" ) const defaultPort = "8080" @@ -277,7 +277,7 @@ func main() { GetRequestUser: devRequestUser, } - loaded, name, assetsDescription := loadTarget(ctx, env, abs) + loaded, name, staticDescription := loadTarget(ctx, env, abs) defer loaded.Close(ctx) dispatcher := plugin.NewDispatcher([]*plugin.Loaded{loaded}) @@ -308,6 +308,15 @@ func main() { server := plugin.NewServer([]*plugin.Loaded{loaded}) server.IsAuthenticated = env.IsAuthenticated + // Manifest-declared admin paths are served without credentials locally. + // The URL this dev server prints is the plugin's own root, and for a + // plugin whose only page is an admin page (file-manager, admin-demo, + // theme-hub) that URL *is* an admin path, so demanding a header would + // make the advertised URL unopenable in a browser. Production wires + // this to Owncast's admin Basic Auth middleware, so the same paths are + // gated by the real admin password there. + server.RequireAdmin = func(h http.HandlerFunc) http.HandlerFunc { return h } + server.GetRequestUser = env.GetRequestUser server.SSE = sseHub mux := http.NewServeMux() @@ -323,8 +332,11 @@ func main() { }) fmt.Printf("owncast-plugin-serve: %s @ http://localhost:%s/plugins/%s/\n", name, port, name) - if assetsDescription != "" { - fmt.Printf(" static assets: %s\n", assetsDescription) + if staticDescription != "" { + fmt.Printf(" static files: %s\n", staticDescription) + } + if len(loaded.Manifest.Admin.Pages) > 0 { + fmt.Println(" admin pages: open to anyone locally, admin password required in Owncast") } fmt.Printf(" drive chat: curl -XPOST localhost:%s/_dev/chat -d '{\"user\":\"alice\",\"body\":\"hi\"}'\n", port) fmt.Printf(" drive event: curl -XPOST localhost:%s/_dev/event -d '{\"type\":\"stream.started\",\"payload\":{}}'\n", port) @@ -559,9 +571,9 @@ Drive event: POST /_dev/event {"type":"stream.started","payload":{}} } // loadTarget loads the plugin from either a project directory (loose files: -// plugin.manifest.json + .wasm + optional assets/) or a packaged -// .ocpkg file. Returns the loaded plugin, its declared name, and a -// human-readable description of where assets came from. +// plugin.manifest.json + .wasm + optional public/ and assets/) or a +// packaged .ocpkg file. Returns the loaded plugin, its declared name, and a +// human-readable description of where static files came from. func loadTarget(ctx context.Context, env *plugin.HostEnv, target string) (*plugin.Loaded, string, string) { info, err := os.Stat(target) if err != nil { @@ -573,8 +585,10 @@ func loadTarget(ctx context.Context, env *plugin.HostEnv, target string) (*plugi if err != nil { fatal("load package: %v", err) } + // LoadPackage mounts public/ and assets/ from inside the archive, + // so there is nothing to wire up here. assets := "" - if loaded.AssetsFS != nil { + if loaded.PublicFS != nil || loaded.AssetsFS != nil { assets = "embedded in " + filepath.Base(target) } return loaded, loaded.Manifest.Slug, assets @@ -601,13 +615,22 @@ func loadTarget(ctx context.Context, env *plugin.HostEnv, target string) (*plugi if err != nil { fatal("load plugin: %v", err) } + // Mount public/ as the web-served root and assets/ as the internal-only + // root the host inlines from, the same pair the manager's loadByPath + // wires in production. Without PublicFS the plugin's own pages and + // directory indexes 404, which makes http.serve plugins untestable here. + var staticDirs []string + publicDir := filepath.Join(target, "public") + if info, err := os.Stat(publicDir); err == nil && info.IsDir() { + loaded.PublicFS = os.DirFS(publicDir) + staticDirs = append(staticDirs, publicDir) + } assetsDir := filepath.Join(target, "assets") - assetsDescription := "" if info, err := os.Stat(assetsDir); err == nil && info.IsDir() { loaded.AssetsFS = os.DirFS(assetsDir) - assetsDescription = assetsDir + staticDirs = append(staticDirs, assetsDir) } - return loaded, m.Slug, assetsDescription + return loaded, m.Slug, strings.Join(staticDirs, ", ") } func logging(h http.Handler) http.Handler { From c484d719c03e26d25c7c193020d71c38790468ab Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 30 Jul 2026 15:46:31 -0700 Subject: [PATCH 2/2] fix(serve): bind loopback and clarify the static-files naming Review feedback on the dev server fix. Bind 127.0.0.1 instead of every interface. This calls itself a localhost dev server and prints localhost URLs, and now that admin paths are served with no credential, binding every interface would hand a plugin like file-manager an open file read, write and delete API to anyone on the same network. Rename the .ocpkg branch's local variable to match what it now describes, which is all static files rather than only assets. --- host-runtime/cmd/owncast-plugin-serve/main.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/host-runtime/cmd/owncast-plugin-serve/main.go b/host-runtime/cmd/owncast-plugin-serve/main.go index 5abb469..86ce46e 100644 --- a/host-runtime/cmd/owncast-plugin-serve/main.go +++ b/host-runtime/cmd/owncast-plugin-serve/main.go @@ -308,7 +308,8 @@ func main() { server := plugin.NewServer([]*plugin.Loaded{loaded}) server.IsAuthenticated = env.IsAuthenticated - // Manifest-declared admin paths are served without credentials locally. + // Manifest-declared admin paths are served without credentials by this + // dev server, which is why it binds loopback only. // The URL this dev server prints is the plugin's own root, and for a // plugin whose only page is an admin page (file-manager, admin-demo, // theme-hub) that URL *is* an admin path, so demanding a header would @@ -336,12 +337,16 @@ func main() { fmt.Printf(" static files: %s\n", staticDescription) } if len(loaded.Manifest.Admin.Pages) > 0 { - fmt.Println(" admin pages: open to anyone locally, admin password required in Owncast") + fmt.Println(" admin pages: served with no password here, Owncast requires the admin password") } fmt.Printf(" drive chat: curl -XPOST localhost:%s/_dev/chat -d '{\"user\":\"alice\",\"body\":\"hi\"}'\n", port) fmt.Printf(" drive event: curl -XPOST localhost:%s/_dev/event -d '{\"type\":\"stream.started\",\"payload\":{}}'\n", port) fmt.Println(" Ctrl-C to stop") - if err := http.ListenAndServe(":"+port, mux); err != nil { + // Loopback only. This is a localhost dev server, and the admin paths below + // are served with no credential, which for a plugin like file-manager means + // an open file read, write and delete API. Binding every interface would + // hand that to anyone on the same network. + if err := http.ListenAndServe("127.0.0.1:"+port, mux); err != nil { fatal("listen: %v", err) } } @@ -587,11 +592,11 @@ func loadTarget(ctx context.Context, env *plugin.HostEnv, target string) (*plugi } // LoadPackage mounts public/ and assets/ from inside the archive, // so there is nothing to wire up here. - assets := "" + staticDescription := "" if loaded.PublicFS != nil || loaded.AssetsFS != nil { - assets = "embedded in " + filepath.Base(target) + staticDescription = "embedded in " + filepath.Base(target) } - return loaded, loaded.Manifest.Slug, assets + return loaded, loaded.Manifest.Slug, staticDescription } manifestPath := filepath.Join(target, "plugin.manifest.json")