mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-27 00:29:04 -04:00
feat: add announcement banner
This commit is contained in:
1 parent
d9e406ca99
commit
51dd107767
14 files changed
+644
-24
No files matched your search
@@ -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),
|
||||
AnnouncementWritePermission(All),
|
||||
ChangeLogoPermission(All),
|
||||
CollaborationPublishNotificationPermission(All),
|
||||
CollaborationManageFontsPermission(All),
|
||||
@@ -117,6 +118,7 @@ func generateBundleAdminRole() *settingsmsg.Bundle {
|
||||
},
|
||||
Settings: []*settingsmsg.Setting{
|
||||
AccountManagementPermission(All),
|
||||
AnnouncementWritePermission(All),
|
||||
AutoAcceptSharesPermission(Own),
|
||||
ChangeLogoPermission(All),
|
||||
CollaborationPublishNotificationPermission(All),
|
||||
|
||||
@@ -29,6 +29,25 @@ func AccountManagementPermission(c settingsmsg.Permission_Constraint) *settingsm
|
||||
}
|
||||
}
|
||||
|
||||
// AnnouncementWritePermission is the permission to manage the web announcement banner
|
||||
func AnnouncementWritePermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting {
|
||||
return &settingsmsg.Setting{
|
||||
Id: "52b1994b-1bdb-4c8d-a887-1967dbe8cb11",
|
||||
Name: "Announcement.Write",
|
||||
DisplayName: "Manage announcement",
|
||||
Description: "This permission permits to manage the announcement banner shown to all users.",
|
||||
Resource: &settingsmsg.Resource{
|
||||
Type: settingsmsg.Resource_TYPE_SYSTEM,
|
||||
},
|
||||
Value: &settingsmsg.Setting_PermissionValue{
|
||||
PermissionValue: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_WRITE,
|
||||
Constraint: c,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AutoAcceptSharesPermission is the permission to enable share auto-accept
|
||||
func AutoAcceptSharesPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting {
|
||||
return &settingsmsg.Setting{
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package announcement persists and serves the web announcement banner.
|
||||
package announcement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
microstore "go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
// _storeKey is the single key under which the announcement is persisted.
|
||||
const _storeKey = "announcement"
|
||||
|
||||
// Announcement is a banner message shown above the top bar to all users.
|
||||
type Announcement struct {
|
||||
// Enabled controls whether the announcement is live (injected into config.json).
|
||||
Enabled bool `json:"enabled"`
|
||||
BannerText string `json:"bannerText"`
|
||||
InfoText string `json:"infoText"`
|
||||
}
|
||||
|
||||
// Store persists a single announcement in a key-value store.
|
||||
type Store struct {
|
||||
store microstore.Store
|
||||
}
|
||||
|
||||
// NewStore returns a new announcement Store backed by the given key-value store.
|
||||
func NewStore(s microstore.Store) *Store {
|
||||
return &Store{store: s}
|
||||
}
|
||||
|
||||
// Get returns the currently stored announcement. An unset announcement is returned as the zero value.
|
||||
func (s *Store) Get() (Announcement, error) {
|
||||
var a Announcement
|
||||
|
||||
records, err := s.store.Read(_storeKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, microstore.ErrNotFound) {
|
||||
return a, nil
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(records[0].Value, &a); err != nil {
|
||||
return a, err
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Set persists the given announcement, overwriting any existing one.
|
||||
func (s *Store) Set(a Announcement) error {
|
||||
value, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.Write(µstore.Record{
|
||||
Key: _storeKey,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes the stored announcement. Deleting a missing announcement is a no-op.
|
||||
func (s *Store) Delete() error {
|
||||
if err := s.store.Delete(_storeKey); err != nil && !errors.Is(err, microstore.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package announcement_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
cs3permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/store"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
|
||||
)
|
||||
|
||||
func newStore() *announcement.Store {
|
||||
return announcement.NewStore(store.Create(store.Store("memory")))
|
||||
}
|
||||
|
||||
func newGatewaySelector(allowed bool) pool.Selectable[gateway.GatewayAPIClient] {
|
||||
code := cs3rpc.Code_CODE_OK
|
||||
name := "announcement-test-allowed"
|
||||
if !allowed {
|
||||
code = cs3rpc.Code_CODE_PERMISSION_DENIED
|
||||
name = "announcement-test-denied"
|
||||
}
|
||||
|
||||
client := &cs3mocks.GatewayAPIClient{}
|
||||
client.On("CheckPermission", mock.Anything, mock.Anything).Return(
|
||||
&cs3permissions.CheckPermissionResponse{Status: &cs3rpc.Status{Code: code}}, nil)
|
||||
|
||||
// pool.GetSelector caches by name, so allow/deny must use distinct names
|
||||
return pool.GetSelector[gateway.GatewayAPIClient](
|
||||
name,
|
||||
"eu.opencloud.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient { return client },
|
||||
)
|
||||
}
|
||||
|
||||
func withUser(r *http.Request) *http.Request {
|
||||
return r.WithContext(revactx.ContextSetUser(r.Context(), &userpb.User{
|
||||
Id: &userpb.UserId{OpaqueId: "user"},
|
||||
}))
|
||||
}
|
||||
|
||||
func newService(store *announcement.Store, allowed bool) announcement.Service {
|
||||
svc, err := announcement.NewService(announcement.ServiceOptions{}.
|
||||
WithStore(store).
|
||||
WithGatewaySelector(newGatewaySelector(allowed)))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return svc
|
||||
}
|
||||
|
||||
var _ = Describe("Store", func() {
|
||||
It("gets, sets and deletes the announcement", func() {
|
||||
s := newStore()
|
||||
|
||||
got, err := s.Get()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.BannerText).To(BeEmpty())
|
||||
|
||||
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed())
|
||||
got, err = s.Get()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.Enabled).To(BeTrue())
|
||||
Expect(got.BannerText).To(Equal("hello"))
|
||||
Expect(got.InfoText).To(Equal("world"))
|
||||
|
||||
Expect(s.Delete()).To(Succeed())
|
||||
got, err = s.Get()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.BannerText).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("treats deleting a missing announcement as a no-op", func() {
|
||||
Expect(newStore().Delete()).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Service", func() {
|
||||
Describe("NewService", func() {
|
||||
It("fails when options are missing", func() {
|
||||
_, err := announcement.NewService(announcement.ServiceOptions{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("succeeds when options are valid", func() {
|
||||
_, err := announcement.NewService(announcement.ServiceOptions{}.
|
||||
WithStore(newStore()).
|
||||
WithGatewaySelector(newGatewaySelector(true)))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns the full stored announcement when permitted", func() {
|
||||
s := newStore()
|
||||
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed())
|
||||
req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
newService(s, true).Get(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var got announcement.Announcement
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &got)).To(Succeed())
|
||||
Expect(got.Enabled).To(BeTrue())
|
||||
Expect(got.BannerText).To(Equal("hello"))
|
||||
Expect(got.InfoText).To(Equal("world"))
|
||||
})
|
||||
|
||||
It("is forbidden without permission", func() {
|
||||
req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
newService(newStore(), false).Get(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Set", func() {
|
||||
It("persists the message when permitted", func() {
|
||||
s := newStore()
|
||||
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`)))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
newService(s, true).Set(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusNoContent))
|
||||
got, _ := s.Get()
|
||||
Expect(got.BannerText).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("is forbidden without permission", func() {
|
||||
s := newStore()
|
||||
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`)))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
newService(s, false).Set(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusForbidden))
|
||||
got, _ := s.Get()
|
||||
Expect(got.BannerText).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects an invalid body", func() {
|
||||
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`not json`)))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
newService(newStore(), true).Set(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("rejects an oversized body", func() {
|
||||
body := `{"bannerText":"` + strings.Repeat("a", 300000) + `"}`
|
||||
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(body)))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
s := newStore()
|
||||
newService(s, true).Set(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusBadRequest))
|
||||
got, _ := s.Get()
|
||||
Expect(got.BannerText).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Set with an empty banner text", func() {
|
||||
It("removes the stored announcement", func() {
|
||||
s := newStore()
|
||||
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello"})).To(Succeed())
|
||||
|
||||
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"enabled":false,"bannerText":"","infoText":""}`)))
|
||||
resp := httptest.NewRecorder()
|
||||
newService(s, true).Set(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusNoContent))
|
||||
got, _ := s.Get()
|
||||
Expect(got.BannerText).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
package announcement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
// _writePermission is the settings permission required to manage the announcement.
|
||||
const _writePermission = "Announcement.Write"
|
||||
|
||||
// _maxBodySize caps the announcement request body. The info text is Markdown and ends up in
|
||||
// the public config.json that every client loads on bootstrap, so it must stay small.
|
||||
const _maxBodySize = 50 << 10 // 50 KiB
|
||||
|
||||
// ServiceOptions defines the options to configure the Service.
|
||||
type ServiceOptions struct {
|
||||
store *Store
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// WithStore sets the announcement store.
|
||||
func (o ServiceOptions) WithStore(s *Store) ServiceOptions {
|
||||
o.store = s
|
||||
return o
|
||||
}
|
||||
|
||||
// WithGatewaySelector sets the gateway selector.
|
||||
func (o ServiceOptions) WithGatewaySelector(gws pool.Selectable[gateway.GatewayAPIClient]) ServiceOptions {
|
||||
o.gatewaySelector = gws
|
||||
return o
|
||||
}
|
||||
|
||||
// validate validates the input parameters.
|
||||
func (o ServiceOptions) validate() error {
|
||||
if o.store == nil {
|
||||
return errors.New("store is required")
|
||||
}
|
||||
|
||||
if o.gatewaySelector == nil {
|
||||
return errors.New("gatewaySelector is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service exposes the http handlers to manage the announcement.
|
||||
type Service struct {
|
||||
store *Store
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// NewService initializes a new Service.
|
||||
func NewService(options ServiceOptions) (Service, error) {
|
||||
if err := options.validate(); err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
|
||||
return Service{
|
||||
store: options.store,
|
||||
gatewaySelector: options.gatewaySelector,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the full stored announcement (including disabled ones) for management.
|
||||
func (s Service) Get(w http.ResponseWriter, r *http.Request) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: _writePermission,
|
||||
SubjectRef: &permissionsapi.SubjectReference{
|
||||
Spec: &permissionsapi.SubjectReference_UserId{
|
||||
UserId: user.GetId(),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
a, err := s.store.Get()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(a); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Set persists the announcement provided in the request body.
|
||||
func (s Service) Set(w http.ResponseWriter, r *http.Request) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: _writePermission,
|
||||
SubjectRef: &permissionsapi.SubjectReference{
|
||||
Spec: &permissionsapi.SubjectReference_UserId{
|
||||
UserId: user.GetId(),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var body Announcement
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, _maxBodySize)).Decode(&body); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// an announcement without a banner text is nothing to show, so remove it entirely
|
||||
if body.BannerText == "" {
|
||||
if err := s.store.Delete(); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if err := s.store.Set(body); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
)
|
||||
@@ -25,9 +26,24 @@ type Config struct {
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
GatewayAddress string `yaml:"gateway_addr" env:"WEB_GATEWAY_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"`
|
||||
Store Store `yaml:"store"`
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
// Store configures the persistent store used to keep runtime managed web settings, e.g. the announcement banner.
|
||||
type Store struct {
|
||||
Store string `yaml:"store" env:"OC_PERSISTENT_STORE;WEB_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"%%NEXT%%"`
|
||||
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
|
||||
Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"%%NEXT%%"`
|
||||
Table string `yaml:"table" env:"WEB_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"%%NEXT%%"`
|
||||
TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;WEB_STORE_TTL" desc:"Time to live for entries in the store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
|
||||
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
|
||||
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
// Asset defines the available asset configuration.
|
||||
type Asset struct {
|
||||
CorePath string `yaml:"core_path" env:"WEB_ASSET_CORE_PATH" desc:"Serve OpenCloud Web assets from a path on the filesystem instead of the builtin assets. If not defined, the root directory derives from $OC_BASE_DATA_PATH/web/assets/core" introductionVersion:"1.0.0"`
|
||||
|
||||
@@ -85,6 +85,13 @@ func DefaultConfig() *config.Config {
|
||||
ThemesPath: filepath.Join(defaults.BaseDataPath(), "web/assets/themes"),
|
||||
},
|
||||
GatewayAddress: "eu.opencloud.api.gateway",
|
||||
Store: config.Store{
|
||||
Store: "nats-js-kv",
|
||||
Nodes: []string{"127.0.0.1:9233"},
|
||||
Database: "web",
|
||||
Table: "",
|
||||
TTL: 0,
|
||||
},
|
||||
Web: config.Web{
|
||||
ThemeServer: "https://localhost:9200",
|
||||
ThemePath: "/themes/opencloud/theme.json",
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
// Options are the option for the web
|
||||
type Options struct {
|
||||
Announcement *Announcement `json:"announcement,omitempty" yaml:"announcement"`
|
||||
AccountEditLink *AccountEditLink `json:"accountEditLink,omitempty" yaml:"accountEditLink"`
|
||||
DisableFeedbackLink bool `json:"disableFeedbackLink,omitempty" yaml:"disableFeedbackLink" env:"WEB_OPTION_DISABLE_FEEDBACK_LINK" desc:"Set this option to 'true' to disable the feedback link in the top bar. Keeping it enabled by setting the value to 'false' or with the absence of the option, allows OpenCloud to get feedback from your user base through a dedicated survey website." introductionVersion:"1.0.0"`
|
||||
DisableSponsorLink bool `json:"disableSponsorLink,omitempty" yaml:"disableSponsorLink" env:"WEB_OPTION_DISABLE_SPONSOR_LINK" desc:"Set this option to 'true' to disable the sponsor link in the left sidebar. Keeping it enabled by setting the value to 'false' or by leaving the option unset allows OpenCloud to get support from the community through a dedicated sponsorship program on GitHub." introductionVersion:"7.3.0"`
|
||||
@@ -23,6 +24,14 @@ type Options struct {
|
||||
OxAppSuite *OxAppSuite `json:"oxAppSuite,omitempty" yaml:"oxAppSuite"`
|
||||
}
|
||||
|
||||
// Announcement is a banner message shown above the top bar to all users.
|
||||
type Announcement struct {
|
||||
// BannerText is the short line shown in the banner.
|
||||
BannerText string `json:"bannerText,omitempty" yaml:"bannerText"`
|
||||
// InfoText is the (Markdown) detail shown in a dialog when the banner is clicked.
|
||||
InfoText string `json:"infoText,omitempty" yaml:"infoText"`
|
||||
}
|
||||
|
||||
// AccountEditLink are the AccountEditLink options
|
||||
type AccountEditLink struct {
|
||||
Href string `json:"href,omitempty" yaml:"href" env:"WEB_OPTION_ACCOUNT_EDIT_LINK_HREF" desc:"Set a different target URL for the edit link. Make sure to prepend it with 'http(s)://'." introductionVersion:"1.0.0"`
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/store"
|
||||
"go-micro.dev/v4"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/cors"
|
||||
"github.com/opencloud-eu/opencloud/pkg/middleware"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
"github.com/opencloud-eu/opencloud/pkg/x/io/fsx"
|
||||
"github.com/opencloud-eu/opencloud/services/web"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/apps"
|
||||
svc "github.com/opencloud-eu/opencloud/services/web/pkg/service/v0"
|
||||
)
|
||||
@@ -76,11 +79,25 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"),
|
||||
)
|
||||
|
||||
// persistent store for runtime managed web settings, e.g. the announcement banner
|
||||
announcementStore := announcement.NewStore(store.Create(
|
||||
store.Store(options.Config.Store.Store),
|
||||
store.TTL(options.Config.Store.TTL),
|
||||
microstore.Nodes(options.Config.Store.Nodes...),
|
||||
microstore.Database(options.Config.Store.Database),
|
||||
microstore.Table(options.Config.Store.Table),
|
||||
store.Authentication(options.Config.Store.AuthUsername, options.Config.Store.AuthPassword),
|
||||
store.TLSEnabled(options.Config.Store.EnableTLS),
|
||||
store.TLSInsecure(options.Config.Store.TLSInsecure),
|
||||
store.TLSRootCA(options.Config.Store.TLSRootCACertificate),
|
||||
))
|
||||
|
||||
handle, err := svc.NewService(
|
||||
svc.Logger(options.Logger),
|
||||
svc.CoreFS(coreFS.IOFS()),
|
||||
svc.AppFS(appsFS.IOFS()),
|
||||
svc.ThemeFS(themeFS),
|
||||
svc.AnnouncementStore(announcementStore),
|
||||
svc.AppsHTTPEndpoint(_customAppsEndpoint),
|
||||
svc.Config(options.Config),
|
||||
svc.GatewaySelector(gatewaySelector),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/middleware"
|
||||
"github.com/opencloud-eu/opencloud/pkg/tracing"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/assets"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/theme"
|
||||
@@ -50,10 +51,11 @@ func NewService(opts ...Option) (Service, error) {
|
||||
)
|
||||
|
||||
svc := Web{
|
||||
logger: options.Logger,
|
||||
config: options.Config,
|
||||
mux: m,
|
||||
gatewaySelector: options.GatewaySelector,
|
||||
logger: options.Logger,
|
||||
config: options.Config,
|
||||
mux: m,
|
||||
gatewaySelector: options.GatewaySelector,
|
||||
announcementStore: options.AnnouncementStore,
|
||||
}
|
||||
|
||||
themeService, err := theme.NewService(
|
||||
@@ -65,6 +67,15 @@ func NewService(opts ...Option) (Service, error) {
|
||||
return svc, err
|
||||
}
|
||||
|
||||
announcementService, err := announcement.NewService(
|
||||
announcement.ServiceOptions{}.
|
||||
WithStore(options.AnnouncementStore).
|
||||
WithGatewaySelector(options.GatewaySelector),
|
||||
)
|
||||
if err != nil {
|
||||
return svc, err
|
||||
}
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Get("/config.json", svc.Config)
|
||||
r.Route("/branding/logo", func(r chi.Router) {
|
||||
@@ -75,6 +86,14 @@ func NewService(opts ...Option) (Service, error) {
|
||||
r.Post("/", themeService.LogoUpload)
|
||||
r.Delete("/", themeService.LogoReset)
|
||||
})
|
||||
r.Route("/announcement", func(r chi.Router) {
|
||||
r.Use(middleware.ExtractAccountUUID(
|
||||
account.Logger(options.Logger),
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
))
|
||||
r.Get("/", announcementService.Get)
|
||||
r.Put("/", announcementService.Set)
|
||||
})
|
||||
r.Route("/themes", func(r chi.Router) {
|
||||
r.Get("/{id}/theme.json", themeService.Get)
|
||||
r.Mount("/", svc.Static(
|
||||
@@ -104,10 +123,11 @@ func NewService(opts ...Option) (Service, error) {
|
||||
|
||||
// Web defines the handlers for the web service.
|
||||
type Web struct {
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
mux *chi.Mux
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
mux *chi.Mux
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
announcementStore *announcement.Store
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
@@ -116,25 +136,53 @@ func (p Web) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (p Web) getPayload() (payload []byte, err error) {
|
||||
// render dynamically using config
|
||||
// render dynamically using a copy of the config, so per-request values (e.g. the
|
||||
// announcement) are not written into the shared config concurrently.
|
||||
webConfig := p.config.Web.Config
|
||||
|
||||
// build theme url
|
||||
if themeServer, err := url.Parse(p.config.Web.ThemeServer); err == nil {
|
||||
p.config.Web.Config.Theme = themeServer.String() + p.config.Web.ThemePath
|
||||
webConfig.Theme = themeServer.String() + p.config.Web.ThemePath
|
||||
} else {
|
||||
p.config.Web.Config.Theme = p.config.Web.ThemePath
|
||||
webConfig.Theme = p.config.Web.ThemePath
|
||||
}
|
||||
|
||||
// make apps render as empty array if it is empty
|
||||
// TODO remove once https://github.com/golang/go/issues/27589 is fixed
|
||||
if len(p.config.Web.Config.Apps) == 0 {
|
||||
p.config.Web.Config.Apps = make([]string, 0)
|
||||
if len(webConfig.Apps) == 0 {
|
||||
webConfig.Apps = make([]string, 0)
|
||||
}
|
||||
|
||||
// ensure that the server url has a trailing slash
|
||||
p.config.Web.Config.Server = strings.TrimRight(p.config.Web.Config.Server, "/") + "/"
|
||||
webConfig.Server = strings.TrimRight(webConfig.Server, "/") + "/"
|
||||
|
||||
return json.Marshal(p.config.Web.Config)
|
||||
// inject the runtime managed announcement banner; keep a statically configured one
|
||||
// (web.config.options.announcement) when nothing is stored
|
||||
if a := p.currentAnnouncement(); a != nil {
|
||||
webConfig.Options.Announcement = a
|
||||
}
|
||||
|
||||
return json.Marshal(webConfig)
|
||||
}
|
||||
|
||||
// currentAnnouncement returns the stored announcement for config.json, or nil if unset or unavailable.
|
||||
func (p Web) currentAnnouncement() *config.Announcement {
|
||||
if p.announcementStore == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
a, err := p.announcementStore.Get()
|
||||
if err != nil {
|
||||
p.logger.Error().Err(err).Msg("could not read announcement from store")
|
||||
return nil
|
||||
}
|
||||
|
||||
// only live (enabled) announcements with a banner line are exposed in the public config.json
|
||||
if !a.Enabled || a.BannerText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &config.Announcement{BannerText: a.BannerText, InfoText: a.InfoText}
|
||||
}
|
||||
|
||||
// Config implements the Service interface.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
|
||||
"github.com/opencloud-eu/opencloud/services/web/pkg/config"
|
||||
)
|
||||
|
||||
func TestCurrentAnnouncement(t *testing.T) {
|
||||
newWeb := func(a *announcement.Store) Web {
|
||||
return Web{logger: log.NopLogger(), announcementStore: a}
|
||||
}
|
||||
newStore := func() *announcement.Store {
|
||||
return announcement.NewStore(store.Create(store.Store("memory")))
|
||||
}
|
||||
|
||||
t.Run("nil when there is no store", func(t *testing.T) {
|
||||
require.Nil(t, newWeb(nil).currentAnnouncement())
|
||||
})
|
||||
|
||||
t.Run("nil when the store is empty", func(t *testing.T) {
|
||||
require.Nil(t, newWeb(newStore()).currentAnnouncement())
|
||||
})
|
||||
|
||||
t.Run("nil when disabled", func(t *testing.T) {
|
||||
s := newStore()
|
||||
require.NoError(t, s.Set(announcement.Announcement{Enabled: false, BannerText: "hi", InfoText: "info"}))
|
||||
require.Nil(t, newWeb(s).currentAnnouncement())
|
||||
})
|
||||
|
||||
t.Run("nil when enabled but the banner text is empty", func(t *testing.T) {
|
||||
s := newStore()
|
||||
require.NoError(t, s.Set(announcement.Announcement{Enabled: true, InfoText: "info"}))
|
||||
require.Nil(t, newWeb(s).currentAnnouncement())
|
||||
})
|
||||
|
||||
t.Run("returns banner and info text when enabled with a banner text", func(t *testing.T) {
|
||||
s := newStore()
|
||||
require.NoError(t, s.Set(announcement.Announcement{Enabled: true, BannerText: "hi", InfoText: "info"}))
|
||||
require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, newWeb(s).currentAnnouncement())
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user