Merge pull request #3189 from opencloud-eu/feat/announcement-banner

feat: add announcement banner
This commit is contained in:
Dominik Schmidt
2026-07-30 10:44:17 +02:00
committed by GitHub
17 changed files with 2436 additions and 27 deletions

View File

@@ -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",

View File

@@ -80,6 +80,7 @@ func ServiceAccountBundle() *settingsmsg.Bundle {
},
Settings: []*settingsmsg.Setting{
AccountManagementPermission(All),
AnnouncementReadWritePermission(All),
ChangeLogoPermission(All),
CollaborationPublishNotificationPermission(All),
CollaborationManageFontsPermission(All),
@@ -117,6 +118,7 @@ func generateBundleAdminRole() *settingsmsg.Bundle {
},
Settings: []*settingsmsg.Setting{
AccountManagementPermission(All),
AnnouncementReadWritePermission(All),
AutoAcceptSharesPermission(Own),
ChangeLogoPermission(All),
CollaborationPublishNotificationPermission(All),

View File

@@ -29,6 +29,25 @@ func AccountManagementPermission(c settingsmsg.Permission_Constraint) *settingsm
}
}
// 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.ReadWrite",
DisplayName: "Manage announcement",
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_READWRITE,
Constraint: c,
},
},
}
}
// AutoAcceptSharesPermission is the permission to enable share auto-accept
func AutoAcceptSharesPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting {
return &settingsmsg.Setting{

View File

@@ -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: {}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -0,0 +1,69 @@
// Package announcement persists and serves the web announcement banner.
package announcement
import (
"context"
"encoding/json"
"errors"
"github.com/nats-io/nats.go/jetstream"
)
// _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 NATS JetStream key-value bucket.
type Store struct {
kv jetstream.KeyValue
}
// 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(ctx context.Context) (Announcement, error) {
var a Announcement
entry, err := s.kv.Get(ctx, _storeKey)
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return a, nil
}
return a, err
}
if err := json.Unmarshal(entry.Value(), &a); err != nil {
return a, err
}
return a, nil
}
// Set persists the given announcement, overwriting any existing one.
func (s *Store) Set(ctx context.Context, a Announcement) error {
value, err := json.Marshal(a)
if err != nil {
return err
}
_, 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(ctx context.Context) error {
if err := s.kv.Delete(ctx, _storeKey); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return err
}
return nil
}

View File

@@ -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")
}

View File

