From 51dd1077675a25c9039be9fc0ff8d4c23335818b Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 28 Jul 2026 17:30:11 +0200 Subject: [PATCH 01/10] feat: add announcement banner --- .../pkg/config/defaults/defaultconfig.go | 4 + .../settings/pkg/store/defaults/defaults.go | 2 + .../pkg/store/defaults/permissions.go | 19 ++ services/web/pkg/announcement/announcement.go | 74 +++++++ .../announcement/announcement_suite_test.go | 13 ++ .../web/pkg/announcement/announcement_test.go | 193 ++++++++++++++++++ services/web/pkg/announcement/service.go | 162 +++++++++++++++ services/web/pkg/config/config.go | 16 ++ .../web/pkg/config/defaults/defaultconfig.go | 7 + services/web/pkg/config/options.go | 9 + services/web/pkg/server/http/server.go | 17 ++ services/web/pkg/service/v0/option.go | 27 ++- services/web/pkg/service/v0/service.go | 78 +++++-- services/web/pkg/service/v0/service_test.go | 47 +++++ 14 files changed, 644 insertions(+), 24 deletions(-) create mode 100644 services/web/pkg/announcement/announcement.go create mode 100644 services/web/pkg/announcement/announcement_suite_test.go create mode 100644 services/web/pkg/announcement/announcement_test.go create mode 100644 services/web/pkg/announcement/service.go create mode 100644 services/web/pkg/service/v0/service_test.go diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index e3f2e380c5..c9b40ffd8a 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -137,6 +137,10 @@ func DefaultPolicies() []config.Policy { Endpoint: "/branding/logo", Service: "eu.opencloud.web.web", }, + { + Endpoint: "/announcement", + Service: "eu.opencloud.web.web", + }, { Endpoint: "/konnect/", Service: "eu.opencloud.web.idp", diff --git a/services/settings/pkg/store/defaults/defaults.go b/services/settings/pkg/store/defaults/defaults.go index 628c07e581..6d4899dc6c 100644 --- a/services/settings/pkg/store/defaults/defaults.go +++ b/services/settings/pkg/store/defaults/defaults.go @@ -80,6 +80,7 @@ func ServiceAccountBundle() *settingsmsg.Bundle { }, Settings: []*settingsmsg.Setting{ AccountManagementPermission(All), + AnnouncementWritePermission(All), ChangeLogoPermission(All), CollaborationPublishNotificationPermission(All), CollaborationManageFontsPermission(All), @@ -117,6 +118,7 @@ func generateBundleAdminRole() *settingsmsg.Bundle { }, Settings: []*settingsmsg.Setting{ AccountManagementPermission(All), + AnnouncementWritePermission(All), AutoAcceptSharesPermission(Own), ChangeLogoPermission(All), CollaborationPublishNotificationPermission(All), diff --git a/services/settings/pkg/store/defaults/permissions.go b/services/settings/pkg/store/defaults/permissions.go index 4f80c1d124..5cd898005f 100644 --- a/services/settings/pkg/store/defaults/permissions.go +++ b/services/settings/pkg/store/defaults/permissions.go @@ -29,6 +29,25 @@ func AccountManagementPermission(c settingsmsg.Permission_Constraint) *settingsm } } +// AnnouncementWritePermission is the permission to manage the web announcement banner +func AnnouncementWritePermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { + return &settingsmsg.Setting{ + Id: "52b1994b-1bdb-4c8d-a887-1967dbe8cb11", + Name: "Announcement.Write", + DisplayName: "Manage announcement", + Description: "This permission permits to manage the announcement banner shown to all users.", + Resource: &settingsmsg.Resource{ + Type: settingsmsg.Resource_TYPE_SYSTEM, + }, + Value: &settingsmsg.Setting_PermissionValue{ + PermissionValue: &settingsmsg.Permission{ + Operation: settingsmsg.Permission_OPERATION_WRITE, + Constraint: c, + }, + }, + } +} + // AutoAcceptSharesPermission is the permission to enable share auto-accept func AutoAcceptSharesPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { return &settingsmsg.Setting{ diff --git a/services/web/pkg/announcement/announcement.go b/services/web/pkg/announcement/announcement.go new file mode 100644 index 0000000000..5fb1029b89 --- /dev/null +++ b/services/web/pkg/announcement/announcement.go @@ -0,0 +1,74 @@ +// Package announcement persists and serves the web announcement banner. +package announcement + +import ( + "encoding/json" + "errors" + + microstore "go-micro.dev/v4/store" +) + +// _storeKey is the single key under which the announcement is persisted. +const _storeKey = "announcement" + +// Announcement is a banner message shown above the top bar to all users. +type Announcement struct { + // Enabled controls whether the announcement is live (injected into config.json). + Enabled bool `json:"enabled"` + BannerText string `json:"bannerText"` + InfoText string `json:"infoText"` +} + +// Store persists a single announcement in a key-value store. +type Store struct { + store microstore.Store +} + +// NewStore returns a new announcement Store backed by the given key-value store. +func NewStore(s microstore.Store) *Store { + return &Store{store: s} +} + +// Get returns the currently stored announcement. An unset announcement is returned as the zero value. +func (s *Store) Get() (Announcement, error) { + var a Announcement + + records, err := s.store.Read(_storeKey) + if err != nil { + if errors.Is(err, microstore.ErrNotFound) { + return a, nil + } + return a, err + } + + if len(records) == 0 { + return a, nil + } + + if err := json.Unmarshal(records[0].Value, &a); err != nil { + return a, err + } + + return a, nil +} + +// Set persists the given announcement, overwriting any existing one. +func (s *Store) Set(a Announcement) error { + value, err := json.Marshal(a) + if err != nil { + return err + } + + return s.store.Write(µstore.Record{ + Key: _storeKey, + Value: value, + }) +} + +// Delete removes the stored announcement. Deleting a missing announcement is a no-op. +func (s *Store) Delete() error { + if err := s.store.Delete(_storeKey); err != nil && !errors.Is(err, microstore.ErrNotFound) { + return err + } + return nil +} diff --git a/services/web/pkg/announcement/announcement_suite_test.go b/services/web/pkg/announcement/announcement_suite_test.go new file mode 100644 index 0000000000..0931590df3 --- /dev/null +++ b/services/web/pkg/announcement/announcement_suite_test.go @@ -0,0 +1,13 @@ +package announcement_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAnnouncement(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Announcement Suite") +} diff --git a/services/web/pkg/announcement/announcement_test.go b/services/web/pkg/announcement/announcement_test.go new file mode 100644 index 0000000000..cff3ce46bc --- /dev/null +++ b/services/web/pkg/announcement/announcement_test.go @@ -0,0 +1,193 @@ +package announcement_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + cs3permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1" + cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/opencloud-eu/reva/v2/pkg/store" + cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" + "github.com/stretchr/testify/mock" + "google.golang.org/grpc" + + "github.com/opencloud-eu/opencloud/services/web/pkg/announcement" +) + +func newStore() *announcement.Store { + return announcement.NewStore(store.Create(store.Store("memory"))) +} + +func newGatewaySelector(allowed bool) pool.Selectable[gateway.GatewayAPIClient] { + code := cs3rpc.Code_CODE_OK + name := "announcement-test-allowed" + if !allowed { + code = cs3rpc.Code_CODE_PERMISSION_DENIED + name = "announcement-test-denied" + } + + client := &cs3mocks.GatewayAPIClient{} + client.On("CheckPermission", mock.Anything, mock.Anything).Return( + &cs3permissions.CheckPermissionResponse{Status: &cs3rpc.Status{Code: code}}, nil) + + // pool.GetSelector caches by name, so allow/deny must use distinct names + return pool.GetSelector[gateway.GatewayAPIClient]( + name, + "eu.opencloud.api.gateway", + func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient { return client }, + ) +} + +func withUser(r *http.Request) *http.Request { + return r.WithContext(revactx.ContextSetUser(r.Context(), &userpb.User{ + Id: &userpb.UserId{OpaqueId: "user"}, + })) +} + +func newService(store *announcement.Store, allowed bool) announcement.Service { + svc, err := announcement.NewService(announcement.ServiceOptions{}. + WithStore(store). + WithGatewaySelector(newGatewaySelector(allowed))) + Expect(err).ToNot(HaveOccurred()) + return svc +} + +var _ = Describe("Store", func() { + It("gets, sets and deletes the announcement", func() { + s := newStore() + + got, err := s.Get() + Expect(err).ToNot(HaveOccurred()) + Expect(got.BannerText).To(BeEmpty()) + + Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed()) + got, err = s.Get() + Expect(err).ToNot(HaveOccurred()) + Expect(got.Enabled).To(BeTrue()) + Expect(got.BannerText).To(Equal("hello")) + Expect(got.InfoText).To(Equal("world")) + + Expect(s.Delete()).To(Succeed()) + got, err = s.Get() + Expect(err).ToNot(HaveOccurred()) + Expect(got.BannerText).To(BeEmpty()) + }) + + It("treats deleting a missing announcement as a no-op", func() { + Expect(newStore().Delete()).To(Succeed()) + }) +}) + +var _ = Describe("Service", func() { + Describe("NewService", func() { + It("fails when options are missing", func() { + _, err := announcement.NewService(announcement.ServiceOptions{}) + Expect(err).To(HaveOccurred()) + }) + + It("succeeds when options are valid", func() { + _, err := announcement.NewService(announcement.ServiceOptions{}. + WithStore(newStore()). + WithGatewaySelector(newGatewaySelector(true))) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Get", func() { + It("returns the full stored announcement when permitted", func() { + s := newStore() + Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed()) + req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil)) + resp := httptest.NewRecorder() + + newService(s, true).Get(resp, req) + + Expect(resp.Code).To(Equal(http.StatusOK)) + var got announcement.Announcement + Expect(json.Unmarshal(resp.Body.Bytes(), &got)).To(Succeed()) + Expect(got.Enabled).To(BeTrue()) + Expect(got.BannerText).To(Equal("hello")) + Expect(got.InfoText).To(Equal("world")) + }) + + It("is forbidden without permission", func() { + req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil)) + resp := httptest.NewRecorder() + + newService(newStore(), false).Get(resp, req) + + Expect(resp.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("Set", func() { + It("persists the message when permitted", func() { + s := newStore() + req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`))) + resp := httptest.NewRecorder() + + newService(s, true).Set(resp, req) + + Expect(resp.Code).To(Equal(http.StatusNoContent)) + got, _ := s.Get() + Expect(got.BannerText).To(Equal("hello")) + }) + + It("is forbidden without permission", func() { + s := newStore() + req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`))) + resp := httptest.NewRecorder() + + newService(s, false).Set(resp, req) + + Expect(resp.Code).To(Equal(http.StatusForbidden)) + got, _ := s.Get() + Expect(got.BannerText).To(BeEmpty()) + }) + + It("rejects an invalid body", func() { + req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`not json`))) + resp := httptest.NewRecorder() + + newService(newStore(), true).Set(resp, req) + + Expect(resp.Code).To(Equal(http.StatusBadRequest)) + }) + + It("rejects an oversized body", func() { + body := `{"bannerText":"` + strings.Repeat("a", 300000) + `"}` + req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(body))) + resp := httptest.NewRecorder() + + s := newStore() + newService(s, true).Set(resp, req) + + Expect(resp.Code).To(Equal(http.StatusBadRequest)) + got, _ := s.Get() + Expect(got.BannerText).To(BeEmpty()) + }) + }) + + Describe("Set with an empty banner text", func() { + It("removes the stored announcement", func() { + s := newStore() + Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello"})).To(Succeed()) + + req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"enabled":false,"bannerText":"","infoText":""}`))) + resp := httptest.NewRecorder() + newService(s, true).Set(resp, req) + + Expect(resp.Code).To(Equal(http.StatusNoContent)) + got, _ := s.Get() + Expect(got.BannerText).To(BeEmpty()) + }) + }) +}) diff --git a/services/web/pkg/announcement/service.go b/services/web/pkg/announcement/service.go new file mode 100644 index 0000000000..b49200590f --- /dev/null +++ b/services/web/pkg/announcement/service.go @@ -0,0 +1,162 @@ +package announcement + +import ( + "encoding/json" + "net/http" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + "github.com/pkg/errors" + + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" +) + +// _writePermission is the settings permission required to manage the announcement. +const _writePermission = "Announcement.Write" + +// _maxBodySize caps the announcement request body. The info text is Markdown and ends up in +// the public config.json that every client loads on bootstrap, so it must stay small. +const _maxBodySize = 50 << 10 // 50 KiB + +// ServiceOptions defines the options to configure the Service. +type ServiceOptions struct { + store *Store + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] +} + +// WithStore sets the announcement store. +func (o ServiceOptions) WithStore(s *Store) ServiceOptions { + o.store = s + return o +} + +// WithGatewaySelector sets the gateway selector. +func (o ServiceOptions) WithGatewaySelector(gws pool.Selectable[gateway.GatewayAPIClient]) ServiceOptions { + o.gatewaySelector = gws + return o +} + +// validate validates the input parameters. +func (o ServiceOptions) validate() error { + if o.store == nil { + return errors.New("store is required") + } + + if o.gatewaySelector == nil { + return errors.New("gatewaySelector is required") + } + + return nil +} + +// Service exposes the http handlers to manage the announcement. +type Service struct { + store *Store + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] +} + +// NewService initializes a new Service. +func NewService(options ServiceOptions) (Service, error) { + if err := options.validate(); err != nil { + return Service{}, err + } + + return Service{ + store: options.store, + gatewaySelector: options.gatewaySelector, + }, nil +} + +// Get returns the full stored announcement (including disabled ones) for management. +func (s Service) Get(w http.ResponseWriter, r *http.Request) { + gatewayClient, err := s.gatewaySelector.Next() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + user, ok := revactx.ContextGetUser(r.Context()) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ + Permission: _writePermission, + SubjectRef: &permissionsapi.SubjectReference{ + Spec: &permissionsapi.SubjectReference_UserId{ + UserId: user.GetId(), + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK { + w.WriteHeader(http.StatusForbidden) + return + } + + a, err := s.store.Get() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(a); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } +} + +// Set persists the announcement provided in the request body. +func (s Service) Set(w http.ResponseWriter, r *http.Request) { + gatewayClient, err := s.gatewaySelector.Next() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + user, ok := revactx.ContextGetUser(r.Context()) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ + Permission: _writePermission, + SubjectRef: &permissionsapi.SubjectReference{ + Spec: &permissionsapi.SubjectReference_UserId{ + UserId: user.GetId(), + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK { + w.WriteHeader(http.StatusForbidden) + return + } + + var body Announcement + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, _maxBodySize)).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + // an announcement without a banner text is nothing to show, so remove it entirely + if body.BannerText == "" { + if err := s.store.Delete(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + } else if err := s.store.Set(body); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/services/web/pkg/config/config.go b/services/web/pkg/config/config.go index 91630f9baf..d40f438b5e 100644 --- a/services/web/pkg/config/config.go +++ b/services/web/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "context" + "time" "github.com/opencloud-eu/opencloud/pkg/shared" ) @@ -25,9 +26,24 @@ type Config struct { TokenManager *TokenManager `yaml:"token_manager"` GatewayAddress string `yaml:"gateway_addr" env:"WEB_GATEWAY_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"` + Store Store `yaml:"store"` Context context.Context `yaml:"-"` } +// Store configures the persistent store used to keep runtime managed web settings, e.g. the announcement banner. +type Store struct { + Store string `yaml:"store" env:"OC_PERSISTENT_STORE;WEB_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"%%NEXT%%"` + Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"%%NEXT%%"` + Table string `yaml:"table" env:"WEB_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"%%NEXT%%"` + TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;WEB_STORE_TTL" desc:"Time to live for entries in the store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"` + AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"` + EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"` + TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"` + TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"` +} + // Asset defines the available asset configuration. type Asset struct { CorePath string `yaml:"core_path" env:"WEB_ASSET_CORE_PATH" desc:"Serve OpenCloud Web assets from a path on the filesystem instead of the builtin assets. If not defined, the root directory derives from $OC_BASE_DATA_PATH/web/assets/core" introductionVersion:"1.0.0"` diff --git a/services/web/pkg/config/defaults/defaultconfig.go b/services/web/pkg/config/defaults/defaultconfig.go index 4c18aae0bd..451e43ce6e 100644 --- a/services/web/pkg/config/defaults/defaultconfig.go +++ b/services/web/pkg/config/defaults/defaultconfig.go @@ -85,6 +85,13 @@ func DefaultConfig() *config.Config { ThemesPath: filepath.Join(defaults.BaseDataPath(), "web/assets/themes"), }, GatewayAddress: "eu.opencloud.api.gateway", + Store: config.Store{ + Store: "nats-js-kv", + Nodes: []string{"127.0.0.1:9233"}, + Database: "web", + Table: "", + TTL: 0, + }, Web: config.Web{ ThemeServer: "https://localhost:9200", ThemePath: "/themes/opencloud/theme.json", diff --git a/services/web/pkg/config/options.go b/services/web/pkg/config/options.go index 736b232b20..cb47976928 100644 --- a/services/web/pkg/config/options.go +++ b/services/web/pkg/config/options.go @@ -2,6 +2,7 @@ package config // Options are the option for the web type Options struct { + Announcement *Announcement `json:"announcement,omitempty" yaml:"announcement"` AccountEditLink *AccountEditLink `json:"accountEditLink,omitempty" yaml:"accountEditLink"` DisableFeedbackLink bool `json:"disableFeedbackLink,omitempty" yaml:"disableFeedbackLink" env:"WEB_OPTION_DISABLE_FEEDBACK_LINK" desc:"Set this option to 'true' to disable the feedback link in the top bar. Keeping it enabled by setting the value to 'false' or with the absence of the option, allows OpenCloud to get feedback from your user base through a dedicated survey website." introductionVersion:"1.0.0"` DisableSponsorLink bool `json:"disableSponsorLink,omitempty" yaml:"disableSponsorLink" env:"WEB_OPTION_DISABLE_SPONSOR_LINK" desc:"Set this option to 'true' to disable the sponsor link in the left sidebar. Keeping it enabled by setting the value to 'false' or by leaving the option unset allows OpenCloud to get support from the community through a dedicated sponsorship program on GitHub." introductionVersion:"7.3.0"` @@ -23,6 +24,14 @@ type Options struct { OxAppSuite *OxAppSuite `json:"oxAppSuite,omitempty" yaml:"oxAppSuite"` } +// Announcement is a banner message shown above the top bar to all users. +type Announcement struct { + // BannerText is the short line shown in the banner. + BannerText string `json:"bannerText,omitempty" yaml:"bannerText"` + // InfoText is the (Markdown) detail shown in a dialog when the banner is clicked. + InfoText string `json:"infoText,omitempty" yaml:"infoText"` +} + // AccountEditLink are the AccountEditLink options type AccountEditLink struct { Href string `json:"href,omitempty" yaml:"href" env:"WEB_OPTION_ACCOUNT_EDIT_LINK_HREF" desc:"Set a different target URL for the edit link. Make sure to prepend it with 'http(s)://'." introductionVersion:"1.0.0"` diff --git a/services/web/pkg/server/http/server.go b/services/web/pkg/server/http/server.go index 25f210b13b..eb393f31ac 100644 --- a/services/web/pkg/server/http/server.go +++ b/services/web/pkg/server/http/server.go @@ -6,7 +6,9 @@ import ( chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/opencloud-eu/reva/v2/pkg/store" "go-micro.dev/v4" + microstore "go-micro.dev/v4/store" "github.com/opencloud-eu/opencloud/pkg/cors" "github.com/opencloud-eu/opencloud/pkg/middleware" @@ -15,6 +17,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/version" "github.com/opencloud-eu/opencloud/pkg/x/io/fsx" "github.com/opencloud-eu/opencloud/services/web" + "github.com/opencloud-eu/opencloud/services/web/pkg/announcement" "github.com/opencloud-eu/opencloud/services/web/pkg/apps" svc "github.com/opencloud-eu/opencloud/services/web/pkg/service/v0" ) @@ -76,11 +79,25 @@ func Server(opts ...Option) (http.Service, error) { fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"), ) + // persistent store for runtime managed web settings, e.g. the announcement banner + announcementStore := announcement.NewStore(store.Create( + store.Store(options.Config.Store.Store), + store.TTL(options.Config.Store.TTL), + microstore.Nodes(options.Config.Store.Nodes...), + microstore.Database(options.Config.Store.Database), + microstore.Table(options.Config.Store.Table), + store.Authentication(options.Config.Store.AuthUsername, options.Config.Store.AuthPassword), + store.TLSEnabled(options.Config.Store.EnableTLS), + store.TLSInsecure(options.Config.Store.TLSInsecure), + store.TLSRootCA(options.Config.Store.TLSRootCACertificate), + )) + handle, err := svc.NewService( svc.Logger(options.Logger), svc.CoreFS(coreFS.IOFS()), svc.AppFS(appsFS.IOFS()), svc.ThemeFS(themeFS), + svc.AnnouncementStore(announcementStore), svc.AppsHTTPEndpoint(_customAppsEndpoint), svc.Config(options.Config), svc.GatewaySelector(gatewaySelector), diff --git a/services/web/pkg/service/v0/option.go b/services/web/pkg/service/v0/option.go index 033c139bf7..dcacf6048e 100644 --- a/services/web/pkg/service/v0/option.go +++ b/services/web/pkg/service/v0/option.go @@ -10,6 +10,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/x/io/fsx" + "github.com/opencloud-eu/opencloud/services/web/pkg/announcement" "github.com/opencloud-eu/opencloud/services/web/pkg/config" ) @@ -18,15 +19,16 @@ type Option func(o *Options) // Options define the available options for this package. type Options struct { - Logger log.Logger - Config *config.Config - Middleware []func(http.Handler) http.Handler - GatewaySelector pool.Selectable[gateway.GatewayAPIClient] - TraceProvider trace.TracerProvider - AppsHTTPEndpoint string - CoreFS fs.FS - AppFS fs.FS - ThemeFS *fsx.FallbackFS + Logger log.Logger + Config *config.Config + Middleware []func(http.Handler) http.Handler + GatewaySelector pool.Selectable[gateway.GatewayAPIClient] + TraceProvider trace.TracerProvider + AppsHTTPEndpoint string + CoreFS fs.FS + AppFS fs.FS + ThemeFS *fsx.FallbackFS + AnnouncementStore *announcement.Store } // newOptions initializes the available default options. @@ -89,6 +91,13 @@ func ThemeFS(val *fsx.FallbackFS) Option { } } +// AnnouncementStore provides a function to set the announcement store option. +func AnnouncementStore(val *announcement.Store) Option { + return func(o *Options) { + o.AnnouncementStore = val + } +} + // AppsHTTPEndpoint provides a function to set the appsHTTPEndpoint option. func AppsHTTPEndpoint(val string) Option { return func(o *Options) { diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index e21e9bc637..4f2cbd984f 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -19,6 +19,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/middleware" "github.com/opencloud-eu/opencloud/pkg/tracing" + "github.com/opencloud-eu/opencloud/services/web/pkg/announcement" "github.com/opencloud-eu/opencloud/services/web/pkg/assets" "github.com/opencloud-eu/opencloud/services/web/pkg/config" "github.com/opencloud-eu/opencloud/services/web/pkg/theme" @@ -50,10 +51,11 @@ func NewService(opts ...Option) (Service, error) { ) svc := Web{ - logger: options.Logger, - config: options.Config, - mux: m, - gatewaySelector: options.GatewaySelector, + logger: options.Logger, + config: options.Config, + mux: m, + gatewaySelector: options.GatewaySelector, + announcementStore: options.AnnouncementStore, } themeService, err := theme.NewService( @@ -65,6 +67,15 @@ func NewService(opts ...Option) (Service, error) { return svc, err } + announcementService, err := announcement.NewService( + announcement.ServiceOptions{}. + WithStore(options.AnnouncementStore). + 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) { @@ -75,6 +86,14 @@ func NewService(opts ...Option) (Service, error) { r.Post("/", themeService.LogoUpload) r.Delete("/", themeService.LogoReset) }) + r.Route("/announcement", func(r chi.Router) { + r.Use(middleware.ExtractAccountUUID( + account.Logger(options.Logger), + account.JWTSecret(options.Config.TokenManager.JWTSecret), + )) + r.Get("/", announcementService.Get) + r.Put("/", announcementService.Set) + }) r.Route("/themes", func(r chi.Router) { r.Get("/{id}/theme.json", themeService.Get) r.Mount("/", svc.Static( @@ -104,10 +123,11 @@ func NewService(opts ...Option) (Service, error) { // Web defines the handlers for the web service. type Web struct { - logger log.Logger - config *config.Config - mux *chi.Mux - gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + logger log.Logger + config *config.Config + mux *chi.Mux + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + announcementStore *announcement.Store } // ServeHTTP implements the Service interface. @@ -116,25 +136,53 @@ func (p Web) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (p Web) getPayload() (payload []byte, err error) { - // render dynamically using config + // render dynamically using a copy of the config, so per-request values (e.g. the + // announcement) are not written into the shared config concurrently. + webConfig := p.config.Web.Config // build theme url if themeServer, err := url.Parse(p.config.Web.ThemeServer); err == nil { - p.config.Web.Config.Theme = themeServer.String() + p.config.Web.ThemePath + webConfig.Theme = themeServer.String() + p.config.Web.ThemePath } else { - p.config.Web.Config.Theme = p.config.Web.ThemePath + webConfig.Theme = p.config.Web.ThemePath } // make apps render as empty array if it is empty // TODO remove once https://github.com/golang/go/issues/27589 is fixed - if len(p.config.Web.Config.Apps) == 0 { - p.config.Web.Config.Apps = make([]string, 0) + if len(webConfig.Apps) == 0 { + webConfig.Apps = make([]string, 0) } // ensure that the server url has a trailing slash - p.config.Web.Config.Server = strings.TrimRight(p.config.Web.Config.Server, "/") + "/" + webConfig.Server = strings.TrimRight(webConfig.Server, "/") + "/" - return json.Marshal(p.config.Web.Config) + // inject the runtime managed announcement banner; keep a statically configured one + // (web.config.options.announcement) when nothing is stored + if a := p.currentAnnouncement(); a != nil { + webConfig.Options.Announcement = a + } + + return json.Marshal(webConfig) +} + +// currentAnnouncement returns the stored announcement for config.json, or nil if unset or unavailable. +func (p Web) currentAnnouncement() *config.Announcement { + if p.announcementStore == nil { + return nil + } + + a, err := p.announcementStore.Get() + if err != nil { + p.logger.Error().Err(err).Msg("could not read announcement from store") + return nil + } + + // only live (enabled) announcements with a banner line are exposed in the public config.json + if !a.Enabled || a.BannerText == "" { + return nil + } + + return &config.Announcement{BannerText: a.BannerText, InfoText: a.InfoText} } // Config implements the Service interface. diff --git a/services/web/pkg/service/v0/service_test.go b/services/web/pkg/service/v0/service_test.go new file mode 100644 index 0000000000..612bf59425 --- /dev/null +++ b/services/web/pkg/service/v0/service_test.go @@ -0,0 +1,47 @@ +package svc + +import ( + "testing" + + "github.com/opencloud-eu/reva/v2/pkg/store" + "github.com/stretchr/testify/require" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/web/pkg/announcement" + "github.com/opencloud-eu/opencloud/services/web/pkg/config" +) + +func TestCurrentAnnouncement(t *testing.T) { + newWeb := func(a *announcement.Store) Web { + return Web{logger: log.NopLogger(), announcementStore: a} + } + newStore := func() *announcement.Store { + return announcement.NewStore(store.Create(store.Store("memory"))) + } + + t.Run("nil when there is no store", func(t *testing.T) { + require.Nil(t, newWeb(nil).currentAnnouncement()) + }) + + t.Run("nil when the store is empty", func(t *testing.T) { + require.Nil(t, newWeb(newStore()).currentAnnouncement()) + }) + + t.Run("nil when disabled", func(t *testing.T) { + s := newStore() + require.NoError(t, s.Set(announcement.Announcement{Enabled: false, BannerText: "hi", InfoText: "info"})) + require.Nil(t, newWeb(s).currentAnnouncement()) + }) + + t.Run("nil when enabled but the banner text is empty", func(t *testing.T) { + s := newStore() + require.NoError(t, s.Set(announcement.Announcement{Enabled: true, InfoText: "info"})) + require.Nil(t, newWeb(s).currentAnnouncement()) + }) + + t.Run("returns banner and info text when enabled with a banner text", func(t *testing.T) { + s := newStore() + require.NoError(t, s.Set(announcement.Announcement{Enabled: true, BannerText: "hi", InfoText: "info"})) + require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, newWeb(s).currentAnnouncement()) + }) +} From 046f46ce8d34f8f3845be590398d43c5791d43df Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 08:18:56 +0200 Subject: [PATCH 02/10] fix: make the runtime store authoritative for the announcement Always reflect the stored announcement in config.json: expose it while live and clear it otherwise. A disabled announcement now clears the config output instead of falling back to a statically configured one, which removes the precedence ambiguity raised in review. --- services/web/pkg/service/v0/service.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index 4f2cbd984f..190b601c73 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -156,11 +156,9 @@ func (p Web) getPayload() (payload []byte, err error) { // ensure that the server url has a trailing slash webConfig.Server = strings.TrimRight(webConfig.Server, "/") + "/" - // inject the runtime managed announcement banner; keep a statically configured one - // (web.config.options.announcement) when nothing is stored - if a := p.currentAnnouncement(); a != nil { - webConfig.Options.Announcement = a - } + // the runtime store is authoritative for the announcement banner: expose it when live, + // clear it otherwise + webConfig.Options.Announcement = p.currentAnnouncement() return json.Marshal(webConfig) } From 809d32c4d845cb44037ad59209c54738ff70021e Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 08:29:58 +0200 Subject: [PATCH 03/10] fix: keep a static announcement as fallback when the store is empty Make the store authoritative only once it manages an announcement: an enabled record is exposed, a disabled record clears any static config, and an empty store falls back to a statically configured web.config.options.announcement. This matches the review request and stops a static announcement from being silently dropped. --- services/web/pkg/service/v0/service.go | 34 ++++++++++++++------- services/web/pkg/service/v0/service_test.go | 32 ++++++++++++------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index 190b601c73..4587ce0822 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -156,31 +156,43 @@ func (p Web) getPayload() (payload []byte, err error) { // ensure that the server url has a trailing slash webConfig.Server = strings.TrimRight(webConfig.Server, "/") + "/" - // the runtime store is authoritative for the announcement banner: expose it when live, - // clear it otherwise - webConfig.Options.Announcement = p.currentAnnouncement() + // the runtime store is authoritative once it manages an announcement (enabled or explicitly + // disabled); only when it holds nothing do we keep a statically configured one + // (web.config.options.announcement) + if a, managed := p.managedAnnouncement(); managed { + webConfig.Options.Announcement = a + } return json.Marshal(webConfig) } -// currentAnnouncement returns the stored announcement for config.json, or nil if unset or unavailable. -func (p Web) currentAnnouncement() *config.Announcement { +// managedAnnouncement returns the announcement the runtime store manages for config.json. The +// bool reports whether the store manages one at all: when true, the returned value (nil for an +// explicitly disabled announcement) overrides any static config; when false, the store holds +// nothing and a statically configured announcement is kept. +func (p Web) managedAnnouncement() (*config.Announcement, bool) { if p.announcementStore == nil { - return nil + return nil, false } a, err := p.announcementStore.Get() if err != nil { p.logger.Error().Err(err).Msg("could not read announcement from store") - return nil + return nil, false } - // only live (enabled) announcements with a banner line are exposed in the public config.json - if !a.Enabled || a.BannerText == "" { - return nil + // an announcement without a banner line is nothing to manage (empty/removed store) + if a.BannerText == "" { + return nil, false } - return &config.Announcement{BannerText: a.BannerText, InfoText: a.InfoText} + // managed but disabled: hide it, overriding any static config + if !a.Enabled { + return nil, true + } + + // only live (enabled) announcements are exposed in the public config.json + return &config.Announcement{BannerText: a.BannerText, InfoText: a.InfoText}, true } // Config implements the Service interface. diff --git a/services/web/pkg/service/v0/service_test.go b/services/web/pkg/service/v0/service_test.go index 612bf59425..9a983f0bca 100644 --- a/services/web/pkg/service/v0/service_test.go +++ b/services/web/pkg/service/v0/service_test.go @@ -11,7 +11,7 @@ import ( "github.com/opencloud-eu/opencloud/services/web/pkg/config" ) -func TestCurrentAnnouncement(t *testing.T) { +func TestManagedAnnouncement(t *testing.T) { newWeb := func(a *announcement.Store) Web { return Web{logger: log.NopLogger(), announcementStore: a} } @@ -19,29 +19,39 @@ func TestCurrentAnnouncement(t *testing.T) { return announcement.NewStore(store.Create(store.Store("memory"))) } - t.Run("nil when there is no store", func(t *testing.T) { - require.Nil(t, newWeb(nil).currentAnnouncement()) + t.Run("not managed when there is no store, so a static config is kept", func(t *testing.T) { + a, managed := newWeb(nil).managedAnnouncement() + require.Nil(t, a) + require.False(t, managed) }) - t.Run("nil when the store is empty", func(t *testing.T) { - require.Nil(t, newWeb(newStore()).currentAnnouncement()) + t.Run("not managed when the store is empty, so a static config is kept", func(t *testing.T) { + a, managed := newWeb(newStore()).managedAnnouncement() + require.Nil(t, a) + require.False(t, managed) }) - t.Run("nil when disabled", func(t *testing.T) { + t.Run("managed but nil when disabled, so a static config is cleared", func(t *testing.T) { s := newStore() require.NoError(t, s.Set(announcement.Announcement{Enabled: false, BannerText: "hi", InfoText: "info"})) - require.Nil(t, newWeb(s).currentAnnouncement()) + a, managed := newWeb(s).managedAnnouncement() + require.Nil(t, a) + require.True(t, managed) }) - t.Run("nil when enabled but the banner text is empty", func(t *testing.T) { + t.Run("not managed when enabled but the banner text is empty", func(t *testing.T) { s := newStore() require.NoError(t, s.Set(announcement.Announcement{Enabled: true, InfoText: "info"})) - require.Nil(t, newWeb(s).currentAnnouncement()) + a, managed := newWeb(s).managedAnnouncement() + require.Nil(t, a) + require.False(t, managed) }) - t.Run("returns banner and info text when enabled with a banner text", func(t *testing.T) { + t.Run("managed with banner and info text when enabled with a banner text", func(t *testing.T) { s := newStore() require.NoError(t, s.Set(announcement.Announcement{Enabled: true, BannerText: "hi", InfoText: "info"})) - require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, newWeb(s).currentAnnouncement()) + a, managed := newWeb(s).managedAnnouncement() + require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, a) + require.True(t, managed) }) } From e29d961be3a52912e9c31bc9d410632669d22bcd Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 08:40:29 +0200 Subject: [PATCH 04/10] refactor: rename Announcement.Write to Announcement.ReadWrite The permission gates reading the full announcement state (including disabled ones) as well as writing it, so name it ReadWrite to match the other management permissions (Accounts.ReadWrite, Settings.ReadWrite, ...) and use the READWRITE operation. --- services/settings/pkg/store/defaults/defaults.go | 4 ++-- services/settings/pkg/store/defaults/permissions.go | 10 +++++----- services/web/pkg/announcement/service.go | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/services/settings/pkg/store/defaults/defaults.go b/services/settings/pkg/store/defaults/defaults.go index 6d4899dc6c..c8d144eefd 100644 --- a/services/settings/pkg/store/defaults/defaults.go +++ b/services/settings/pkg/store/defaults/defaults.go @@ -80,7 +80,7 @@ func ServiceAccountBundle() *settingsmsg.Bundle { }, Settings: []*settingsmsg.Setting{ AccountManagementPermission(All), - AnnouncementWritePermission(All), + AnnouncementReadWritePermission(All), ChangeLogoPermission(All), CollaborationPublishNotificationPermission(All), CollaborationManageFontsPermission(All), @@ -118,7 +118,7 @@ func generateBundleAdminRole() *settingsmsg.Bundle { }, Settings: []*settingsmsg.Setting{ AccountManagementPermission(All), - AnnouncementWritePermission(All), + AnnouncementReadWritePermission(All), AutoAcceptSharesPermission(Own), ChangeLogoPermission(All), CollaborationPublishNotificationPermission(All), diff --git a/services/settings/pkg/store/defaults/permissions.go b/services/settings/pkg/store/defaults/permissions.go index 5cd898005f..7d6ee14de6 100644 --- a/services/settings/pkg/store/defaults/permissions.go +++ b/services/settings/pkg/store/defaults/permissions.go @@ -29,19 +29,19 @@ func AccountManagementPermission(c settingsmsg.Permission_Constraint) *settingsm } } -// AnnouncementWritePermission is the permission to manage the web announcement banner -func AnnouncementWritePermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { +// AnnouncementReadWritePermission is the permission to read and manage the web announcement banner +func AnnouncementReadWritePermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { return &settingsmsg.Setting{ Id: "52b1994b-1bdb-4c8d-a887-1967dbe8cb11", - Name: "Announcement.Write", + Name: "Announcement.ReadWrite", DisplayName: "Manage announcement", - Description: "This permission permits to manage the announcement banner shown to all users.", + Description: "This permission permits to read and manage the announcement banner shown to all users.", Resource: &settingsmsg.Resource{ Type: settingsmsg.Resource_TYPE_SYSTEM, }, Value: &settingsmsg.Setting_PermissionValue{ PermissionValue: &settingsmsg.Permission{ - Operation: settingsmsg.Permission_OPERATION_WRITE, + Operation: settingsmsg.Permission_OPERATION_READWRITE, Constraint: c, }, }, diff --git a/services/web/pkg/announcement/service.go b/services/web/pkg/announcement/service.go index b49200590f..39ef7c9443 100644 --- a/services/web/pkg/announcement/service.go +++ b/services/web/pkg/announcement/service.go @@ -13,8 +13,8 @@ import ( "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" ) -// _writePermission is the settings permission required to manage the announcement. -const _writePermission = "Announcement.Write" +// _permission is the settings permission required to read and manage the announcement. +const _permission = "Announcement.ReadWrite" // _maxBodySize caps the announcement request body. The info text is Markdown and ends up in // the public config.json that every client loads on bootstrap, so it must stay small. @@ -83,7 +83,7 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) { return } rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ - Permission: _writePermission, + Permission: _permission, SubjectRef: &permissionsapi.SubjectReference{ Spec: &permissionsapi.SubjectReference_UserId{ UserId: user.GetId(), @@ -125,7 +125,7 @@ func (s Service) Set(w http.ResponseWriter, r *http.Request) { return } rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ - Permission: _writePermission, + Permission: _permission, SubjectRef: &permissionsapi.SubjectReference{ Spec: &permissionsapi.SubjectReference_UserId{ UserId: user.GetId(), From 9d472290f2ba803ebdddc6fbe1bea895a9538f90 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 08:46:46 +0200 Subject: [PATCH 05/10] fix: return 413 when the announcement body exceeds the size cap Distinguish the http.MaxBytesReader limit from a malformed body: an oversized payload now returns 413 Request Entity Too Large instead of a generic 400. --- services/web/pkg/announcement/announcement_test.go | 2 +- services/web/pkg/announcement/service.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/services/web/pkg/announcement/announcement_test.go b/services/web/pkg/announcement/announcement_test.go index cff3ce46bc..986bc9d40b 100644 --- a/services/web/pkg/announcement/announcement_test.go +++ b/services/web/pkg/announcement/announcement_test.go @@ -170,7 +170,7 @@ var _ = Describe("Service", func() { s := newStore() newService(s, true).Set(resp, req) - Expect(resp.Code).To(Equal(http.StatusBadRequest)) + Expect(resp.Code).To(Equal(http.StatusRequestEntityTooLarge)) got, _ := s.Get() Expect(got.BannerText).To(BeEmpty()) }) diff --git a/services/web/pkg/announcement/service.go b/services/web/pkg/announcement/service.go index 39ef7c9443..2cd541b096 100644 --- a/services/web/pkg/announcement/service.go +++ b/services/web/pkg/announcement/service.go @@ -143,6 +143,11 @@ func (s Service) Set(w http.ResponseWriter, r *http.Request) { var body Announcement if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, _maxBodySize)).Decode(&body); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } w.WriteHeader(http.StatusBadRequest) return } From 7d59a83c18e137b18c30e4d6d18c9f5d35186191 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 10:08:47 +0200 Subject: [PATCH 06/10] feat: log announcement handler errors with the request id Add a logger to the announcement service and log server-side errors before returning a 5xx, tagged with the request id so they correlate with the request log line from the logging middleware. --- services/web/pkg/announcement/service.go | 25 ++++++++++++++++++++++++ services/web/pkg/service/v0/service.go | 1 + 2 files changed, 26 insertions(+) diff --git a/services/web/pkg/announcement/service.go b/services/web/pkg/announcement/service.go index 2cd541b096..f601073da5 100644 --- a/services/web/pkg/announcement/service.go +++ b/services/web/pkg/announcement/service.go @@ -11,6 +11,8 @@ import ( revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + + "github.com/opencloud-eu/opencloud/pkg/log" ) // _permission is the settings permission required to read and manage the announcement. @@ -22,10 +24,17 @@ const _maxBodySize = 50 << 10 // 50 KiB // ServiceOptions defines the options to configure the Service. type ServiceOptions struct { + logger log.Logger store *Store gatewaySelector pool.Selectable[gateway.GatewayAPIClient] } +// WithLogger sets the logger. +func (o ServiceOptions) WithLogger(l log.Logger) ServiceOptions { + o.logger = l + return o +} + // WithStore sets the announcement store. func (o ServiceOptions) WithStore(s *Store) ServiceOptions { o.store = s @@ -53,6 +62,7 @@ func (o ServiceOptions) validate() error { // Service exposes the http handlers to manage the announcement. type Service struct { + logger log.Logger store *Store gatewaySelector pool.Selectable[gateway.GatewayAPIClient] } @@ -64,15 +74,23 @@ func NewService(options ServiceOptions) (Service, error) { } return Service{ + logger: options.logger, store: options.store, gatewaySelector: options.gatewaySelector, }, nil } +// logError logs a server-side error together with the request id, so it can be correlated with +// the request log line emitted by the logging middleware. +func (s Service) logError(r *http.Request, err error, msg string) { + s.logger.Error().Err(err).Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Msg(msg) +} + // Get returns the full stored announcement (including disabled ones) for management. func (s Service) Get(w http.ResponseWriter, r *http.Request) { gatewayClient, err := s.gatewaySelector.Next() if err != nil { + s.logError(r, err, "could not select next gateway client") w.WriteHeader(http.StatusInternalServerError) return } @@ -91,6 +109,7 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) { }, }) if err != nil { + s.logError(r, err, "could not check permission") w.WriteHeader(http.StatusInternalServerError) return } @@ -101,12 +120,14 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) { a, err := s.store.Get() if err != nil { + s.logError(r, err, "could not read announcement from store") w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(a); err != nil { + s.logError(r, err, "could not encode announcement") w.WriteHeader(http.StatusInternalServerError) } } @@ -115,6 +136,7 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) { func (s Service) Set(w http.ResponseWriter, r *http.Request) { gatewayClient, err := s.gatewaySelector.Next() if err != nil { + s.logError(r, err, "could not select next gateway client") w.WriteHeader(http.StatusInternalServerError) return } @@ -133,6 +155,7 @@ func (s Service) Set(w http.ResponseWriter, r *http.Request) { }, }) if err != nil { + s.logError(r, err, "could not check permission") w.WriteHeader(http.StatusInternalServerError) return } @@ -155,10 +178,12 @@ func (s Service) Set(w http.ResponseWriter, r *http.Request) { // an announcement without a banner text is nothing to show, so remove it entirely if body.BannerText == "" { if err := s.store.Delete(); err != nil { + s.logError(r, err, "could not delete announcement from store") w.WriteHeader(http.StatusInternalServerError) return } } else if err := s.store.Set(body); err != nil { + s.logError(r, err, "could not write announcement to store") w.WriteHeader(http.StatusInternalServerError) return } diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index 4587ce0822..f1ed45f372 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -69,6 +69,7 @@ func NewService(opts ...Option) (Service, error) { announcementService, err := announcement.NewService( announcement.ServiceOptions{}. + WithLogger(options.Logger). WithStore(options.AnnouncementStore). WithGatewaySelector(options.GatewaySelector), ) From 27bdda265f2b16a0df73eca77458281d7a32c3cf Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 10:22:43 +0200 Subject: [PATCH 07/10] refactor: back the announcement store with plain NATS JetStream Replace the go-micro store with a NATS JetStream key-value bucket, created via reva's cache.NewNatsKeyValue, so the web service no longer depends on go-micro stores. The store methods now take a context, threaded through the config.json render path and the management handlers. Tests use a mockery mock of jetstream.KeyValue. --- services/web/.mockery.yaml | 14 + services/web/mocks/key_value.go | 1363 +++++++++++++++++ services/web/mocks/key_value_entry.go | 349 +++++ services/web/pkg/announcement/announcement.go | 37 +- .../web/pkg/announcement/announcement_test.go | 92 +- services/web/pkg/announcement/service.go | 6 +- services/web/pkg/config/config.go | 21 +- .../web/pkg/config/defaults/defaultconfig.go | 3 - services/web/pkg/server/http/server.go | 29 +- services/web/pkg/service/v0/service.go | 13 +- services/web/pkg/service/v0/service_test.go | 43 +- 11 files changed, 1856 insertions(+), 114 deletions(-) create mode 100644 services/web/.mockery.yaml create mode 100644 services/web/mocks/key_value.go create mode 100644 services/web/mocks/key_value_entry.go diff --git a/services/web/.mockery.yaml b/services/web/.mockery.yaml new file mode 100644 index 0000000000..04c898f18f --- /dev/null +++ b/services/web/.mockery.yaml @@ -0,0 +1,14 @@ +# maintain v2 separate mocks dir +dir: "{{.InterfaceDir}}/mocks" +structname: "{{.InterfaceName}}" +filename: "{{.InterfaceName | snakecase }}.go" +pkgname: mocks + +template: testify +packages: + github.com/nats-io/nats.go/jetstream: + config: + dir: mocks + interfaces: + KeyValue: {} + KeyValueEntry: {} diff --git a/services/web/mocks/key_value.go b/services/web/mocks/key_value.go new file mode 100644 index 0000000000..042e5dcc54 --- /dev/null +++ b/services/web/mocks/key_value.go @@ -0,0 +1,1363 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/nats-io/nats.go/jetstream" + mock "github.com/stretchr/testify/mock" +) + +// NewKeyValue creates a new instance of KeyValue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewKeyValue(t interface { + mock.TestingT + Cleanup(func()) +}) *KeyValue { + mock := &KeyValue{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// KeyValue is an autogenerated mock type for the KeyValue type +type KeyValue struct { + mock.Mock +} + +type KeyValue_Expecter struct { + mock *mock.Mock +} + +func (_m *KeyValue) EXPECT() *KeyValue_Expecter { + return &KeyValue_Expecter{mock: &_m.Mock} +} + +// Bucket provides a mock function for the type KeyValue +func (_mock *KeyValue) Bucket() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Bucket") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// KeyValue_Bucket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Bucket' +type KeyValue_Bucket_Call struct { + *mock.Call +} + +// Bucket is a helper method to define mock.On call +func (_e *KeyValue_Expecter) Bucket() *KeyValue_Bucket_Call { + return &KeyValue_Bucket_Call{Call: _e.mock.On("Bucket")} +} + +func (_c *KeyValue_Bucket_Call) Run(run func()) *KeyValue_Bucket_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValue_Bucket_Call) Return(s string) *KeyValue_Bucket_Call { + _c.Call.Return(s) + return _c +} + +func (_c *KeyValue_Bucket_Call) RunAndReturn(run func() string) *KeyValue_Bucket_Call { + _c.Call.Return(run) + return _c +} + +// Create provides a mock function for the type KeyValue +func (_mock *KeyValue) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, key, value, opts) + } else { + tmpRet = _mock.Called(ctx, key, value) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte, ...jetstream.KVCreateOpt) (uint64, error)); ok { + return returnFunc(ctx, key, value, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte, ...jetstream.KVCreateOpt) uint64); ok { + r0 = returnFunc(ctx, key, value, opts...) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, []byte, ...jetstream.KVCreateOpt) error); ok { + r1 = returnFunc(ctx, key, value, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type KeyValue_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - value []byte +// - opts ...jetstream.KVCreateOpt +func (_e *KeyValue_Expecter) Create(ctx interface{}, key interface{}, value interface{}, opts ...interface{}) *KeyValue_Create_Call { + return &KeyValue_Create_Call{Call: _e.mock.On("Create", + append([]interface{}{ctx, key, value}, opts...)...)} +} + +func (_c *KeyValue_Create_Call) Run(run func(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt)) *KeyValue_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []byte + if args[2] != nil { + arg2 = args[2].([]byte) + } + var arg3 []jetstream.KVCreateOpt + var variadicArgs []jetstream.KVCreateOpt + if len(args) > 3 { + variadicArgs = args[3].([]jetstream.KVCreateOpt) + } + arg3 = variadicArgs + run( + arg0, + arg1, + arg2, + arg3..., + ) + }) + return _c +} + +func (_c *KeyValue_Create_Call) Return(v uint64, err error) *KeyValue_Create_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *KeyValue_Create_Call) RunAndReturn(run func(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error)) *KeyValue_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function for the type KeyValue +func (_mock *KeyValue) Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, key, opts) + } else { + tmpRet = _mock.Called(ctx, key) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...jetstream.KVDeleteOpt) error); ok { + r0 = returnFunc(ctx, key, opts...) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// KeyValue_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type KeyValue_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - opts ...jetstream.KVDeleteOpt +func (_e *KeyValue_Expecter) Delete(ctx interface{}, key interface{}, opts ...interface{}) *KeyValue_Delete_Call { + return &KeyValue_Delete_Call{Call: _e.mock.On("Delete", + append([]interface{}{ctx, key}, opts...)...)} +} + +func (_c *KeyValue_Delete_Call) Run(run func(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt)) *KeyValue_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []jetstream.KVDeleteOpt + var variadicArgs []jetstream.KVDeleteOpt + if len(args) > 2 { + variadicArgs = args[2].([]jetstream.KVDeleteOpt) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *KeyValue_Delete_Call) Return(err error) *KeyValue_Delete_Call { + _c.Call.Return(err) + return _c +} + +func (_c *KeyValue_Delete_Call) RunAndReturn(run func(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error) *KeyValue_Delete_Call { + _c.Call.Return(run) + return _c +} + +// Get provides a mock function for the type KeyValue +func (_mock *KeyValue) Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 jetstream.KeyValueEntry + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (jetstream.KeyValueEntry, error)); ok { + return returnFunc(ctx, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) jetstream.KeyValueEntry); ok { + r0 = returnFunc(ctx, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyValueEntry) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, key) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type KeyValue_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - key string +func (_e *KeyValue_Expecter) Get(ctx interface{}, key interface{}) *KeyValue_Get_Call { + return &KeyValue_Get_Call{Call: _e.mock.On("Get", ctx, key)} +} + +func (_c *KeyValue_Get_Call) Run(run func(ctx context.Context, key string)) *KeyValue_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *KeyValue_Get_Call) Return(keyValueEntry jetstream.KeyValueEntry, err error) *KeyValue_Get_Call { + _c.Call.Return(keyValueEntry, err) + return _c +} + +func (_c *KeyValue_Get_Call) RunAndReturn(run func(ctx context.Context, key string) (jetstream.KeyValueEntry, error)) *KeyValue_Get_Call { + _c.Call.Return(run) + return _c +} + +// GetRevision provides a mock function for the type KeyValue +func (_mock *KeyValue) GetRevision(ctx context.Context, key string, revision uint64) (jetstream.KeyValueEntry, error) { + ret := _mock.Called(ctx, key, revision) + + if len(ret) == 0 { + panic("no return value specified for GetRevision") + } + + var r0 jetstream.KeyValueEntry + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint64) (jetstream.KeyValueEntry, error)); ok { + return returnFunc(ctx, key, revision) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint64) jetstream.KeyValueEntry); ok { + r0 = returnFunc(ctx, key, revision) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyValueEntry) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, uint64) error); ok { + r1 = returnFunc(ctx, key, revision) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_GetRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRevision' +type KeyValue_GetRevision_Call struct { + *mock.Call +} + +// GetRevision is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - revision uint64 +func (_e *KeyValue_Expecter) GetRevision(ctx interface{}, key interface{}, revision interface{}) *KeyValue_GetRevision_Call { + return &KeyValue_GetRevision_Call{Call: _e.mock.On("GetRevision", ctx, key, revision)} +} + +func (_c *KeyValue_GetRevision_Call) Run(run func(ctx context.Context, key string, revision uint64)) *KeyValue_GetRevision_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 uint64 + if args[2] != nil { + arg2 = args[2].(uint64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *KeyValue_GetRevision_Call) Return(keyValueEntry jetstream.KeyValueEntry, err error) *KeyValue_GetRevision_Call { + _c.Call.Return(keyValueEntry, err) + return _c +} + +func (_c *KeyValue_GetRevision_Call) RunAndReturn(run func(ctx context.Context, key string, revision uint64) (jetstream.KeyValueEntry, error)) *KeyValue_GetRevision_Call { + _c.Call.Return(run) + return _c +} + +// History provides a mock function for the type KeyValue +func (_mock *KeyValue) History(ctx context.Context, key string, opts ...jetstream.WatchOpt) ([]jetstream.KeyValueEntry, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, key, opts) + } else { + tmpRet = _mock.Called(ctx, key) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for History") + } + + var r0 []jetstream.KeyValueEntry + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...jetstream.WatchOpt) ([]jetstream.KeyValueEntry, error)); ok { + return returnFunc(ctx, key, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...jetstream.WatchOpt) []jetstream.KeyValueEntry); ok { + r0 = returnFunc(ctx, key, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]jetstream.KeyValueEntry) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, ...jetstream.WatchOpt) error); ok { + r1 = returnFunc(ctx, key, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_History_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'History' +type KeyValue_History_Call struct { + *mock.Call +} + +// History is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - opts ...jetstream.WatchOpt +func (_e *KeyValue_Expecter) History(ctx interface{}, key interface{}, opts ...interface{}) *KeyValue_History_Call { + return &KeyValue_History_Call{Call: _e.mock.On("History", + append([]interface{}{ctx, key}, opts...)...)} +} + +func (_c *KeyValue_History_Call) Run(run func(ctx context.Context, key string, opts ...jetstream.WatchOpt)) *KeyValue_History_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []jetstream.WatchOpt + var variadicArgs []jetstream.WatchOpt + if len(args) > 2 { + variadicArgs = args[2].([]jetstream.WatchOpt) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *KeyValue_History_Call) Return(keyValueEntrys []jetstream.KeyValueEntry, err error) *KeyValue_History_Call { + _c.Call.Return(keyValueEntrys, err) + return _c +} + +func (_c *KeyValue_History_Call) RunAndReturn(run func(ctx context.Context, key string, opts ...jetstream.WatchOpt) ([]jetstream.KeyValueEntry, error)) *KeyValue_History_Call { + _c.Call.Return(run) + return _c +} + +// Keys provides a mock function for the type KeyValue +func (_mock *KeyValue) Keys(ctx context.Context, opts ...jetstream.WatchOpt) ([]string, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, opts) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Keys") + } + + var r0 []string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...jetstream.WatchOpt) ([]string, error)); ok { + return returnFunc(ctx, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...jetstream.WatchOpt) []string); ok { + r0 = returnFunc(ctx, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...jetstream.WatchOpt) error); ok { + r1 = returnFunc(ctx, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_Keys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Keys' +type KeyValue_Keys_Call struct { + *mock.Call +} + +// Keys is a helper method to define mock.On call +// - ctx context.Context +// - opts ...jetstream.WatchOpt +func (_e *KeyValue_Expecter) Keys(ctx interface{}, opts ...interface{}) *KeyValue_Keys_Call { + return &KeyValue_Keys_Call{Call: _e.mock.On("Keys", + append([]interface{}{ctx}, opts...)...)} +} + +func (_c *KeyValue_Keys_Call) Run(run func(ctx context.Context, opts ...jetstream.WatchOpt)) *KeyValue_Keys_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []jetstream.WatchOpt + var variadicArgs []jetstream.WatchOpt + if len(args) > 1 { + variadicArgs = args[1].([]jetstream.WatchOpt) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *KeyValue_Keys_Call) Return(strings []string, err error) *KeyValue_Keys_Call { + _c.Call.Return(strings, err) + return _c +} + +func (_c *KeyValue_Keys_Call) RunAndReturn(run func(ctx context.Context, opts ...jetstream.WatchOpt) ([]string, error)) *KeyValue_Keys_Call { + _c.Call.Return(run) + return _c +} + +// ListKeys provides a mock function for the type KeyValue +func (_mock *KeyValue) ListKeys(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyLister, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, opts) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for ListKeys") + } + + var r0 jetstream.KeyLister + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...jetstream.WatchOpt) (jetstream.KeyLister, error)); ok { + return returnFunc(ctx, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...jetstream.WatchOpt) jetstream.KeyLister); ok { + r0 = returnFunc(ctx, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyLister) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...jetstream.WatchOpt) error); ok { + r1 = returnFunc(ctx, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' +type KeyValue_ListKeys_Call struct { + *mock.Call +} + +// ListKeys is a helper method to define mock.On call +// - ctx context.Context +// - opts ...jetstream.WatchOpt +func (_e *KeyValue_Expecter) ListKeys(ctx interface{}, opts ...interface{}) *KeyValue_ListKeys_Call { + return &KeyValue_ListKeys_Call{Call: _e.mock.On("ListKeys", + append([]interface{}{ctx}, opts...)...)} +} + +func (_c *KeyValue_ListKeys_Call) Run(run func(ctx context.Context, opts ...jetstream.WatchOpt)) *KeyValue_ListKeys_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []jetstream.WatchOpt + var variadicArgs []jetstream.WatchOpt + if len(args) > 1 { + variadicArgs = args[1].([]jetstream.WatchOpt) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *KeyValue_ListKeys_Call) Return(keyLister jetstream.KeyLister, err error) *KeyValue_ListKeys_Call { + _c.Call.Return(keyLister, err) + return _c +} + +func (_c *KeyValue_ListKeys_Call) RunAndReturn(run func(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyLister, error)) *KeyValue_ListKeys_Call { + _c.Call.Return(run) + return _c +} + +// ListKeysFiltered provides a mock function for the type KeyValue +func (_mock *KeyValue) ListKeysFiltered(ctx context.Context, filters ...string) (jetstream.KeyLister, error) { + var tmpRet mock.Arguments + if len(filters) > 0 { + tmpRet = _mock.Called(ctx, filters) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for ListKeysFiltered") + } + + var r0 jetstream.KeyLister + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...string) (jetstream.KeyLister, error)); ok { + return returnFunc(ctx, filters...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...string) jetstream.KeyLister); ok { + r0 = returnFunc(ctx, filters...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyLister) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...string) error); ok { + r1 = returnFunc(ctx, filters...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_ListKeysFiltered_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeysFiltered' +type KeyValue_ListKeysFiltered_Call struct { + *mock.Call +} + +// ListKeysFiltered is a helper method to define mock.On call +// - ctx context.Context +// - filters ...string +func (_e *KeyValue_Expecter) ListKeysFiltered(ctx interface{}, filters ...interface{}) *KeyValue_ListKeysFiltered_Call { + return &KeyValue_ListKeysFiltered_Call{Call: _e.mock.On("ListKeysFiltered", + append([]interface{}{ctx}, filters...)...)} +} + +func (_c *KeyValue_ListKeysFiltered_Call) Run(run func(ctx context.Context, filters ...string)) *KeyValue_ListKeysFiltered_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []string + var variadicArgs []string + if len(args) > 1 { + variadicArgs = args[1].([]string) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *KeyValue_ListKeysFiltered_Call) Return(keyLister jetstream.KeyLister, err error) *KeyValue_ListKeysFiltered_Call { + _c.Call.Return(keyLister, err) + return _c +} + +func (_c *KeyValue_ListKeysFiltered_Call) RunAndReturn(run func(ctx context.Context, filters ...string) (jetstream.KeyLister, error)) *KeyValue_ListKeysFiltered_Call { + _c.Call.Return(run) + return _c +} + +// Purge provides a mock function for the type KeyValue +func (_mock *KeyValue) Purge(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, key, opts) + } else { + tmpRet = _mock.Called(ctx, key) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Purge") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...jetstream.KVDeleteOpt) error); ok { + r0 = returnFunc(ctx, key, opts...) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// KeyValue_Purge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Purge' +type KeyValue_Purge_Call struct { + *mock.Call +} + +// Purge is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - opts ...jetstream.KVDeleteOpt +func (_e *KeyValue_Expecter) Purge(ctx interface{}, key interface{}, opts ...interface{}) *KeyValue_Purge_Call { + return &KeyValue_Purge_Call{Call: _e.mock.On("Purge", + append([]interface{}{ctx, key}, opts...)...)} +} + +func (_c *KeyValue_Purge_Call) Run(run func(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt)) *KeyValue_Purge_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []jetstream.KVDeleteOpt + var variadicArgs []jetstream.KVDeleteOpt + if len(args) > 2 { + variadicArgs = args[2].([]jetstream.KVDeleteOpt) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *KeyValue_Purge_Call) Return(err error) *KeyValue_Purge_Call { + _c.Call.Return(err) + return _c +} + +func (_c *KeyValue_Purge_Call) RunAndReturn(run func(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error) *KeyValue_Purge_Call { + _c.Call.Return(run) + return _c +} + +// PurgeDeletes provides a mock function for the type KeyValue +func (_mock *KeyValue) PurgeDeletes(ctx context.Context, opts ...jetstream.KVPurgeOpt) error { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, opts) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for PurgeDeletes") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...jetstream.KVPurgeOpt) error); ok { + r0 = returnFunc(ctx, opts...) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// KeyValue_PurgeDeletes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PurgeDeletes' +type KeyValue_PurgeDeletes_Call struct { + *mock.Call +} + +// PurgeDeletes is a helper method to define mock.On call +// - ctx context.Context +// - opts ...jetstream.KVPurgeOpt +func (_e *KeyValue_Expecter) PurgeDeletes(ctx interface{}, opts ...interface{}) *KeyValue_PurgeDeletes_Call { + return &KeyValue_PurgeDeletes_Call{Call: _e.mock.On("PurgeDeletes", + append([]interface{}{ctx}, opts...)...)} +} + +func (_c *KeyValue_PurgeDeletes_Call) Run(run func(ctx context.Context, opts ...jetstream.KVPurgeOpt)) *KeyValue_PurgeDeletes_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []jetstream.KVPurgeOpt + var variadicArgs []jetstream.KVPurgeOpt + if len(args) > 1 { + variadicArgs = args[1].([]jetstream.KVPurgeOpt) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *KeyValue_PurgeDeletes_Call) Return(err error) *KeyValue_PurgeDeletes_Call { + _c.Call.Return(err) + return _c +} + +func (_c *KeyValue_PurgeDeletes_Call) RunAndReturn(run func(ctx context.Context, opts ...jetstream.KVPurgeOpt) error) *KeyValue_PurgeDeletes_Call { + _c.Call.Return(run) + return _c +} + +// Put provides a mock function for the type KeyValue +func (_mock *KeyValue) Put(ctx context.Context, key string, value []byte) (uint64, error) { + ret := _mock.Called(ctx, key, value) + + if len(ret) == 0 { + panic("no return value specified for Put") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) (uint64, error)); ok { + return returnFunc(ctx, key, value) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) uint64); ok { + r0 = returnFunc(ctx, key, value) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, []byte) error); ok { + r1 = returnFunc(ctx, key, value) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' +type KeyValue_Put_Call struct { + *mock.Call +} + +// Put is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - value []byte +func (_e *KeyValue_Expecter) Put(ctx interface{}, key interface{}, value interface{}) *KeyValue_Put_Call { + return &KeyValue_Put_Call{Call: _e.mock.On("Put", ctx, key, value)} +} + +func (_c *KeyValue_Put_Call) Run(run func(ctx context.Context, key string, value []byte)) *KeyValue_Put_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []byte + if args[2] != nil { + arg2 = args[2].([]byte) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *KeyValue_Put_Call) Return(v uint64, err error) *KeyValue_Put_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *KeyValue_Put_Call) RunAndReturn(run func(ctx context.Context, key string, value []byte) (uint64, error)) *KeyValue_Put_Call { + _c.Call.Return(run) + return _c +} + +// PutString provides a mock function for the type KeyValue +func (_mock *KeyValue) PutString(ctx context.Context, key string, value string) (uint64, error) { + ret := _mock.Called(ctx, key, value) + + if len(ret) == 0 { + panic("no return value specified for PutString") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (uint64, error)); ok { + return returnFunc(ctx, key, value) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) uint64); ok { + r0 = returnFunc(ctx, key, value) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, key, value) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_PutString_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutString' +type KeyValue_PutString_Call struct { + *mock.Call +} + +// PutString is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - value string +func (_e *KeyValue_Expecter) PutString(ctx interface{}, key interface{}, value interface{}) *KeyValue_PutString_Call { + return &KeyValue_PutString_Call{Call: _e.mock.On("PutString", ctx, key, value)} +} + +func (_c *KeyValue_PutString_Call) Run(run func(ctx context.Context, key string, value string)) *KeyValue_PutString_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *KeyValue_PutString_Call) Return(v uint64, err error) *KeyValue_PutString_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *KeyValue_PutString_Call) RunAndReturn(run func(ctx context.Context, key string, value string) (uint64, error)) *KeyValue_PutString_Call { + _c.Call.Return(run) + return _c +} + +// Status provides a mock function for the type KeyValue +func (_mock *KeyValue) Status(ctx context.Context) (jetstream.KeyValueStatus, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Status") + } + + var r0 jetstream.KeyValueStatus + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (jetstream.KeyValueStatus, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) jetstream.KeyValueStatus); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyValueStatus) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status' +type KeyValue_Status_Call struct { + *mock.Call +} + +// Status is a helper method to define mock.On call +// - ctx context.Context +func (_e *KeyValue_Expecter) Status(ctx interface{}) *KeyValue_Status_Call { + return &KeyValue_Status_Call{Call: _e.mock.On("Status", ctx)} +} + +func (_c *KeyValue_Status_Call) Run(run func(ctx context.Context)) *KeyValue_Status_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *KeyValue_Status_Call) Return(keyValueStatus jetstream.KeyValueStatus, err error) *KeyValue_Status_Call { + _c.Call.Return(keyValueStatus, err) + return _c +} + +func (_c *KeyValue_Status_Call) RunAndReturn(run func(ctx context.Context) (jetstream.KeyValueStatus, error)) *KeyValue_Status_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function for the type KeyValue +func (_mock *KeyValue) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) { + ret := _mock.Called(ctx, key, value, revision) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte, uint64) (uint64, error)); ok { + return returnFunc(ctx, key, value, revision) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte, uint64) uint64); ok { + r0 = returnFunc(ctx, key, value, revision) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, []byte, uint64) error); ok { + r1 = returnFunc(ctx, key, value, revision) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type KeyValue_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - key string +// - value []byte +// - revision uint64 +func (_e *KeyValue_Expecter) Update(ctx interface{}, key interface{}, value interface{}, revision interface{}) *KeyValue_Update_Call { + return &KeyValue_Update_Call{Call: _e.mock.On("Update", ctx, key, value, revision)} +} + +func (_c *KeyValue_Update_Call) Run(run func(ctx context.Context, key string, value []byte, revision uint64)) *KeyValue_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []byte + if args[2] != nil { + arg2 = args[2].([]byte) + } + var arg3 uint64 + if args[3] != nil { + arg3 = args[3].(uint64) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *KeyValue_Update_Call) Return(v uint64, err error) *KeyValue_Update_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *KeyValue_Update_Call) RunAndReturn(run func(ctx context.Context, key string, value []byte, revision uint64) (uint64, error)) *KeyValue_Update_Call { + _c.Call.Return(run) + return _c +} + +// Watch provides a mock function for the type KeyValue +func (_mock *KeyValue) Watch(ctx context.Context, keys string, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, keys, opts) + } else { + tmpRet = _mock.Called(ctx, keys) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Watch") + } + + var r0 jetstream.KeyWatcher + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error)); ok { + return returnFunc(ctx, keys, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...jetstream.WatchOpt) jetstream.KeyWatcher); ok { + r0 = returnFunc(ctx, keys, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyWatcher) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, ...jetstream.WatchOpt) error); ok { + r1 = returnFunc(ctx, keys, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_Watch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Watch' +type KeyValue_Watch_Call struct { + *mock.Call +} + +// Watch is a helper method to define mock.On call +// - ctx context.Context +// - keys string +// - opts ...jetstream.WatchOpt +func (_e *KeyValue_Expecter) Watch(ctx interface{}, keys interface{}, opts ...interface{}) *KeyValue_Watch_Call { + return &KeyValue_Watch_Call{Call: _e.mock.On("Watch", + append([]interface{}{ctx, keys}, opts...)...)} +} + +func (_c *KeyValue_Watch_Call) Run(run func(ctx context.Context, keys string, opts ...jetstream.WatchOpt)) *KeyValue_Watch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []jetstream.WatchOpt + var variadicArgs []jetstream.WatchOpt + if len(args) > 2 { + variadicArgs = args[2].([]jetstream.WatchOpt) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *KeyValue_Watch_Call) Return(keyWatcher jetstream.KeyWatcher, err error) *KeyValue_Watch_Call { + _c.Call.Return(keyWatcher, err) + return _c +} + +func (_c *KeyValue_Watch_Call) RunAndReturn(run func(ctx context.Context, keys string, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error)) *KeyValue_Watch_Call { + _c.Call.Return(run) + return _c +} + +// WatchAll provides a mock function for the type KeyValue +func (_mock *KeyValue) WatchAll(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, opts) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for WatchAll") + } + + var r0 jetstream.KeyWatcher + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error)); ok { + return returnFunc(ctx, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...jetstream.WatchOpt) jetstream.KeyWatcher); ok { + r0 = returnFunc(ctx, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyWatcher) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...jetstream.WatchOpt) error); ok { + r1 = returnFunc(ctx, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_WatchAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WatchAll' +type KeyValue_WatchAll_Call struct { + *mock.Call +} + +// WatchAll is a helper method to define mock.On call +// - ctx context.Context +// - opts ...jetstream.WatchOpt +func (_e *KeyValue_Expecter) WatchAll(ctx interface{}, opts ...interface{}) *KeyValue_WatchAll_Call { + return &KeyValue_WatchAll_Call{Call: _e.mock.On("WatchAll", + append([]interface{}{ctx}, opts...)...)} +} + +func (_c *KeyValue_WatchAll_Call) Run(run func(ctx context.Context, opts ...jetstream.WatchOpt)) *KeyValue_WatchAll_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []jetstream.WatchOpt + var variadicArgs []jetstream.WatchOpt + if len(args) > 1 { + variadicArgs = args[1].([]jetstream.WatchOpt) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *KeyValue_WatchAll_Call) Return(keyWatcher jetstream.KeyWatcher, err error) *KeyValue_WatchAll_Call { + _c.Call.Return(keyWatcher, err) + return _c +} + +func (_c *KeyValue_WatchAll_Call) RunAndReturn(run func(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error)) *KeyValue_WatchAll_Call { + _c.Call.Return(run) + return _c +} + +// WatchFiltered provides a mock function for the type KeyValue +func (_mock *KeyValue) WatchFiltered(ctx context.Context, keys []string, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, keys, opts) + } else { + tmpRet = _mock.Called(ctx, keys) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for WatchFiltered") + } + + var r0 jetstream.KeyWatcher + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error)); ok { + return returnFunc(ctx, keys, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, []string, ...jetstream.WatchOpt) jetstream.KeyWatcher); ok { + r0 = returnFunc(ctx, keys, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jetstream.KeyWatcher) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, []string, ...jetstream.WatchOpt) error); ok { + r1 = returnFunc(ctx, keys, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// KeyValue_WatchFiltered_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WatchFiltered' +type KeyValue_WatchFiltered_Call struct { + *mock.Call +} + +// WatchFiltered is a helper method to define mock.On call +// - ctx context.Context +// - keys []string +// - opts ...jetstream.WatchOpt +func (_e *KeyValue_Expecter) WatchFiltered(ctx interface{}, keys interface{}, opts ...interface{}) *KeyValue_WatchFiltered_Call { + return &KeyValue_WatchFiltered_Call{Call: _e.mock.On("WatchFiltered", + append([]interface{}{ctx, keys}, opts...)...)} +} + +func (_c *KeyValue_WatchFiltered_Call) Run(run func(ctx context.Context, keys []string, opts ...jetstream.WatchOpt)) *KeyValue_WatchFiltered_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []string + if args[1] != nil { + arg1 = args[1].([]string) + } + var arg2 []jetstream.WatchOpt + var variadicArgs []jetstream.WatchOpt + if len(args) > 2 { + variadicArgs = args[2].([]jetstream.WatchOpt) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *KeyValue_WatchFiltered_Call) Return(keyWatcher jetstream.KeyWatcher, err error) *KeyValue_WatchFiltered_Call { + _c.Call.Return(keyWatcher, err) + return _c +} + +func (_c *KeyValue_WatchFiltered_Call) RunAndReturn(run func(ctx context.Context, keys []string, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error)) *KeyValue_WatchFiltered_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/web/mocks/key_value_entry.go b/services/web/mocks/key_value_entry.go new file mode 100644 index 0000000000..7decc5725a --- /dev/null +++ b/services/web/mocks/key_value_entry.go @@ -0,0 +1,349 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "time" + + "github.com/nats-io/nats.go/jetstream" + mock "github.com/stretchr/testify/mock" +) + +// NewKeyValueEntry creates a new instance of KeyValueEntry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewKeyValueEntry(t interface { + mock.TestingT + Cleanup(func()) +}) *KeyValueEntry { + mock := &KeyValueEntry{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// KeyValueEntry is an autogenerated mock type for the KeyValueEntry type +type KeyValueEntry struct { + mock.Mock +} + +type KeyValueEntry_Expecter struct { + mock *mock.Mock +} + +func (_m *KeyValueEntry) EXPECT() *KeyValueEntry_Expecter { + return &KeyValueEntry_Expecter{mock: &_m.Mock} +} + +// Bucket provides a mock function for the type KeyValueEntry +func (_mock *KeyValueEntry) Bucket() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Bucket") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// KeyValueEntry_Bucket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Bucket' +type KeyValueEntry_Bucket_Call struct { + *mock.Call +} + +// Bucket is a helper method to define mock.On call +func (_e *KeyValueEntry_Expecter) Bucket() *KeyValueEntry_Bucket_Call { + return &KeyValueEntry_Bucket_Call{Call: _e.mock.On("Bucket")} +} + +func (_c *KeyValueEntry_Bucket_Call) Run(run func()) *KeyValueEntry_Bucket_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValueEntry_Bucket_Call) Return(s string) *KeyValueEntry_Bucket_Call { + _c.Call.Return(s) + return _c +} + +func (_c *KeyValueEntry_Bucket_Call) RunAndReturn(run func() string) *KeyValueEntry_Bucket_Call { + _c.Call.Return(run) + return _c +} + +// Created provides a mock function for the type KeyValueEntry +func (_mock *KeyValueEntry) Created() time.Time { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Created") + } + + var r0 time.Time + if returnFunc, ok := ret.Get(0).(func() time.Time); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(time.Time) + } + return r0 +} + +// KeyValueEntry_Created_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Created' +type KeyValueEntry_Created_Call struct { + *mock.Call +} + +// Created is a helper method to define mock.On call +func (_e *KeyValueEntry_Expecter) Created() *KeyValueEntry_Created_Call { + return &KeyValueEntry_Created_Call{Call: _e.mock.On("Created")} +} + +func (_c *KeyValueEntry_Created_Call) Run(run func()) *KeyValueEntry_Created_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValueEntry_Created_Call) Return(time1 time.Time) *KeyValueEntry_Created_Call { + _c.Call.Return(time1) + return _c +} + +func (_c *KeyValueEntry_Created_Call) RunAndReturn(run func() time.Time) *KeyValueEntry_Created_Call { + _c.Call.Return(run) + return _c +} + +// Delta provides a mock function for the type KeyValueEntry +func (_mock *KeyValueEntry) Delta() uint64 { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Delta") + } + + var r0 uint64 + if returnFunc, ok := ret.Get(0).(func() uint64); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(uint64) + } + return r0 +} + +// KeyValueEntry_Delta_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delta' +type KeyValueEntry_Delta_Call struct { + *mock.Call +} + +// Delta is a helper method to define mock.On call +func (_e *KeyValueEntry_Expecter) Delta() *KeyValueEntry_Delta_Call { + return &KeyValueEntry_Delta_Call{Call: _e.mock.On("Delta")} +} + +func (_c *KeyValueEntry_Delta_Call) Run(run func()) *KeyValueEntry_Delta_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValueEntry_Delta_Call) Return(v uint64) *KeyValueEntry_Delta_Call { + _c.Call.Return(v) + return _c +} + +func (_c *KeyValueEntry_Delta_Call) RunAndReturn(run func() uint64) *KeyValueEntry_Delta_Call { + _c.Call.Return(run) + return _c +} + +// Key provides a mock function for the type KeyValueEntry +func (_mock *KeyValueEntry) Key() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Key") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// KeyValueEntry_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key' +type KeyValueEntry_Key_Call struct { + *mock.Call +} + +// Key is a helper method to define mock.On call +func (_e *KeyValueEntry_Expecter) Key() *KeyValueEntry_Key_Call { + return &KeyValueEntry_Key_Call{Call: _e.mock.On("Key")} +} + +func (_c *KeyValueEntry_Key_Call) Run(run func()) *KeyValueEntry_Key_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValueEntry_Key_Call) Return(s string) *KeyValueEntry_Key_Call { + _c.Call.Return(s) + return _c +} + +func (_c *KeyValueEntry_Key_Call) RunAndReturn(run func() string) *KeyValueEntry_Key_Call { + _c.Call.Return(run) + return _c +} + +// Operation provides a mock function for the type KeyValueEntry +func (_mock *KeyValueEntry) Operation() jetstream.KeyValueOp { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Operation") + } + + var r0 jetstream.KeyValueOp + if returnFunc, ok := ret.Get(0).(func() jetstream.KeyValueOp); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(jetstream.KeyValueOp) + } + return r0 +} + +// KeyValueEntry_Operation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Operation' +type KeyValueEntry_Operation_Call struct { + *mock.Call +} + +// Operation is a helper method to define mock.On call +func (_e *KeyValueEntry_Expecter) Operation() *KeyValueEntry_Operation_Call { + return &KeyValueEntry_Operation_Call{Call: _e.mock.On("Operation")} +} + +func (_c *KeyValueEntry_Operation_Call) Run(run func()) *KeyValueEntry_Operation_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValueEntry_Operation_Call) Return(keyValueOp jetstream.KeyValueOp) *KeyValueEntry_Operation_Call { + _c.Call.Return(keyValueOp) + return _c +} + +func (_c *KeyValueEntry_Operation_Call) RunAndReturn(run func() jetstream.KeyValueOp) *KeyValueEntry_Operation_Call { + _c.Call.Return(run) + return _c +} + +// Revision provides a mock function for the type KeyValueEntry +func (_mock *KeyValueEntry) Revision() uint64 { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Revision") + } + + var r0 uint64 + if returnFunc, ok := ret.Get(0).(func() uint64); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(uint64) + } + return r0 +} + +// KeyValueEntry_Revision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revision' +type KeyValueEntry_Revision_Call struct { + *mock.Call +} + +// Revision is a helper method to define mock.On call +func (_e *KeyValueEntry_Expecter) Revision() *KeyValueEntry_Revision_Call { + return &KeyValueEntry_Revision_Call{Call: _e.mock.On("Revision")} +} + +func (_c *KeyValueEntry_Revision_Call) Run(run func()) *KeyValueEntry_Revision_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValueEntry_Revision_Call) Return(v uint64) *KeyValueEntry_Revision_Call { + _c.Call.Return(v) + return _c +} + +func (_c *KeyValueEntry_Revision_Call) RunAndReturn(run func() uint64) *KeyValueEntry_Revision_Call { + _c.Call.Return(run) + return _c +} + +// Value provides a mock function for the type KeyValueEntry +func (_mock *KeyValueEntry) Value() []byte { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Value") + } + + var r0 []byte + if returnFunc, ok := ret.Get(0).(func() []byte); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + return r0 +} + +// KeyValueEntry_Value_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Value' +type KeyValueEntry_Value_Call struct { + *mock.Call +} + +// Value is a helper method to define mock.On call +func (_e *KeyValueEntry_Expecter) Value() *KeyValueEntry_Value_Call { + return &KeyValueEntry_Value_Call{Call: _e.mock.On("Value")} +} + +func (_c *KeyValueEntry_Value_Call) Run(run func()) *KeyValueEntry_Value_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *KeyValueEntry_Value_Call) Return(bytes []byte) *KeyValueEntry_Value_Call { + _c.Call.Return(bytes) + return _c +} + +func (_c *KeyValueEntry_Value_Call) RunAndReturn(run func() []byte) *KeyValueEntry_Value_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/web/pkg/announcement/announcement.go b/services/web/pkg/announcement/announcement.go index 5fb1029b89..085fd16a71 100644 --- a/services/web/pkg/announcement/announcement.go +++ b/services/web/pkg/announcement/announcement.go @@ -2,10 +2,11 @@ package announcement import ( + "context" "encoding/json" "errors" - microstore "go-micro.dev/v4/store" + "github.com/nats-io/nats.go/jetstream" ) // _storeKey is the single key under which the announcement is persisted. @@ -19,33 +20,29 @@ type Announcement struct { InfoText string `json:"infoText"` } -// Store persists a single announcement in a key-value store. +// Store persists a single announcement in a NATS JetStream key-value bucket. type Store struct { - store microstore.Store + kv jetstream.KeyValue } -// NewStore returns a new announcement Store backed by the given key-value store. -func NewStore(s microstore.Store) *Store { - return &Store{store: s} +// NewStore returns a new announcement Store backed by the given key-value bucket. +func NewStore(kv jetstream.KeyValue) *Store { + return &Store{kv: kv} } // Get returns the currently stored announcement. An unset announcement is returned as the zero value. -func (s *Store) Get() (Announcement, error) { +func (s *Store) Get(ctx context.Context) (Announcement, error) { var a Announcement - records, err := s.store.Read(_storeKey) + entry, err := s.kv.Get(ctx, _storeKey) if err != nil { - if errors.Is(err, microstore.ErrNotFound) { + if errors.Is(err, jetstream.ErrKeyNotFound) { return a, nil } return a, err } - if len(records) == 0 { - return a, nil - } - - if err := json.Unmarshal(records[0].Value, &a); err != nil { + if err := json.Unmarshal(entry.Value(), &a); err != nil { return a, err } @@ -53,21 +50,19 @@ func (s *Store) Get() (Announcement, error) { } // Set persists the given announcement, overwriting any existing one. -func (s *Store) Set(a Announcement) error { +func (s *Store) Set(ctx context.Context, a Announcement) error { value, err := json.Marshal(a) if err != nil { return err } - return s.store.Write(µstore.Record{ - Key: _storeKey, - Value: value, - }) + _, err = s.kv.Put(ctx, _storeKey, value) + return err } // Delete removes the stored announcement. Deleting a missing announcement is a no-op. -func (s *Store) Delete() error { - if err := s.store.Delete(_storeKey); err != nil && !errors.Is(err, microstore.ErrNotFound) { +func (s *Store) Delete(ctx context.Context) error { + if err := s.kv.Delete(ctx, _storeKey); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { return err } return nil diff --git a/services/web/pkg/announcement/announcement_test.go b/services/web/pkg/announcement/announcement_test.go index 986bc9d40b..2cfe22dd44 100644 --- a/services/web/pkg/announcement/announcement_test.go +++ b/services/web/pkg/announcement/announcement_test.go @@ -1,6 +1,7 @@ package announcement_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -10,22 +11,20 @@ import ( userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" cs3permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1" cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + "github.com/nats-io/nats.go/jetstream" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" - "github.com/opencloud-eu/reva/v2/pkg/store" cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" "github.com/stretchr/testify/mock" "google.golang.org/grpc" + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/web/mocks" "github.com/opencloud-eu/opencloud/services/web/pkg/announcement" ) -func newStore() *announcement.Store { - return announcement.NewStore(store.Create(store.Store("memory"))) -} - func newGatewaySelector(allowed bool) pool.Selectable[gateway.GatewayAPIClient] { code := cs3rpc.Code_CODE_OK name := "announcement-test-allowed" @@ -54,6 +53,7 @@ func withUser(r *http.Request) *http.Request { func newService(store *announcement.Store, allowed bool) announcement.Service { svc, err := announcement.NewService(announcement.ServiceOptions{}. + WithLogger(log.NopLogger()). WithStore(store). WithGatewaySelector(newGatewaySelector(allowed))) Expect(err).ToNot(HaveOccurred()) @@ -61,28 +61,47 @@ func newService(store *announcement.Store, allowed bool) announcement.Service { } var _ = Describe("Store", func() { - It("gets, sets and deletes the announcement", func() { - s := newStore() + It("reads the stored announcement", func() { + entry := mocks.NewKeyValueEntry(GinkgoT()) + entry.EXPECT().Value().Return([]byte(`{"enabled":true,"bannerText":"hello","infoText":"world"}`)) + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil) - got, err := s.Get() - Expect(err).ToNot(HaveOccurred()) - Expect(got.BannerText).To(BeEmpty()) - - Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed()) - got, err = s.Get() + got, err := announcement.NewStore(kv).Get(context.Background()) Expect(err).ToNot(HaveOccurred()) Expect(got.Enabled).To(BeTrue()) Expect(got.BannerText).To(Equal("hello")) Expect(got.InfoText).To(Equal("world")) + }) - Expect(s.Delete()).To(Succeed()) - got, err = s.Get() + It("returns the zero value when unset", func() { + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Get(mock.Anything, "announcement").Return(nil, jetstream.ErrKeyNotFound) + + got, err := announcement.NewStore(kv).Get(context.Background()) Expect(err).ToNot(HaveOccurred()) Expect(got.BannerText).To(BeEmpty()) }) + It("writes the announcement", func() { + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Put(mock.Anything, "announcement", mock.Anything).Return(uint64(1), nil) + + Expect(announcement.NewStore(kv).Set(context.Background(), announcement.Announcement{BannerText: "hello"})).To(Succeed()) + }) + + It("deletes the announcement", func() { + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Delete(mock.Anything, "announcement").Return(nil) + + Expect(announcement.NewStore(kv).Delete(context.Background())).To(Succeed()) + }) + It("treats deleting a missing announcement as a no-op", func() { - Expect(newStore().Delete()).To(Succeed()) + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Delete(mock.Anything, "announcement").Return(jetstream.ErrKeyNotFound) + + Expect(announcement.NewStore(kv).Delete(context.Background())).To(Succeed()) }) }) @@ -95,7 +114,7 @@ var _ = Describe("Service", func() { It("succeeds when options are valid", func() { _, err := announcement.NewService(announcement.ServiceOptions{}. - WithStore(newStore()). + WithStore(announcement.NewStore(mocks.NewKeyValue(GinkgoT()))). WithGatewaySelector(newGatewaySelector(true))) Expect(err).ToNot(HaveOccurred()) }) @@ -103,12 +122,15 @@ var _ = Describe("Service", func() { Describe("Get", func() { It("returns the full stored announcement when permitted", func() { - s := newStore() - Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed()) + entry := mocks.NewKeyValueEntry(GinkgoT()) + entry.EXPECT().Value().Return([]byte(`{"enabled":true,"bannerText":"hello","infoText":"world"}`)) + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil) + req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil)) resp := httptest.NewRecorder() - newService(s, true).Get(resp, req) + newService(announcement.NewStore(kv), true).Get(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) var got announcement.Announcement @@ -122,7 +144,7 @@ var _ = Describe("Service", func() { req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil)) resp := httptest.NewRecorder() - newService(newStore(), false).Get(resp, req) + newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Get(resp, req) Expect(resp.Code).To(Equal(http.StatusForbidden)) }) @@ -130,34 +152,31 @@ var _ = Describe("Service", func() { Describe("Set", func() { It("persists the message when permitted", func() { - s := newStore() + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Put(mock.Anything, "announcement", mock.Anything).Return(uint64(1), nil) + req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`))) resp := httptest.NewRecorder() - newService(s, true).Set(resp, req) + newService(announcement.NewStore(kv), true).Set(resp, req) Expect(resp.Code).To(Equal(http.StatusNoContent)) - got, _ := s.Get() - Expect(got.BannerText).To(Equal("hello")) }) It("is forbidden without permission", func() { - s := newStore() req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`))) resp := httptest.NewRecorder() - newService(s, false).Set(resp, req) + newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Set(resp, req) Expect(resp.Code).To(Equal(http.StatusForbidden)) - got, _ := s.Get() - Expect(got.BannerText).To(BeEmpty()) }) It("rejects an invalid body", func() { req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`not json`))) resp := httptest.NewRecorder() - newService(newStore(), true).Set(resp, req) + newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), true).Set(resp, req) Expect(resp.Code).To(Equal(http.StatusBadRequest)) }) @@ -167,27 +186,22 @@ var _ = Describe("Service", func() { req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(body))) resp := httptest.NewRecorder() - s := newStore() - newService(s, true).Set(resp, req) + newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), true).Set(resp, req) Expect(resp.Code).To(Equal(http.StatusRequestEntityTooLarge)) - got, _ := s.Get() - Expect(got.BannerText).To(BeEmpty()) }) }) Describe("Set with an empty banner text", func() { It("removes the stored announcement", func() { - s := newStore() - Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello"})).To(Succeed()) + kv := mocks.NewKeyValue(GinkgoT()) + kv.EXPECT().Delete(mock.Anything, "announcement").Return(nil) req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"enabled":false,"bannerText":"","infoText":""}`))) resp := httptest.NewRecorder() - newService(s, true).Set(resp, req) + newService(announcement.NewStore(kv), true).Set(resp, req) Expect(resp.Code).To(Equal(http.StatusNoContent)) - got, _ := s.Get() - Expect(got.BannerText).To(BeEmpty()) }) }) }) diff --git a/services/web/pkg/announcement/service.go b/services/web/pkg/announcement/service.go index f601073da5..d20512eb18 100644 --- a/services/web/pkg/announcement/service.go +++ b/services/web/pkg/announcement/service.go @@ -118,7 +118,7 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) { return } - a, err := s.store.Get() + a, err := s.store.Get(r.Context()) if err != nil { s.logError(r, err, "could not read announcement from store") w.WriteHeader(http.StatusInternalServerError) @@ -177,12 +177,12 @@ func (s Service) Set(w http.ResponseWriter, r *http.Request) { // an announcement without a banner text is nothing to show, so remove it entirely if body.BannerText == "" { - if err := s.store.Delete(); err != nil { + if err := s.store.Delete(r.Context()); err != nil { s.logError(r, err, "could not delete announcement from store") w.WriteHeader(http.StatusInternalServerError) return } - } else if err := s.store.Set(body); err != nil { + } else if err := s.store.Set(r.Context(), body); err != nil { s.logError(r, err, "could not write announcement to store") w.WriteHeader(http.StatusInternalServerError) return diff --git a/services/web/pkg/config/config.go b/services/web/pkg/config/config.go index d40f438b5e..b643c56f8c 100644 --- a/services/web/pkg/config/config.go +++ b/services/web/pkg/config/config.go @@ -2,7 +2,6 @@ package config import ( "context" - "time" "github.com/opencloud-eu/opencloud/pkg/shared" ) @@ -30,18 +29,16 @@ type Config struct { Context context.Context `yaml:"-"` } -// Store configures the persistent store used to keep runtime managed web settings, e.g. the announcement banner. +// Store configures the NATS JetStream key-value store used to keep runtime managed web settings, +// e.g. the announcement banner. type Store struct { - Store string `yaml:"store" env:"OC_PERSISTENT_STORE;WEB_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"%%NEXT%%"` - Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` - Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"%%NEXT%%"` - Table string `yaml:"table" env:"WEB_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"%%NEXT%%"` - TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;WEB_STORE_TTL" desc:"Time to live for entries in the store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` - AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"` - AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"` - EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"` - TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"` - TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"` + Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the NATS JetStream store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The bucket name the store should use." introductionVersion:"%%NEXT%%"` + AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store." introductionVersion:"%%NEXT%%"` + AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store." introductionVersion:"%%NEXT%%"` + EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store." introductionVersion:"%%NEXT%%"` + TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"` + TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"` } // Asset defines the available asset configuration. diff --git a/services/web/pkg/config/defaults/defaultconfig.go b/services/web/pkg/config/defaults/defaultconfig.go index 451e43ce6e..0460bcef26 100644 --- a/services/web/pkg/config/defaults/defaultconfig.go +++ b/services/web/pkg/config/defaults/defaultconfig.go @@ -86,11 +86,8 @@ func DefaultConfig() *config.Config { }, GatewayAddress: "eu.opencloud.api.gateway", Store: config.Store{ - Store: "nats-js-kv", Nodes: []string{"127.0.0.1:9233"}, Database: "web", - Table: "", - TTL: 0, }, Web: config.Web{ ThemeServer: "https://localhost:9200", diff --git a/services/web/pkg/server/http/server.go b/services/web/pkg/server/http/server.go index eb393f31ac..13c21ec8aa 100644 --- a/services/web/pkg/server/http/server.go +++ b/services/web/pkg/server/http/server.go @@ -6,9 +6,8 @@ import ( chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" - "github.com/opencloud-eu/reva/v2/pkg/store" + "github.com/opencloud-eu/reva/v2/pkg/storage/cache" "go-micro.dev/v4" - microstore "go-micro.dev/v4/store" "github.com/opencloud-eu/opencloud/pkg/cors" "github.com/opencloud-eu/opencloud/pkg/middleware" @@ -79,18 +78,20 @@ func Server(opts ...Option) (http.Service, error) { fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"), ) - // persistent store for runtime managed web settings, e.g. the announcement banner - announcementStore := announcement.NewStore(store.Create( - store.Store(options.Config.Store.Store), - store.TTL(options.Config.Store.TTL), - microstore.Nodes(options.Config.Store.Nodes...), - microstore.Database(options.Config.Store.Database), - microstore.Table(options.Config.Store.Table), - store.Authentication(options.Config.Store.AuthUsername, options.Config.Store.AuthPassword), - store.TLSEnabled(options.Config.Store.EnableTLS), - store.TLSInsecure(options.Config.Store.TLSInsecure), - store.TLSRootCA(options.Config.Store.TLSRootCACertificate), - )) + // NATS JetStream key-value store for runtime managed web settings, e.g. the announcement banner + kv, err := cache.NewNatsKeyValue(cache.Config{ + Nodes: options.Config.Store.Nodes, + Database: options.Config.Store.Database, + AuthUsername: options.Config.Store.AuthUsername, + AuthPassword: options.Config.Store.AuthPassword, + TLSEnabled: options.Config.Store.EnableTLS, + TLSInsecure: options.Config.Store.TLSInsecure, + TLSRootCACertificate: options.Config.Store.TLSRootCACertificate, + }, &options.Logger.Logger) + if err != nil { + return http.Service{}, fmt.Errorf("could not initialize announcement store: %w", err) + } + announcementStore := announcement.NewStore(kv) handle, err := svc.NewService( svc.Logger(options.Logger), diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index f1ed45f372..9db9ac23c8 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -1,6 +1,7 @@ package svc import ( + "context" "encoding/json" "io/fs" "net/http" @@ -136,7 +137,7 @@ func (p Web) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.mux.ServeHTTP(w, r) } -func (p Web) getPayload() (payload []byte, err error) { +func (p Web) getPayload(ctx context.Context) (payload []byte, err error) { // render dynamically using a copy of the config, so per-request values (e.g. the // announcement) are not written into the shared config concurrently. webConfig := p.config.Web.Config @@ -160,7 +161,7 @@ func (p Web) getPayload() (payload []byte, err error) { // the runtime store is authoritative once it manages an announcement (enabled or explicitly // disabled); only when it holds nothing do we keep a statically configured one // (web.config.options.announcement) - if a, managed := p.managedAnnouncement(); managed { + if a, managed := p.managedAnnouncement(ctx); managed { webConfig.Options.Announcement = a } @@ -171,12 +172,12 @@ func (p Web) getPayload() (payload []byte, err error) { // bool reports whether the store manages one at all: when true, the returned value (nil for an // explicitly disabled announcement) overrides any static config; when false, the store holds // nothing and a statically configured announcement is kept. -func (p Web) managedAnnouncement() (*config.Announcement, bool) { +func (p Web) managedAnnouncement(ctx context.Context) (*config.Announcement, bool) { if p.announcementStore == nil { return nil, false } - a, err := p.announcementStore.Get() + a, err := p.announcementStore.Get(ctx) if err != nil { p.logger.Error().Err(err).Msg("could not read announcement from store") return nil, false @@ -197,8 +198,8 @@ func (p Web) managedAnnouncement() (*config.Announcement, bool) { } // Config implements the Service interface. -func (p Web) Config(w http.ResponseWriter, _ *http.Request) { - payload, err := p.getPayload() +func (p Web) Config(w http.ResponseWriter, r *http.Request) { + payload, err := p.getPayload(r.Context()) if err != nil { http.Error(w, ErrConfigInvalid, http.StatusUnprocessableEntity) return diff --git a/services/web/pkg/service/v0/service_test.go b/services/web/pkg/service/v0/service_test.go index 9a983f0bca..e4cc1704c2 100644 --- a/services/web/pkg/service/v0/service_test.go +++ b/services/web/pkg/service/v0/service_test.go @@ -1,56 +1,67 @@ package svc import ( + "context" "testing" - "github.com/opencloud-eu/reva/v2/pkg/store" + "github.com/nats-io/nats.go/jetstream" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/web/mocks" "github.com/opencloud-eu/opencloud/services/web/pkg/announcement" "github.com/opencloud-eu/opencloud/services/web/pkg/config" ) func TestManagedAnnouncement(t *testing.T) { - newWeb := func(a *announcement.Store) Web { - return Web{logger: log.NopLogger(), announcementStore: a} + newWeb := func(store *announcement.Store) Web { + return Web{logger: log.NopLogger(), announcementStore: store} } - newStore := func() *announcement.Store { - return announcement.NewStore(store.Create(store.Store("memory"))) + // storeReturning builds a store whose backing bucket returns the given JSON for the announcement key. + storeReturning := func(t *testing.T, value string) *announcement.Store { + entry := mocks.NewKeyValueEntry(t) + entry.EXPECT().Value().Return([]byte(value)) + kv := mocks.NewKeyValue(t) + kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil) + return announcement.NewStore(kv) + } + // emptyStore builds a store whose backing bucket has no announcement. + emptyStore := func(t *testing.T) *announcement.Store { + kv := mocks.NewKeyValue(t) + kv.EXPECT().Get(mock.Anything, "announcement").Return(nil, jetstream.ErrKeyNotFound) + return announcement.NewStore(kv) } t.Run("not managed when there is no store, so a static config is kept", func(t *testing.T) { - a, managed := newWeb(nil).managedAnnouncement() + a, managed := newWeb(nil).managedAnnouncement(context.Background()) require.Nil(t, a) require.False(t, managed) }) t.Run("not managed when the store is empty, so a static config is kept", func(t *testing.T) { - a, managed := newWeb(newStore()).managedAnnouncement() + a, managed := newWeb(emptyStore(t)).managedAnnouncement(context.Background()) require.Nil(t, a) require.False(t, managed) }) t.Run("managed but nil when disabled, so a static config is cleared", func(t *testing.T) { - s := newStore() - require.NoError(t, s.Set(announcement.Announcement{Enabled: false, BannerText: "hi", InfoText: "info"})) - a, managed := newWeb(s).managedAnnouncement() + s := storeReturning(t, `{"enabled":false,"bannerText":"hi","infoText":"info"}`) + a, managed := newWeb(s).managedAnnouncement(context.Background()) require.Nil(t, a) require.True(t, managed) }) t.Run("not managed when enabled but the banner text is empty", func(t *testing.T) { - s := newStore() - require.NoError(t, s.Set(announcement.Announcement{Enabled: true, InfoText: "info"})) - a, managed := newWeb(s).managedAnnouncement() + s := storeReturning(t, `{"enabled":true,"bannerText":"","infoText":"info"}`) + a, managed := newWeb(s).managedAnnouncement(context.Background()) require.Nil(t, a) require.False(t, managed) }) t.Run("managed with banner and info text when enabled with a banner text", func(t *testing.T) { - s := newStore() - require.NoError(t, s.Set(announcement.Announcement{Enabled: true, BannerText: "hi", InfoText: "info"})) - a, managed := newWeb(s).managedAnnouncement() + s := storeReturning(t, `{"enabled":true,"bannerText":"hi","infoText":"info"}`) + a, managed := newWeb(s).managedAnnouncement(context.Background()) require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, a) require.True(t, managed) }) From d27b7ef40a2f70eace32163071712084070743d9 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 11:52:09 +0200 Subject: [PATCH 08/10] refactor: drop static config for the announcement, runtime store only A statically configured announcement is not supported: it cannot be removed via the runtime API and disappears from the running session once the admin settings load (they read the store, not config.json). Make the store the single source of truth, config.json always reflects it and a statically configured options.announcement is ignored. --- services/web/pkg/config/options.go | 4 ++- services/web/pkg/service/v0/service.go | 35 +++++++-------------- services/web/pkg/service/v0/service_test.go | 32 +++++++------------ 3 files changed, 26 insertions(+), 45 deletions(-) diff --git a/services/web/pkg/config/options.go b/services/web/pkg/config/options.go index cb47976928..bc4f4781aa 100644 --- a/services/web/pkg/config/options.go +++ b/services/web/pkg/config/options.go @@ -24,7 +24,9 @@ type Options struct { OxAppSuite *OxAppSuite `json:"oxAppSuite,omitempty" yaml:"oxAppSuite"` } -// Announcement is a banner message shown above the top bar to all users. +// Announcement is a banner message shown above the top bar to all users. It is managed at runtime +// (via the web service's store and the admin settings UI) and injected into config.json here; a +// value configured statically is ignored. type Announcement struct { // BannerText is the short line shown in the banner. BannerText string `json:"bannerText,omitempty" yaml:"bannerText"` diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index 9db9ac23c8..5454c15c07 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -158,43 +158,32 @@ func (p Web) getPayload(ctx context.Context) (payload []byte, err error) { // ensure that the server url has a trailing slash webConfig.Server = strings.TrimRight(webConfig.Server, "/") + "/" - // the runtime store is authoritative once it manages an announcement (enabled or explicitly - // disabled); only when it holds nothing do we keep a statically configured one - // (web.config.options.announcement) - if a, managed := p.managedAnnouncement(ctx); managed { - webConfig.Options.Announcement = a - } + // the runtime store is the single source of truth for the announcement banner: expose it + // when live, clear it otherwise. A statically configured value is not supported. + webConfig.Options.Announcement = p.currentAnnouncement(ctx) return json.Marshal(webConfig) } -// managedAnnouncement returns the announcement the runtime store manages for config.json. The -// bool reports whether the store manages one at all: when true, the returned value (nil for an -// explicitly disabled announcement) overrides any static config; when false, the store holds -// nothing and a statically configured announcement is kept. -func (p Web) managedAnnouncement(ctx context.Context) (*config.Announcement, bool) { +// currentAnnouncement returns the stored announcement for config.json, or nil if unset, disabled +// or unavailable. +func (p Web) currentAnnouncement(ctx context.Context) *config.Announcement { if p.announcementStore == nil { - return nil, false + return nil } a, err := p.announcementStore.Get(ctx) if err != nil { p.logger.Error().Err(err).Msg("could not read announcement from store") - return nil, false + return nil } - // an announcement without a banner line is nothing to manage (empty/removed store) - if a.BannerText == "" { - return nil, false + // only live (enabled) announcements with a banner line are exposed in the public config.json + if !a.Enabled || a.BannerText == "" { + return nil } - // managed but disabled: hide it, overriding any static config - if !a.Enabled { - return nil, true - } - - // only live (enabled) announcements are exposed in the public config.json - return &config.Announcement{BannerText: a.BannerText, InfoText: a.InfoText}, true + return &config.Announcement{BannerText: a.BannerText, InfoText: a.InfoText} } // Config implements the Service interface. diff --git a/services/web/pkg/service/v0/service_test.go b/services/web/pkg/service/v0/service_test.go index e4cc1704c2..a61b7ec648 100644 --- a/services/web/pkg/service/v0/service_test.go +++ b/services/web/pkg/service/v0/service_test.go @@ -14,7 +14,7 @@ import ( "github.com/opencloud-eu/opencloud/services/web/pkg/config" ) -func TestManagedAnnouncement(t *testing.T) { +func TestCurrentAnnouncement(t *testing.T) { newWeb := func(store *announcement.Store) Web { return Web{logger: log.NopLogger(), announcementStore: store} } @@ -33,36 +33,26 @@ func TestManagedAnnouncement(t *testing.T) { return announcement.NewStore(kv) } - t.Run("not managed when there is no store, so a static config is kept", func(t *testing.T) { - a, managed := newWeb(nil).managedAnnouncement(context.Background()) - require.Nil(t, a) - require.False(t, managed) + t.Run("nil when there is no store", func(t *testing.T) { + require.Nil(t, newWeb(nil).currentAnnouncement(context.Background())) }) - t.Run("not managed when the store is empty, so a static config is kept", func(t *testing.T) { - a, managed := newWeb(emptyStore(t)).managedAnnouncement(context.Background()) - require.Nil(t, a) - require.False(t, managed) + t.Run("nil when the store is empty", func(t *testing.T) { + require.Nil(t, newWeb(emptyStore(t)).currentAnnouncement(context.Background())) }) - t.Run("managed but nil when disabled, so a static config is cleared", func(t *testing.T) { + t.Run("nil when disabled", func(t *testing.T) { s := storeReturning(t, `{"enabled":false,"bannerText":"hi","infoText":"info"}`) - a, managed := newWeb(s).managedAnnouncement(context.Background()) - require.Nil(t, a) - require.True(t, managed) + require.Nil(t, newWeb(s).currentAnnouncement(context.Background())) }) - t.Run("not managed when enabled but the banner text is empty", func(t *testing.T) { + t.Run("nil when enabled but the banner text is empty", func(t *testing.T) { s := storeReturning(t, `{"enabled":true,"bannerText":"","infoText":"info"}`) - a, managed := newWeb(s).managedAnnouncement(context.Background()) - require.Nil(t, a) - require.False(t, managed) + require.Nil(t, newWeb(s).currentAnnouncement(context.Background())) }) - t.Run("managed with banner and info text when enabled with a banner text", func(t *testing.T) { + t.Run("returns banner and info text when enabled with a banner text", func(t *testing.T) { s := storeReturning(t, `{"enabled":true,"bannerText":"hi","infoText":"info"}`) - a, managed := newWeb(s).managedAnnouncement(context.Background()) - require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, a) - require.True(t, managed) + require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, newWeb(s).currentAnnouncement(context.Background())) }) } From 6d2d6c38ec0b7b3bf9ab4cbd0bfcd67beabd480f Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 11:53:58 +0200 Subject: [PATCH 09/10] fix: fail fast when the announcement store is unreachable Replace reva's cache.NewNatsKeyValue (which retries with backoff and can hang the web service startup) with a direct nats.Connect + jetstream setup like the graph service, so an unreachable or misconfigured store surfaces as a clear startup error instead of a hang. --- services/web/pkg/server/http/server.go | 39 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/services/web/pkg/server/http/server.go b/services/web/pkg/server/http/server.go index 13c21ec8aa..0b9621c798 100644 --- a/services/web/pkg/server/http/server.go +++ b/services/web/pkg/server/http/server.go @@ -1,16 +1,20 @@ package http import ( + "errors" "fmt" "path" + "strings" chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" - "github.com/opencloud-eu/reva/v2/pkg/storage/cache" "go-micro.dev/v4" "github.com/opencloud-eu/opencloud/pkg/cors" "github.com/opencloud-eu/opencloud/pkg/middleware" + natspkg "github.com/opencloud-eu/opencloud/pkg/nats" "github.com/opencloud-eu/opencloud/pkg/registry" "github.com/opencloud-eu/opencloud/pkg/service/http" "github.com/opencloud-eu/opencloud/pkg/version" @@ -78,18 +82,29 @@ func Server(opts ...Option) (http.Service, error) { fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"), ) - // NATS JetStream key-value store for runtime managed web settings, e.g. the announcement banner - kv, err := cache.NewNatsKeyValue(cache.Config{ - Nodes: options.Config.Store.Nodes, - Database: options.Config.Store.Database, - AuthUsername: options.Config.Store.AuthUsername, - AuthPassword: options.Config.Store.AuthPassword, - TLSEnabled: options.Config.Store.EnableTLS, - TLSInsecure: options.Config.Store.TLSInsecure, - TLSRootCACertificate: options.Config.Store.TLSRootCACertificate, - }, &options.Logger.Logger) + // NATS JetStream key-value store for runtime managed web settings, e.g. the announcement banner. + // Connect eagerly and fail fast: an unreachable store means the feature would be broken, so a + // clear startup error is preferable to silently degrading. + natsConn, err := nats.Connect( + strings.Join(options.Config.Store.Nodes, ","), + natspkg.Secure(options.Config.Store.EnableTLS, options.Config.Store.TLSInsecure, options.Config.Store.TLSRootCACertificate), + nats.UserInfo(options.Config.Store.AuthUsername, options.Config.Store.AuthPassword), + ) if err != nil { - return http.Service{}, fmt.Errorf("could not initialize announcement store: %w", err) + return http.Service{}, fmt.Errorf("could not connect to nats for the announcement store: %w", err) + } + js, err := jetstream.New(natsConn) + if err != nil { + return http.Service{}, fmt.Errorf("could not create jetstream context for the announcement store: %w", err) + } + kv, err := js.KeyValue(options.Context, options.Config.Store.Database) + if err != nil { + if !errors.Is(err, jetstream.ErrBucketNotFound) { + return http.Service{}, fmt.Errorf("could not open the announcement store bucket %q: %w", options.Config.Store.Database, err) + } + if kv, err = js.CreateKeyValue(options.Context, jetstream.KeyValueConfig{Bucket: options.Config.Store.Database}); err != nil { + return http.Service{}, fmt.Errorf("could not create the announcement store bucket %q: %w", options.Config.Store.Database, err) + } } announcementStore := announcement.NewStore(kv) From 341ebdf6cc7414ee8cdb7b1d076e29d0e2232d6d Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 12:03:57 +0200 Subject: [PATCH 10/10] refactor: mark the announcement config as output-only (yaml:"-") The announcement is runtime-managed and injected into config.json; it is not a static yaml config option. Exclude it from yaml (yaml:"-") so it is not presented as a configurable knob, keeping the json tag only for the config.json output. --- services/web/pkg/config/options.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/pkg/config/options.go b/services/web/pkg/config/options.go index bc4f4781aa..3a4f037ed9 100644 --- a/services/web/pkg/config/options.go +++ b/services/web/pkg/config/options.go @@ -2,7 +2,7 @@ package config // Options are the option for the web type Options struct { - Announcement *Announcement `json:"announcement,omitempty" yaml:"announcement"` + Announcement *Announcement `json:"announcement,omitempty" yaml:"-"` AccountEditLink *AccountEditLink `json:"accountEditLink,omitempty" yaml:"accountEditLink"` DisableFeedbackLink bool `json:"disableFeedbackLink,omitempty" yaml:"disableFeedbackLink" env:"WEB_OPTION_DISABLE_FEEDBACK_LINK" desc:"Set this option to 'true' to disable the feedback link in the top bar. Keeping it enabled by setting the value to 'false' or with the absence of the option, allows OpenCloud to get feedback from your user base through a dedicated survey website." introductionVersion:"1.0.0"` DisableSponsorLink bool `json:"disableSponsorLink,omitempty" yaml:"disableSponsorLink" env:"WEB_OPTION_DISABLE_SPONSOR_LINK" desc:"Set this option to 'true' to disable the sponsor link in the left sidebar. Keeping it enabled by setting the value to 'false' or by leaving the option unset allows OpenCloud to get support from the community through a dedicated sponsorship program on GitHub." introductionVersion:"7.3.0"` @@ -29,9 +29,9 @@ type Options struct { // value configured statically is ignored. type Announcement struct { // BannerText is the short line shown in the banner. - BannerText string `json:"bannerText,omitempty" yaml:"bannerText"` + BannerText string `json:"bannerText,omitempty" yaml:"-"` // InfoText is the (Markdown) detail shown in a dialog when the banner is clicked. - InfoText string `json:"infoText,omitempty" yaml:"infoText"` + InfoText string `json:"infoText,omitempty" yaml:"-"` } // AccountEditLink are the AccountEditLink options