diff --git a/services/web/README.md b/services/web/README.md index 80aa8fc4999..7d46abd53d0 100644 --- a/services/web/README.md +++ b/services/web/README.md @@ -5,6 +5,52 @@ Note that clients will respond with a connection error if the web service is not The web service also provides a minimal API for branding functionality like changing the logo shown. +## Static-only Mode + +Set the environment variable `WEB_STATIC_ONLY=true` to disable the dynamic web API surface (branding endpoints, theme handlers, external app discovery) and serve the embedded/static assets only. Ensure that a `config.json` file exists in the asset root (`services/web/assets/core/config.json`) or provide one via `WEB_UI_CONFIG_FILE`; this file is exposed through `/config.json` by the static file server. + +### Development (local binary) + +```bash +go build -o bin/web ./services/web/cmd/web + +# Option A: point to a config file without modifying the assets tree +WEB_STATIC_ONLY=true \ +WEB_HTTP_ADDR=127.0.0.1:9100 \ +WEB_UI_CONFIG_FILE=$(pwd)/services/web/assets/core/config.json \ +./bin/web + +# Option B: place config.json inside services/web/assets/core and rely on the assets only +WEB_STATIC_ONLY=true \ +WEB_HTTP_ADDR=127.0.0.1:9100 \ +./bin/web + +# Verification +curl -sS http://127.0.0.1:9100/config.json | jq . >/dev/null +curl -I http://127.0.0.1:9100/ +``` + +### Production (existing Docker image) + +```bash +make -C ocis dev-docker +docker run --rm -p 9100:9100 \ + -e WEB_STATIC_ONLY=true \ + -e WEB_HTTP_ADDR=0.0.0.0:9100 \ + -e WEB_ASSET_CORE_PATH=/data/assets/core \ + -v $(pwd)/services/web/assets/core:/data/assets/core:ro \ + --entrypoint web \ + owncloud/ocis:dev + +# Optional: mount a standalone config.json file instead of updating the assets directory +# -v $(pwd)/services/web/assets/core/config.json:/data/config.json:ro \ +# -e WEB_UI_CONFIG_FILE=/data/config.json \ + +# Verification +curl -sS http://localhost:9100/config.json | jq . >/dev/null +curl -I http://localhost:9100/ +``` + ## Custom Compiled Web Assets If you want to use your custom compiled web client assets instead of the embedded ones, diff --git a/services/web/pkg/config/config.go b/services/web/pkg/config/config.go index d5d93153570..00c035df8ea 100644 --- a/services/web/pkg/config/config.go +++ b/services/web/pkg/config/config.go @@ -110,6 +110,7 @@ type ExternalAppConfig struct { type Web struct { ThemeServer string `yaml:"theme_server" env:"OCIS_URL;WEB_UI_THEME_SERVER" desc:"Base URL to load themes from. Will be prepended to the theme path." introductionVersion:"pre5.0"` // used to build Theme in WebConfig ThemePath string `yaml:"theme_path" env:"WEB_UI_THEME_PATH" desc:"Path to the theme json file. Will be appended to the URL of the theme server." introductionVersion:"pre5.0"` // used to build Theme in WebConfig + StaticOnly bool `yaml:"static_only" env:"WEB_STATIC_ONLY" desc:"Serve only static assets without exposing dynamic web endpoints. Requires a config.json file in the asset root or provided via WEB_UI_CONFIG_FILE."` Config WebConfig `yaml:"config"` } diff --git a/services/web/pkg/server/http/server.go b/services/web/pkg/server/http/server.go index a4b1323fbc7..512377c4e44 100644 --- a/services/web/pkg/server/http/server.go +++ b/services/web/pkg/server/http/server.go @@ -2,8 +2,10 @@ package http import ( "fmt" + "os" "path" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" "go-micro.dev/v4" @@ -17,6 +19,7 @@ import ( "github.com/owncloud/ocis/v2/services/web" "github.com/owncloud/ocis/v2/services/web/pkg/apps" svc "github.com/owncloud/ocis/v2/services/web/pkg/service/v0" + "github.com/spf13/afero" ) var ( @@ -46,13 +49,17 @@ func Server(opts ...Option) (http.Service, error) { return http.Service{}, fmt.Errorf("could not initialize http service: %w", err) } - gatewaySelector, err := pool.GatewaySelector( - options.Config.GatewayAddress, - pool.WithRegistry(registry.GetRegistry()), - pool.WithTracerProvider(options.TraceProvider), - ) - if err != nil { - return http.Service{}, err + var gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + if !options.Config.Web.StaticOnly { + var err error + gatewaySelector, err = pool.GatewaySelector( + options.Config.GatewayAddress, + pool.WithRegistry(registry.GetRegistry()), + pool.WithTracerProvider(options.TraceProvider), + ) + if err != nil { + return http.Service{}, err + } } appsFS := fsx.NewFallbackFS( @@ -60,17 +67,30 @@ func Server(opts ...Option) (http.Service, error) { fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/apps"), ) // build and inject the list of applications into the config - for _, application := range apps.List(options.Logger, options.Config.Apps, appsFS.Secondary().IOFS(), appsFS.Primary().IOFS()) { - options.Config.Web.Config.ExternalApps = append( - options.Config.Web.Config.ExternalApps, - application.ToExternal(path.Join(options.Config.HTTP.Root, _customAppsEndpoint)), - ) + if !options.Config.Web.StaticOnly { + for _, application := range apps.List(options.Logger, options.Config.Apps, appsFS.Secondary().IOFS(), appsFS.Primary().IOFS()) { + options.Config.Web.Config.ExternalApps = append( + options.Config.Web.Config.ExternalApps, + application.ToExternal(path.Join(options.Config.HTTP.Root, _customAppsEndpoint)), + ) + } } coreFS := fsx.NewFallbackFS( fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.CorePath), fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/core"), ) + if options.Config.Web.StaticOnly && options.Config.File != "" { + data, err := os.ReadFile(options.Config.File) + if err != nil { + return http.Service{}, fmt.Errorf("load static config: %w", err) + } + mem := fsx.NewMemMapFs() + if err := afero.WriteFile(mem, "config.json", data, 0o644); err != nil { + return http.Service{}, fmt.Errorf("write static config: %w", err) + } + coreFS = fsx.NewFallbackFS(mem, coreFS) + } themeFS := fsx.NewFallbackFS( fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.ThemesPath), fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"), @@ -87,14 +107,13 @@ func Server(opts ...Option) (http.Service, error) { fsx.NewBasePathFs(coreFS.Secondary(), "themes"), ) - handle, err := svc.NewService( + svcOptions := []svc.Option{ svc.Logger(options.Logger), svc.CoreFS(coreFS.IOFS()), svc.AppFS(appsFS.IOFS()), svc.ThemeFS(themeFS), svc.AppsHTTPEndpoint(_customAppsEndpoint), svc.Config(options.Config), - svc.GatewaySelector(gatewaySelector), svc.Middleware( middleware.GetOtelhttpMiddleware(options.Config.Service.Name, options.TraceProvider), chimiddleware.RealIP, @@ -117,7 +136,12 @@ func Server(opts ...Option) (http.Service, error) { ), ), svc.TraceProvider(options.TraceProvider), - ) + } + if !options.Config.Web.StaticOnly { + svcOptions = append(svcOptions, svc.GatewaySelector(gatewaySelector)) + } + + handle, err := svc.NewService(svcOptions...) if err != nil { return http.Service{}, err diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index f6924e20984..8e8a33e52ba 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -47,39 +47,46 @@ func NewService(opts ...Option) (Service, error) { themeFS: options.ThemeFS, gatewaySelector: options.GatewaySelector, } - - themeService, err := theme.NewService( - theme.ServiceOptions{}. - WithThemeFS(options.ThemeFS). - WithGatewaySelector(options.GatewaySelector), + var ( + err error + themeService theme.Service ) - if err != nil { - return svc, err + if !options.Config.Web.StaticOnly { + themeService, err = theme.NewService( + theme.ServiceOptions{}. + WithThemeFS(options.ThemeFS). + WithGatewaySelector(options.GatewaySelector), + ) + if err != nil { + return svc, err + } } m.Route(options.Config.HTTP.Root, func(r chi.Router) { - r.Get("/config.json", svc.Config) - r.Route("/branding/logo", func(r chi.Router) { - r.Use(middleware.ExtractAccountUUID( - account.Logger(options.Logger), - account.JWTSecret(options.Config.TokenManager.JWTSecret), - )) - r.Post("/", themeService.LogoUpload) - r.Delete("/", themeService.LogoReset) - }) - r.Route("/themes", func(r chi.Router) { - r.Get("/{id}/theme.json", themeService.Get) - r.Mount("/", svc.Static( - options.ThemeFS.IOFS(), - path.Join(svc.config.HTTP.Root, "/themes"), + if !options.Config.Web.StaticOnly { + r.Get("/config.json", svc.Config) + r.Route("/branding/logo", func(r chi.Router) { + r.Use(middleware.ExtractAccountUUID( + account.Logger(options.Logger), + account.JWTSecret(options.Config.TokenManager.JWTSecret), + )) + r.Post("/", themeService.LogoUpload) + r.Delete("/", themeService.LogoReset) + }) + r.Route("/themes", func(r chi.Router) { + r.Get("/{id}/theme.json", themeService.Get) + r.Mount("/", svc.Static( + options.ThemeFS.IOFS(), + path.Join(svc.config.HTTP.Root, "/themes"), + options.Config.HTTP.CacheTTL, + )) + }) + r.Mount(options.AppsHTTPEndpoint, svc.Static( + options.AppFS, + path.Join(svc.config.HTTP.Root, options.AppsHTTPEndpoint), options.Config.HTTP.CacheTTL, )) - }) - r.Mount(options.AppsHTTPEndpoint, svc.Static( - options.AppFS, - path.Join(svc.config.HTTP.Root, options.AppsHTTPEndpoint), - options.Config.HTTP.CacheTTL, - )) + } r.Mount("/", svc.Static( svc.coreFS, svc.config.HTTP.Root,