@@ -0,0 +1,207 @@
package announcement_test
import (
"context"
"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/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"
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 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{}.
WithLogger(log.NopLogger()).
WithStore(store).
WithGatewaySelector(newGatewaySelector(allowed)))
Expect(err).ToNot(HaveOccurred())
return svc
}
var _ = Describe("Store", func() {
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 := 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"))
})
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() {
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Delete(mock.Anything, "announcement").Return(jetstream.ErrKeyNotFound)
Expect(announcement.NewStore(kv).Delete(context.Background())).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(announcement.NewStore(mocks.NewKeyValue(GinkgoT()))).
WithGatewaySelector(newGatewaySelector(true)))
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Get", func() {
It("returns the full stored announcement when permitted", 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)
req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil))
resp := httptest.NewRecorder()
newService(announcement.NewStore(kv), 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(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Get(resp, req)
Expect(resp.Code).To(Equal(http.StatusForbidden))
})
})
Describe("Set", func() {
It("persists the message when permitted", func() {
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(announcement.NewStore(kv), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusNoContent))
})
It("is forbidden without permission", func() {
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`)))
resp := httptest.NewRecorder()
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusForbidden))
})
It("rejects an invalid body", func() {
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`not json`)))
resp := httptest.NewRecorder()
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), 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()
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusRequestEntityTooLarge))
})
})
Describe("Set with an empty banner text", func() {
It("removes the stored announcement", func() {
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(announcement.NewStore(kv), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusNoContent))
})
})
})

View File

@@ -0,0 +1,192 @@
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"
"github.com/opencloud-eu/opencloud/pkg/log"
)
// _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.
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
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 {
logger log.Logger
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{
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
}
user, ok := revactx.ContextGetUser(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
Permission: _permission,
SubjectRef: &permissionsapi.SubjectReference{
Spec: &permissionsapi.SubjectReference_UserId{
UserId: user.GetId(),
},
},
})
if err != nil {
s.logError(r, err, "could not check permission")
w.WriteHeader(http.StatusInternalServerError)
return
}
if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
w.WriteHeader(http.StatusForbidden)
return
}
a, err := s.store.Get(r.Context())
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)
}
}
// 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 {
s.logError(r, err, "could not select next gateway client")
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: _permission,
SubjectRef: &permissionsapi.SubjectReference{
Spec: &permissionsapi.SubjectReference_UserId{
UserId: user.GetId(),
},
},
})
if err != nil {
s.logError(r, err, "could not check permission")
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 {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
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(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(r.Context(), body); err != nil {
s.logError(r, err, "could not write announcement to store")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -25,9 +25,22 @@ 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 NATS JetStream key-value store used to keep runtime managed web settings,
// e.g. the announcement banner.
type Store struct {
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.
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"`

View File

@@ -85,6 +85,10 @@ func DefaultConfig() *config.Config {
ThemesPath: filepath.Join(defaults.BaseDataPath(), "web/assets/themes"),
},
GatewayAddress: "eu.opencloud.api.gateway",
Store: config.Store{
Nodes: []string{"127.0.0.1:9233"},
Database: "web",
},
Web: config.Web{
ThemeServer: "https://localhost:9200",
ThemePath: "/themes/opencloud/theme.json",

View File

@@ -2,6 +2,7 @@ package config
// Options are the option for the web
type Options struct {
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"`
@@ -23,6 +24,16 @@ type Options struct {
OxAppSuite *OxAppSuite `json:"oxAppSuite,omitempty" yaml:"oxAppSuite"`
}
// 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:"-"`
// InfoText is the (Markdown) detail shown in a dialog when the banner is clicked.
InfoText string `json:"infoText,omitempty" yaml:"-"`
}
// 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"`

View File

@@ -1,20 +1,26 @@
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"
"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"
"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 +82,38 @@ 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.
// 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 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)
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),

View File

@@ -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) {

View File

@@ -1,6 +1,7 @@
package svc
import (
"context"
"encoding/json"
"io/fs"
"net/http"
@@ -19,6 +20,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 +52,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 +68,16 @@ func NewService(opts ...Option) (Service, error) {
return svc, err
}
announcementService, err := announcement.NewService(
announcement.ServiceOptions{}.
WithLogger(options.Logger).
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 +88,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 +125,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.
@@ -115,31 +137,58 @@ func (p Web) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.mux.ServeHTTP(w, r)
}
func (p Web) getPayload() (payload []byte, err error) {
// render dynamically using config
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
// 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)
// 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)
}
// 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
}
a, err := p.announcementStore.Get(ctx)
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.
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

View File

@@ -0,0 +1,58 @@
package svc
import (
"context"
"testing"
"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 TestCurrentAnnouncement(t *testing.T) {
newWeb := func(store *announcement.Store) Web {
return Web{logger: log.NopLogger(), announcementStore: store}
}
// 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("nil when there is no store", func(t *testing.T) {
require.Nil(t, newWeb(nil).currentAnnouncement(context.Background()))
})
t.Run("nil when the store is empty", func(t *testing.T) {
require.Nil(t, newWeb(emptyStore(t)).currentAnnouncement(context.Background()))
})
t.Run("nil when disabled", func(t *testing.T) {
s := storeReturning(t, `{"enabled":false,"bannerText":"hi","infoText":"info"}`)
require.Nil(t, newWeb(s).currentAnnouncement(context.Background()))
})
t.Run("nil when enabled but the banner text is empty", func(t *testing.T) {
s := storeReturning(t, `{"enabled":true,"bannerText":"","infoText":"info"}`)
require.Nil(t, newWeb(s).currentAnnouncement(context.Background()))
})
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"}`)
require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, newWeb(s).currentAnnouncement(context.Background()))
})
}