mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-08-01 02:11:15 -04:00
Merge pull request #3189 from opencloud-eu/feat/announcement-banner
feat: add announcement banner
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
|
||||
14
services/web/.mockery.yaml
Normal file
14
services/web/.mockery.yaml
Normal 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: {}
|
||||
1363
services/web/mocks/key_value.go
Normal file
1363
services/web/mocks/key_value.go
Normal file
File diff suppressed because it is too large
Load Diff
349
services/web/mocks/key_value_entry.go
Normal file
349
services/web/mocks/key_value_entry.go
Normal 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
|
||||
}
|
||||
69
services/web/pkg/announcement/announcement.go
Normal file
69
services/web/pkg/announcement/announcement.go
Normal 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
|
||||
}
|
||||
13
services/web/pkg/announcement/announcement_suite_test.go
Normal file
13
services/web/pkg/announcement/announcement_suite_test.go
Normal 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")
|
||||
}
|
||||
207
services/web/pkg/announcement/announcement_test.go
Normal file
207
services/web/pkg/announcement/announcement_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
192
services/web/pkg/announcement/service.go
Normal file
192
services/web/pkg/announcement/service.go
Normal 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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
58
services/web/pkg/service/v0/service_test.go
Normal file
58
services/web/pkg/service/v0/service_test.go
Normal 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()))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user