From b7c57e2ff89c36414e8e58fabb73b3b97ee72a4d Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Fri, 27 Jun 2025 11:28:31 +0200 Subject: [PATCH] enhancement(console): introduce console service and add theming functionality --- .bingo/go-xgettext.mod | 2 - opencloud/pkg/command/services.go | 6 + opencloud/pkg/runtime/service/service.go | 15 +- pkg/config/config.go | 2 + pkg/config/defaultconfig.go | 2 + pkg/x/io/fsx/cs3/metadata/file.go | 89 ++++ pkg/x/io/fsx/cs3/metadata/file_info.go | 58 ++ pkg/x/io/fsx/cs3/metadata/fs.go | 95 ++++ pkg/x/io/fsx/cs3/metadata/metadata.go | 9 + .../gen/opencloud/services/web/v0/web.pb.go | 499 ++++++++++++++++++ .../opencloud/services/web/v0/web.pb.micro.go | 121 +++++ .../services/web/v0/web.swagger.json | 80 +++ protogen/proto/buf.gen.yaml | 1 + .../proto/opencloud/services/web/v0/web.proto | 58 ++ services/console/.mockery.yaml | 14 + services/console/Makefile | 16 + services/console/cmd/console/main.go | 19 + services/console/mocks/console_repository.go | 113 ++++ services/console/mocks/repository.go | 225 ++++++++ services/console/pkg/command/health.go | 24 + services/console/pkg/command/root.go | 32 ++ services/console/pkg/command/server.go | 26 + services/console/pkg/command/version.go | 53 ++ .../console/pkg/command/web/theme/pull.go | 65 +++ .../console/pkg/command/web/theme/theme.go | 17 + services/console/pkg/command/web/web.go | 18 + services/console/pkg/config/config.go | 68 +++ services/console/pkg/config/console.go | 6 + .../pkg/config/defaults/defaultconfig.go | 72 +++ services/console/pkg/config/parser/parse.go | 45 ++ services/console/pkg/console/grpc.go | 28 + services/console/pkg/console/http.go | 35 ++ services/console/pkg/console/jwt.go | 39 ++ services/console/pkg/logging/logging.go | 17 + services/console/pkg/web/repository.go | 123 +++++ services/console/pkg/web/service.go | 87 +++ services/console/pkg/web/service_test.go | 33 ++ services/console/pkg/web/web.go | 13 + services/web/Makefile | 8 +- services/web/pkg/command/server.go | 109 +++- services/web/pkg/config/config.go | 22 +- .../web/pkg/config/defaults/defaultconfig.go | 27 +- services/web/pkg/config/grpc.go | 11 + services/web/pkg/fs/fs.go | 52 ++ services/web/pkg/server/grpc/option.go | 94 ++++ services/web/pkg/server/grpc/server.go | 67 +++ services/web/pkg/server/http/server.go | 13 +- services/web/pkg/service/grpc/v0/option.go | 68 +++ services/web/pkg/service/grpc/v0/service.go | 67 +++ services/web/pkg/service/v0/service.go | 81 +-- services/web/pkg/theme/http.go | 72 +++ services/web/pkg/theme/http_test.go | 57 ++ services/web/pkg/theme/kv.go | 64 +-- services/web/pkg/theme/kv_test.go | 137 +---- services/web/pkg/theme/service.go | 186 +++---- services/web/pkg/theme/service_test.go | 51 +- services/web/pkg/theme/theme.go | 6 +- 57 files changed, 2968 insertions(+), 449 deletions(-) create mode 100644 pkg/x/io/fsx/cs3/metadata/file.go create mode 100644 pkg/x/io/fsx/cs3/metadata/file_info.go create mode 100644 pkg/x/io/fsx/cs3/metadata/fs.go create mode 100644 pkg/x/io/fsx/cs3/metadata/metadata.go create mode 100644 protogen/gen/opencloud/services/web/v0/web.pb.go create mode 100644 protogen/gen/opencloud/services/web/v0/web.pb.micro.go create mode 100644 protogen/gen/opencloud/services/web/v0/web.swagger.json create mode 100644 protogen/proto/opencloud/services/web/v0/web.proto create mode 100644 services/console/.mockery.yaml create mode 100644 services/console/Makefile create mode 100644 services/console/cmd/console/main.go create mode 100644 services/console/mocks/console_repository.go create mode 100644 services/console/mocks/repository.go create mode 100644 services/console/pkg/command/health.go create mode 100644 services/console/pkg/command/root.go create mode 100644 services/console/pkg/command/server.go create mode 100644 services/console/pkg/command/version.go create mode 100644 services/console/pkg/command/web/theme/pull.go create mode 100644 services/console/pkg/command/web/theme/theme.go create mode 100644 services/console/pkg/command/web/web.go create mode 100644 services/console/pkg/config/config.go create mode 100644 services/console/pkg/config/console.go create mode 100644 services/console/pkg/config/defaults/defaultconfig.go create mode 100644 services/console/pkg/config/parser/parse.go create mode 100644 services/console/pkg/console/grpc.go create mode 100644 services/console/pkg/console/http.go create mode 100644 services/console/pkg/console/jwt.go create mode 100644 services/console/pkg/logging/logging.go create mode 100644 services/console/pkg/web/repository.go create mode 100644 services/console/pkg/web/service.go create mode 100644 services/console/pkg/web/service_test.go create mode 100644 services/console/pkg/web/web.go create mode 100644 services/web/pkg/config/grpc.go create mode 100644 services/web/pkg/fs/fs.go create mode 100644 services/web/pkg/server/grpc/option.go create mode 100644 services/web/pkg/server/grpc/server.go create mode 100644 services/web/pkg/service/grpc/v0/option.go create mode 100644 services/web/pkg/service/grpc/v0/service.go create mode 100644 services/web/pkg/theme/http.go create mode 100644 services/web/pkg/theme/http_test.go diff --git a/.bingo/go-xgettext.mod b/.bingo/go-xgettext.mod index b14946a0b8..8e3c6210b5 100644 --- a/.bingo/go-xgettext.mod +++ b/.bingo/go-xgettext.mod @@ -3,5 +3,3 @@ module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT go 1.23.4 require github.com/gosexy/gettext v0.0.0-20160830220431-74466a0a0c4a // go-xgettext - -require github.com/jessevdk/go-flags v1.6.1 // indirect diff --git a/opencloud/pkg/command/services.go b/opencloud/pkg/command/services.go index e87548979c..2336747ee3 100644 --- a/opencloud/pkg/command/services.go +++ b/opencloud/pkg/command/services.go @@ -20,6 +20,7 @@ import ( authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/command" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/command" collaboration "github.com/opencloud-eu/opencloud/services/collaboration/pkg/command" + console "github.com/opencloud-eu/opencloud/services/console/pkg/command" eventhistory "github.com/opencloud-eu/opencloud/services/eventhistory/pkg/command" frontend "github.com/opencloud-eu/opencloud/services/frontend/pkg/command" gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command" @@ -113,6 +114,11 @@ var svccmds = []register.Command{ cfg.Collaboration.Commons = cfg.Commons }) }, + func(cfg *config.Config) *cli.Command { + return ServiceCommand(cfg, cfg.Console.Service.Name, console.GetCommands(cfg.Console), func(c *config.Config) { + cfg.Console.Commons = cfg.Commons + }) + }, func(cfg *config.Config) *cli.Command { return ServiceCommand(cfg, cfg.EventHistory.Service.Name, eventhistory.GetCommands(cfg.EventHistory), func(c *config.Config) { cfg.EventHistory.Commons = cfg.Commons diff --git a/opencloud/pkg/runtime/service/service.go b/opencloud/pkg/runtime/service/service.go index 5d244e1ace..eed945eb48 100644 --- a/opencloud/pkg/runtime/service/service.go +++ b/opencloud/pkg/runtime/service/service.go @@ -15,6 +15,11 @@ import ( "github.com/cenkalti/backoff" "github.com/mohae/deepcopy" "github.com/olekukonko/tablewriter" + "github.com/opencloud-eu/reva/v2/pkg/events/stream" + "github.com/opencloud-eu/reva/v2/pkg/logger" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/thejerf/suture/v4" + occfg "github.com/opencloud-eu/opencloud/pkg/config" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/runner" @@ -31,6 +36,7 @@ import ( authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/command" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/command" collaboration "github.com/opencloud-eu/opencloud/services/collaboration/pkg/command" + console "github.com/opencloud-eu/opencloud/services/console/pkg/command" eventhistory "github.com/opencloud-eu/opencloud/services/eventhistory/pkg/command" frontend "github.com/opencloud-eu/opencloud/services/frontend/pkg/command" gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command" @@ -61,10 +67,6 @@ import ( web "github.com/opencloud-eu/opencloud/services/web/pkg/command" webdav "github.com/opencloud-eu/opencloud/services/webdav/pkg/command" webfinger "github.com/opencloud-eu/opencloud/services/webfinger/pkg/command" - "github.com/opencloud-eu/reva/v2/pkg/events/stream" - "github.com/opencloud-eu/reva/v2/pkg/logger" - "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" - "github.com/thejerf/suture/v4" ) var ( @@ -334,6 +336,11 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) { cfg.Collaboration.Commons = cfg.Commons return collaboration.Execute(cfg.Collaboration) }) + areg(opts.Config.Console.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { + cfg.Console.Context = ctx + cfg.Console.Commons = cfg.Commons + return console.Execute(cfg.Console) + }) areg(opts.Config.Policies.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { cfg.Policies.Context = ctx cfg.Policies.Commons = cfg.Commons diff --git a/pkg/config/config.go b/pkg/config/config.go index 26b019f12d..ce59966ace 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -14,6 +14,7 @@ import ( authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/config" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/config" collaboration "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config" + console "github.com/opencloud-eu/opencloud/services/console/pkg/config" eventhistory "github.com/opencloud-eu/opencloud/services/eventhistory/pkg/config" frontend "github.com/opencloud-eu/opencloud/services/frontend/pkg/config" gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config" @@ -94,6 +95,7 @@ type Config struct { AuthService *authservice.Config `yaml:"auth_service"` Clientlog *clientlog.Config `yaml:"clientlog"` Collaboration *collaboration.Config `yaml:"collaboration"` + Console *console.Config `yaml:"console"` EventHistory *eventhistory.Config `yaml:"eventhistory"` Frontend *frontend.Config `yaml:"frontend"` Gateway *gateway.Config `yaml:"gateway"` diff --git a/pkg/config/defaultconfig.go b/pkg/config/defaultconfig.go index 00cade7fb3..2e754631bf 100644 --- a/pkg/config/defaultconfig.go +++ b/pkg/config/defaultconfig.go @@ -14,6 +14,7 @@ import ( authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/config/defaults" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/config/defaults" collaboration "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config/defaults" + console "github.com/opencloud-eu/opencloud/services/console/pkg/config/defaults" eventhistory "github.com/opencloud-eu/opencloud/services/eventhistory/pkg/config/defaults" frontend "github.com/opencloud-eu/opencloud/services/frontend/pkg/config/defaults" gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config/defaults" @@ -69,6 +70,7 @@ func DefaultConfig() *Config { AuthService: authservice.DefaultConfig(), Clientlog: clientlog.DefaultConfig(), Collaboration: collaboration.DefaultConfig(), + Console: console.DefaultConfig(), EventHistory: eventhistory.DefaultConfig(), Frontend: frontend.DefaultConfig(), Gateway: gateway.DefaultConfig(), diff --git a/pkg/x/io/fsx/cs3/metadata/file.go b/pkg/x/io/fsx/cs3/metadata/file.go new file mode 100644 index 0000000000..22414fe373 --- /dev/null +++ b/pkg/x/io/fsx/cs3/metadata/file.go @@ -0,0 +1,89 @@ +package metadata + +import ( + "bytes" + "context" + "io" + "io/fs" + "os" +) + +type File struct { + name string + fs *Fs + fileMode os.FileMode + content []byte + resource io.ReadCloser +} + +func newFile(name string, fs *Fs, fileMode os.FileMode, content []byte) (*File, error) { + return &File{ + name: name, + fs: fs, + fileMode: fileMode, + content: content, + resource: io.NopCloser(bytes.NewBuffer(content)), + }, nil +} + +func (f *File) Close() error { + return f.resource.Close() +} + +func (f *File) Read(p []byte) (n int, err error) { + return f.resource.Read(p) +} + +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + readerAt, ok := f.resource.(io.ReaderAt) + if !ok { + return -1, &fs.PathError{Op: "ReadAt", Path: f.name, Err: ErrNotImplemented} + } + + return readerAt.ReadAt(p, off) +} + +func (f *File) Seek(offset int64, whence int) (int64, error) { + seeker, ok := f.resource.(io.Seeker) + if !ok { + return -1, &fs.PathError{Op: "Seek", Path: f.name, Err: ErrNotImplemented} + } + + return seeker.Seek(offset, whence) +} + +func (f *File) Write(p []byte) (n int, err error) { + return len(p), f.fs.storage.SimpleUpload(context.Background(), f.name, p) +} + +func (f *File) WriteAt(p []byte, off int64) (n int, err error) { + return -1, &fs.PathError{Op: "Write", Path: f.name, Err: ErrNotImplemented} +} + +func (f *File) Name() string { + return f.name +} + +func (f *File) Readdir(_ int) ([]os.FileInfo, error) { + return nil, &fs.PathError{Op: "Readdir", Path: f.name, Err: ErrNotImplemented} +} + +func (f *File) Readdirnames(_ int) ([]string, error) { + return nil, &fs.PathError{Op: "Readdirnames", Path: f.name, Err: ErrNotImplemented} +} + +func (f *File) Sync() error { + return nil +} + +func (f *File) Truncate(_ int64) error { + return &fs.PathError{Op: "Truncate", Path: f.name, Err: ErrNotImplemented} +} + +func (f *File) WriteString(_ string) (ret int, err error) { + return -1, &fs.PathError{Op: "WriteString", Path: f.name, Err: ErrNotImplemented} +} + +func (f *File) Stat() (os.FileInfo, error) { + return newFileInfo(f.name, f.fs, f.fileMode) +} diff --git a/pkg/x/io/fsx/cs3/metadata/file_info.go b/pkg/x/io/fsx/cs3/metadata/file_info.go new file mode 100644 index 0000000000..9df974996a --- /dev/null +++ b/pkg/x/io/fsx/cs3/metadata/file_info.go @@ -0,0 +1,58 @@ +package metadata + +import ( + "context" + "io/fs" + "os" + "time" + + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/opencloud-eu/reva/v2/pkg/utils" +) + +type FileInfo struct { + name string + size int64 + modTime time.Time + isDir bool + mode os.FileMode +} + +func newFileInfo(name string, fs *Fs, fileMode os.FileMode) (*FileInfo, error) { + info, err := fs.storage.Stat(context.Background(), name) + if err != nil { + return nil, err + } + + return &FileInfo{ + name: info.GetName(), + size: int64(info.GetSize()), + modTime: utils.TSToTime(info.GetMtime()), + isDir: info.GetType() == providerv1beta1.ResourceType_RESOURCE_TYPE_CONTAINER, + mode: fileMode, + }, nil +} + +func (f *FileInfo) Name() string { + return f.name +} + +func (f *FileInfo) Size() int64 { + return f.size +} + +func (f *FileInfo) ModTime() time.Time { + return f.modTime +} + +func (f *FileInfo) IsDir() bool { + return f.isDir +} + +func (f *FileInfo) Mode() fs.FileMode { + return f.mode +} + +func (f *FileInfo) Sys() any { + return nil +} diff --git a/pkg/x/io/fsx/cs3/metadata/fs.go b/pkg/x/io/fsx/cs3/metadata/fs.go new file mode 100644 index 0000000000..adf3c0e453 --- /dev/null +++ b/pkg/x/io/fsx/cs3/metadata/fs.go @@ -0,0 +1,95 @@ +package metadata + +import ( + "context" + "fmt" + "io/fs" + "os" + "path" + "strings" + "syscall" + "time" + + revaMetadata "github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/metadata" + "github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata" + "github.com/spf13/afero" +) + +func NewMetadataFs(storage metadata.Storage) *Fs { + return &Fs{storage: storage} +} + +type Fs struct { + storage metadata.Storage +} + +func (fs *Fs) Create(_ string) (afero.File, error) { + return nil, syscall.EPERM +} + +func (fs *Fs) Mkdir(name string, _ os.FileMode) error { + return fs.storage.MakeDirIfNotExist(context.Background(), name) +} + +func (fs *Fs) MkdirAll(name string, _ os.FileMode) error { + paths := strings.Split(name, string(os.PathSeparator)) + // Create all parent directories if they do not exist + for i := 0; i <= len(paths)-1; i++ { + c := path.Join(paths[:i+1]...) + if err := fs.storage.MakeDirIfNotExist(context.Background(), c); err != nil { + return fmt.Errorf("failed to create directory %s: %w", c, err) + } + } + + return nil +} + +func (fs *Fs) Open(name string) (afero.File, error) { + return fs.OpenFile(name, os.O_RDONLY, 0) +} + +func (fs *Fs) OpenFile(name string, _ int, _ os.FileMode) (afero.File, error) { + res, err := fs.storage.Download(context.Background(), metadata.DownloadRequest{Path: name}) + if err != nil && !revaMetadata.IsNotExist(err) { + return nil, err + } + + var contend []byte + if res != nil { + contend = res.Content + } + + return newFile(name, fs, 0, contend) +} + +func (fs *Fs) Remove(name string) error { + return fs.RemoveAll(name) +} + +func (fs *Fs) RemoveAll(path string) error { + return fs.storage.Delete(context.Background(), path) +} + +func (fs *Fs) Rename(_, _ string) error { + return syscall.EPERM +} + +func (fs *Fs) Stat(name string) (fs.FileInfo, error) { + return newFileInfo(name, fs, 0) +} + +func (fs *Fs) Name() string { + return "MetadataFS" +} + +func (fs *Fs) Chmod(_ string, _ os.FileMode) error { + return syscall.EPERM +} + +func (fs *Fs) Chown(_ string, _, _ int) error { + return syscall.EPERM +} + +func (fs *Fs) Chtimes(_ string, _ time.Time, _ time.Time) error { + return syscall.EPERM +} diff --git a/pkg/x/io/fsx/cs3/metadata/metadata.go b/pkg/x/io/fsx/cs3/metadata/metadata.go new file mode 100644 index 0000000000..aecf62fdaf --- /dev/null +++ b/pkg/x/io/fsx/cs3/metadata/metadata.go @@ -0,0 +1,9 @@ +package metadata + +import ( + "errors" +) + +var ( + ErrNotImplemented = errors.New("not implemented") +) diff --git a/protogen/gen/opencloud/services/web/v0/web.pb.go b/protogen/gen/opencloud/services/web/v0/web.pb.go new file mode 100644 index 0000000000..badb1022d2 --- /dev/null +++ b/protogen/gen/opencloud/services/web/v0/web.pb.go @@ -0,0 +1,499 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc (unknown) +// source: opencloud/services/web/v0/web.proto + +package v0 + +import ( + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ThemeAddRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // The ID of the theme to add + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` // The theme data in bytes +} + +func (x *ThemeAddRequest) Reset() { + *x = ThemeAddRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ThemeAddRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThemeAddRequest) ProtoMessage() {} + +func (x *ThemeAddRequest) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ThemeAddRequest.ProtoReflect.Descriptor instead. +func (*ThemeAddRequest) Descriptor() ([]byte, []int) { + return file_opencloud_services_web_v0_web_proto_rawDescGZIP(), []int{0} +} + +func (x *ThemeAddRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ThemeAddRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type ThemeAddResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ThemeAddResponse) Reset() { + *x = ThemeAddResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ThemeAddResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThemeAddResponse) ProtoMessage() {} + +func (x *ThemeAddResponse) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ThemeAddResponse.ProtoReflect.Descriptor instead. +func (*ThemeAddResponse) Descriptor() ([]byte, []int) { + return file_opencloud_services_web_v0_web_proto_rawDescGZIP(), []int{1} +} + +type ThemeExistsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // The ID of the theme to check +} + +func (x *ThemeExistsRequest) Reset() { + *x = ThemeExistsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ThemeExistsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThemeExistsRequest) ProtoMessage() {} + +func (x *ThemeExistsRequest) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ThemeExistsRequest.ProtoReflect.Descriptor instead. +func (*ThemeExistsRequest) Descriptor() ([]byte, []int) { + return file_opencloud_services_web_v0_web_proto_rawDescGZIP(), []int{2} +} + +func (x *ThemeExistsRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type ThemeExistsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Indicates if the theme exists +} + +func (x *ThemeExistsResponse) Reset() { + *x = ThemeExistsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ThemeExistsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThemeExistsResponse) ProtoMessage() {} + +func (x *ThemeExistsResponse) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ThemeExistsResponse.ProtoReflect.Descriptor instead. +func (*ThemeExistsResponse) Descriptor() ([]byte, []int) { + return file_opencloud_services_web_v0_web_proto_rawDescGZIP(), []int{3} +} + +func (x *ThemeExistsResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +type ThemeRemoveRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // The ID of the theme to remove +} + +func (x *ThemeRemoveRequest) Reset() { + *x = ThemeRemoveRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ThemeRemoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThemeRemoveRequest) ProtoMessage() {} + +func (x *ThemeRemoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ThemeRemoveRequest.ProtoReflect.Descriptor instead. +func (*ThemeRemoveRequest) Descriptor() ([]byte, []int) { + return file_opencloud_services_web_v0_web_proto_rawDescGZIP(), []int{4} +} + +func (x *ThemeRemoveRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type ThemeRemoveResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ThemeRemoveResponse) Reset() { + *x = ThemeRemoveResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ThemeRemoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThemeRemoveResponse) ProtoMessage() {} + +func (x *ThemeRemoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_web_v0_web_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ThemeRemoveResponse.ProtoReflect.Descriptor instead. +func (*ThemeRemoveResponse) Descriptor() ([]byte, []int) { + return file_opencloud_services_web_v0_web_proto_rawDescGZIP(), []int{5} +} + +var File_opencloud_services_web_v0_web_proto protoreflect.FileDescriptor + +var file_opencloud_services_web_v0_web_proto_rawDesc = []byte{ + 0x0a, 0x23, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x2f, 0x77, 0x65, 0x62, 0x2f, 0x76, 0x30, 0x2f, 0x77, 0x65, 0x62, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x19, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x77, 0x65, 0x62, 0x2e, 0x76, 0x30, + 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x2d, 0x67, 0x65, 0x6e, 0x2d, 0x6f, 0x70, 0x65, + 0x6e, 0x61, 0x70, 0x69, 0x76, 0x32, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2f, 0x61, + 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x22, 0x35, 0x0a, 0x0f, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x12, 0x0a, 0x10, 0x54, 0x68, 0x65, 0x6d, 0x65, + 0x41, 0x64, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x24, 0x0a, 0x12, 0x54, + 0x68, 0x65, 0x6d, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x22, 0x2d, 0x0a, 0x13, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x69, 0x73, + 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, + 0x22, 0x24, 0x0a, 0x12, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x15, 0x0a, 0x13, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, + 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xcd, 0x02, + 0x0a, 0x0a, 0x57, 0x65, 0x62, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x63, 0x0a, 0x08, + 0x54, 0x68, 0x65, 0x6d, 0x65, 0x41, 0x64, 0x64, 0x12, 0x2a, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x77, 0x65, + 0x62, 0x2e, 0x76, 0x30, 0x2e, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x77, 0x65, 0x62, 0x2e, 0x76, 0x30, + 0x2e, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x41, 0x64, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x6c, 0x0a, 0x0b, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, + 0x12, 0x2d, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x77, 0x65, 0x62, 0x2e, 0x76, 0x30, 0x2e, 0x54, 0x68, 0x65, + 0x6d, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x2e, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x2e, 0x77, 0x65, 0x62, 0x2e, 0x76, 0x30, 0x2e, 0x54, 0x68, 0x65, 0x6d, + 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x6c, 0x0a, 0x0b, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x2d, + 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x73, 0x2e, 0x77, 0x65, 0x62, 0x2e, 0x76, 0x30, 0x2e, 0x54, 0x68, 0x65, 0x6d, 0x65, + 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, + 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x2e, 0x77, 0x65, 0x62, 0x2e, 0x76, 0x30, 0x2e, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, + 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0xea, 0x02, + 0x5a, 0x48, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, + 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, + 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x73, 0x2f, 0x77, 0x65, 0x62, 0x2f, 0x76, 0x30, 0x92, 0x41, 0x9c, 0x02, 0x12, 0xb4, + 0x01, 0x0a, 0x0d, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x77, 0x65, 0x62, + 0x22, 0x51, 0x0a, 0x0e, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, + 0x62, 0x48, 0x12, 0x29, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x1a, 0x14, 0x73, + 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x40, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x65, 0x75, 0x2a, 0x49, 0x0a, 0x0a, 0x41, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, + 0x30, 0x12, 0x3b, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, + 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x62, 0x6c, 0x6f, + 0x62, 0x2f, 0x6d, 0x61, 0x69, 0x6e, 0x2f, 0x4c, 0x49, 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, + 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x2a, 0x02, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, + 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3b, 0x0a, + 0x10, 0x44, 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, + 0x6c, 0x12, 0x27, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, + 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2f, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x77, 0x65, 0x62, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_opencloud_services_web_v0_web_proto_rawDescOnce sync.Once + file_opencloud_services_web_v0_web_proto_rawDescData = file_opencloud_services_web_v0_web_proto_rawDesc +) + +func file_opencloud_services_web_v0_web_proto_rawDescGZIP() []byte { + file_opencloud_services_web_v0_web_proto_rawDescOnce.Do(func() { + file_opencloud_services_web_v0_web_proto_rawDescData = protoimpl.X.CompressGZIP(file_opencloud_services_web_v0_web_proto_rawDescData) + }) + return file_opencloud_services_web_v0_web_proto_rawDescData +} + +var file_opencloud_services_web_v0_web_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_opencloud_services_web_v0_web_proto_goTypes = []interface{}{ + (*ThemeAddRequest)(nil), // 0: opencloud.services.web.v0.ThemeAddRequest + (*ThemeAddResponse)(nil), // 1: opencloud.services.web.v0.ThemeAddResponse + (*ThemeExistsRequest)(nil), // 2: opencloud.services.web.v0.ThemeExistsRequest + (*ThemeExistsResponse)(nil), // 3: opencloud.services.web.v0.ThemeExistsResponse + (*ThemeRemoveRequest)(nil), // 4: opencloud.services.web.v0.ThemeRemoveRequest + (*ThemeRemoveResponse)(nil), // 5: opencloud.services.web.v0.ThemeRemoveResponse +} +var file_opencloud_services_web_v0_web_proto_depIdxs = []int32{ + 0, // 0: opencloud.services.web.v0.WebService.ThemeAdd:input_type -> opencloud.services.web.v0.ThemeAddRequest + 2, // 1: opencloud.services.web.v0.WebService.ThemeExists:input_type -> opencloud.services.web.v0.ThemeExistsRequest + 4, // 2: opencloud.services.web.v0.WebService.ThemeRemove:input_type -> opencloud.services.web.v0.ThemeRemoveRequest + 1, // 3: opencloud.services.web.v0.WebService.ThemeAdd:output_type -> opencloud.services.web.v0.ThemeAddResponse + 3, // 4: opencloud.services.web.v0.WebService.ThemeExists:output_type -> opencloud.services.web.v0.ThemeExistsResponse + 5, // 5: opencloud.services.web.v0.WebService.ThemeRemove:output_type -> opencloud.services.web.v0.ThemeRemoveResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_opencloud_services_web_v0_web_proto_init() } +func file_opencloud_services_web_v0_web_proto_init() { + if File_opencloud_services_web_v0_web_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_opencloud_services_web_v0_web_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ThemeAddRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_web_v0_web_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ThemeAddResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_web_v0_web_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ThemeExistsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_web_v0_web_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ThemeExistsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_web_v0_web_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ThemeRemoveRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_web_v0_web_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ThemeRemoveResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_opencloud_services_web_v0_web_proto_rawDesc, + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_opencloud_services_web_v0_web_proto_goTypes, + DependencyIndexes: file_opencloud_services_web_v0_web_proto_depIdxs, + MessageInfos: file_opencloud_services_web_v0_web_proto_msgTypes, + }.Build() + File_opencloud_services_web_v0_web_proto = out.File + file_opencloud_services_web_v0_web_proto_rawDesc = nil + file_opencloud_services_web_v0_web_proto_goTypes = nil + file_opencloud_services_web_v0_web_proto_depIdxs = nil +} diff --git a/protogen/gen/opencloud/services/web/v0/web.pb.micro.go b/protogen/gen/opencloud/services/web/v0/web.pb.micro.go new file mode 100644 index 0000000000..5b33965391 --- /dev/null +++ b/protogen/gen/opencloud/services/web/v0/web.pb.micro.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-micro. DO NOT EDIT. +// source: opencloud/services/web/v0/web.proto + +package v0 + +import ( + fmt "fmt" + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" + proto "google.golang.org/protobuf/proto" + math "math" +) + +import ( + context "context" + api "go-micro.dev/v4/api" + client "go-micro.dev/v4/client" + server "go-micro.dev/v4/server" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// Reference imports to suppress errors if they are not otherwise used. +var _ api.Endpoint +var _ context.Context +var _ client.Option +var _ server.Option + +// Api Endpoints for WebService service + +func NewWebServiceEndpoints() []*api.Endpoint { + return []*api.Endpoint{} +} + +// Client API for WebService service +type WebService interface { + ThemeAdd(ctx context.Context, in *ThemeAddRequest, opts ...client.CallOption) (*ThemeAddResponse, error) + ThemeExists(ctx context.Context, in *ThemeExistsRequest, opts ...client.CallOption) (*ThemeExistsResponse, error) + ThemeRemove(ctx context.Context, in *ThemeRemoveRequest, opts ...client.CallOption) (*ThemeRemoveResponse, error) +} + +type webService struct { + c client.Client + name string +} + +func NewWebService(name string, c client.Client) WebService { + return &webService{ + c: c, + name: name, + } +} + +func (c *webService) ThemeAdd(ctx context.Context, in *ThemeAddRequest, opts ...client.CallOption) (*ThemeAddResponse, error) { + req := c.c.NewRequest(c.name, "WebService.ThemeAdd", in) + out := new(ThemeAddResponse) + err := c.c.Call(ctx, req, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *webService) ThemeExists(ctx context.Context, in *ThemeExistsRequest, opts ...client.CallOption) (*ThemeExistsResponse, error) { + req := c.c.NewRequest(c.name, "WebService.ThemeExists", in) + out := new(ThemeExistsResponse) + err := c.c.Call(ctx, req, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *webService) ThemeRemove(ctx context.Context, in *ThemeRemoveRequest, opts ...client.CallOption) (*ThemeRemoveResponse, error) { + req := c.c.NewRequest(c.name, "WebService.ThemeRemove", in) + out := new(ThemeRemoveResponse) + err := c.c.Call(ctx, req, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// Server API for WebService service + +type WebServiceHandler interface { + ThemeAdd(context.Context, *ThemeAddRequest, *ThemeAddResponse) error + ThemeExists(context.Context, *ThemeExistsRequest, *ThemeExistsResponse) error + ThemeRemove(context.Context, *ThemeRemoveRequest, *ThemeRemoveResponse) error +} + +func RegisterWebServiceHandler(s server.Server, hdlr WebServiceHandler, opts ...server.HandlerOption) error { + type webService interface { + ThemeAdd(ctx context.Context, in *ThemeAddRequest, out *ThemeAddResponse) error + ThemeExists(ctx context.Context, in *ThemeExistsRequest, out *ThemeExistsResponse) error + ThemeRemove(ctx context.Context, in *ThemeRemoveRequest, out *ThemeRemoveResponse) error + } + type WebService struct { + webService + } + h := &webServiceHandler{hdlr} + return s.Handle(s.NewHandler(&WebService{h}, opts...)) +} + +type webServiceHandler struct { + WebServiceHandler +} + +func (h *webServiceHandler) ThemeAdd(ctx context.Context, in *ThemeAddRequest, out *ThemeAddResponse) error { + return h.WebServiceHandler.ThemeAdd(ctx, in, out) +} + +func (h *webServiceHandler) ThemeExists(ctx context.Context, in *ThemeExistsRequest, out *ThemeExistsResponse) error { + return h.WebServiceHandler.ThemeExists(ctx, in, out) +} + +func (h *webServiceHandler) ThemeRemove(ctx context.Context, in *ThemeRemoveRequest, out *ThemeRemoveResponse) error { + return h.WebServiceHandler.ThemeRemove(ctx, in, out) +} diff --git a/protogen/gen/opencloud/services/web/v0/web.swagger.json b/protogen/gen/opencloud/services/web/v0/web.swagger.json new file mode 100644 index 0000000000..2e48770b69 --- /dev/null +++ b/protogen/gen/opencloud/services/web/v0/web.swagger.json @@ -0,0 +1,80 @@ +{ + "swagger": "2.0", + "info": { + "title": "OpenCloud web", + "version": "1.0.0", + "contact": { + "name": "OpenCloud GmbH", + "url": "https://github.com/opencloud-eu/opencloud", + "email": "support@opencloud.eu" + }, + "license": { + "name": "Apache-2.0", + "url": "https://github.com/opencloud-eu/opencloud/blob/main/LICENSE" + } + }, + "tags": [ + { + "name": "WebService" + } + ], + "schemes": [ + "http", + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": {}, + "definitions": { + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "$ref": "#/definitions/protobufAny" + } + } + } + }, + "v0ThemeAddResponse": { + "type": "object" + }, + "v0ThemeExistsResponse": { + "type": "object", + "properties": { + "exists": { + "type": "boolean", + "title": "Indicates if the theme exists" + } + } + }, + "v0ThemeRemoveResponse": { + "type": "object" + } + }, + "externalDocs": { + "description": "Developer Manual", + "url": "https://docs.opencloud.eu/services/web/" + } +} diff --git a/protogen/proto/buf.gen.yaml b/protogen/proto/buf.gen.yaml index 1e7689dce4..adca608319 100644 --- a/protogen/proto/buf.gen.yaml +++ b/protogen/proto/buf.gen.yaml @@ -25,6 +25,7 @@ plugins: opencloud.services.eventhistory.v0;\ opencloud.messages.eventhistory.v0;\ opencloud.services.policies.v0;\ + opencloud.services.web.v0;\ opencloud.messages.policies.v0" - name: openapiv2 diff --git a/protogen/proto/opencloud/services/web/v0/web.proto b/protogen/proto/opencloud/services/web/v0/web.proto new file mode 100644 index 0000000000..46da4b2a57 --- /dev/null +++ b/protogen/proto/opencloud/services/web/v0/web.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package opencloud.services.web.v0; + +option go_package = "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/web/v0"; + +import "protoc-gen-openapiv2/options/annotations.proto"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "OpenCloud web"; + version: "1.0.0"; + contact: { + name: "OpenCloud GmbH"; + url: "https://github.com/opencloud-eu/opencloud"; + email: "support@opencloud.eu"; + }; + license: { + name: "Apache-2.0"; + url: "https://github.com/opencloud-eu/opencloud/blob/main/LICENSE"; + }; + }; + schemes: HTTP; + schemes: HTTPS; + consumes: "application/json"; + produces: "application/json"; + external_docs: { + description: "Developer Manual"; + url: "https://docs.opencloud.eu/services/web/"; + }; +}; + +service WebService { + rpc ThemeAdd(ThemeAddRequest) returns (ThemeAddResponse); + rpc ThemeExists(ThemeExistsRequest) returns (ThemeExistsResponse); + rpc ThemeRemove(ThemeRemoveRequest) returns (ThemeRemoveResponse); +} + +message ThemeAddRequest { + string id = 1; // The ID of the theme to add + bytes data = 2; // The theme data in bytes +} + +message ThemeAddResponse {} + +message ThemeExistsRequest { + string id = 1; // The ID of the theme to check +} + +message ThemeExistsResponse { + bool exists = 1; // Indicates if the theme exists +} + +message ThemeRemoveRequest { + string id = 1; // The ID of the theme to remove +} + +message ThemeRemoveResponse {} diff --git a/services/console/.mockery.yaml b/services/console/.mockery.yaml new file mode 100644 index 0000000000..9730b4a78e --- /dev/null +++ b/services/console/.mockery.yaml @@ -0,0 +1,14 @@ +# maintain v2 separate mocks dir +dir: "{{.InterfaceDir}}/mocks" +structname: "{{.InterfaceName}}" +filename: "{{.InterfaceName | snakecase }}.go" +pkgname: mocks + +template: testify +packages: + github.com/opencloud-eu/opencloud/services/console/pkg/web: + config: + dir: mocks + interfaces: + Repository: {} + ConsoleRepository: {} diff --git a/services/console/Makefile b/services/console/Makefile new file mode 100644 index 0000000000..c1e60e5846 --- /dev/null +++ b/services/console/Makefile @@ -0,0 +1,16 @@ +SHELL := bash +NAME := console + +ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI +include ../../.bingo/Variables.mk +endif + +include ../../.make/default.mk +include ../../.make/go.mk +include ../../.make/release.mk +include ../../.make/docs.mk + + +.PHONY: go-generate +go-generate: $(MOCKERY) + $(MOCKERY) diff --git a/services/console/cmd/console/main.go b/services/console/cmd/console/main.go new file mode 100644 index 0000000000..92fb54a855 --- /dev/null +++ b/services/console/cmd/console/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/opencloud-eu/opencloud/services/console/pkg/command" + "github.com/opencloud-eu/opencloud/services/console/pkg/config/defaults" +) + +func main() { + cfg := defaults.DefaultConfig() + cfg.Context, _ = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP) + if err := command.Execute(cfg); err != nil { + os.Exit(1) + } +} diff --git a/services/console/mocks/console_repository.go b/services/console/mocks/console_repository.go new file mode 100644 index 0000000000..6479856ac7 --- /dev/null +++ b/services/console/mocks/console_repository.go @@ -0,0 +1,113 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + "io" + + mock "github.com/stretchr/testify/mock" +) + +// NewConsoleRepository creates a new instance of ConsoleRepository. 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 NewConsoleRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *ConsoleRepository { + mock := &ConsoleRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ConsoleRepository is an autogenerated mock type for the ConsoleRepository type +type ConsoleRepository struct { + mock.Mock +} + +type ConsoleRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *ConsoleRepository) EXPECT() *ConsoleRepository_Expecter { + return &ConsoleRepository_Expecter{mock: &_m.Mock} +} + +// ThemeGet provides a mock function for the type ConsoleRepository +func (_mock *ConsoleRepository) ThemeGet(ctx context.Context, token string, tenantID string) (io.ReadCloser, error) { + ret := _mock.Called(ctx, token, tenantID) + + if len(ret) == 0 { + panic("no return value specified for ThemeGet") + } + + var r0 io.ReadCloser + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (io.ReadCloser, error)); ok { + return returnFunc(ctx, token, tenantID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) io.ReadCloser); ok { + r0 = returnFunc(ctx, token, tenantID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.ReadCloser) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, token, tenantID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ConsoleRepository_ThemeGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ThemeGet' +type ConsoleRepository_ThemeGet_Call struct { + *mock.Call +} + +// ThemeGet is a helper method to define mock.On call +// - ctx context.Context +// - token string +// - tenantID string +func (_e *ConsoleRepository_Expecter) ThemeGet(ctx interface{}, token interface{}, tenantID interface{}) *ConsoleRepository_ThemeGet_Call { + return &ConsoleRepository_ThemeGet_Call{Call: _e.mock.On("ThemeGet", ctx, token, tenantID)} +} + +func (_c *ConsoleRepository_ThemeGet_Call) Run(run func(ctx context.Context, token string, tenantID string)) *ConsoleRepository_ThemeGet_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ConsoleRepository_ThemeGet_Call) Return(readCloser io.ReadCloser, err error) *ConsoleRepository_ThemeGet_Call { + _c.Call.Return(readCloser, err) + return _c +} + +func (_c *ConsoleRepository_ThemeGet_Call) RunAndReturn(run func(ctx context.Context, token string, tenantID string) (io.ReadCloser, error)) *ConsoleRepository_ThemeGet_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/console/mocks/repository.go b/services/console/mocks/repository.go new file mode 100644 index 0000000000..8f47fc0014 --- /dev/null +++ b/services/console/mocks/repository.go @@ -0,0 +1,225 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + "io" + + mock "github.com/stretchr/testify/mock" +) + +// NewRepository creates a new instance of Repository. 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 NewRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *Repository { + mock := &Repository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +type Repository_Expecter struct { + mock *mock.Mock +} + +func (_m *Repository) EXPECT() *Repository_Expecter { + return &Repository_Expecter{mock: &_m.Mock} +} + +// ThemeAdd provides a mock function for the type Repository +func (_mock *Repository) ThemeAdd(ctx context.Context, id string, r io.Reader) error { + ret := _mock.Called(ctx, id, r) + + if len(ret) == 0 { + panic("no return value specified for ThemeAdd") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader) error); ok { + r0 = returnFunc(ctx, id, r) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Repository_ThemeAdd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ThemeAdd' +type Repository_ThemeAdd_Call struct { + *mock.Call +} + +// ThemeAdd is a helper method to define mock.On call +// - ctx context.Context +// - id string +// - r io.Reader +func (_e *Repository_Expecter) ThemeAdd(ctx interface{}, id interface{}, r interface{}) *Repository_ThemeAdd_Call { + return &Repository_ThemeAdd_Call{Call: _e.mock.On("ThemeAdd", ctx, id, r)} +} + +func (_c *Repository_ThemeAdd_Call) Run(run func(ctx context.Context, id string, r io.Reader)) *Repository_ThemeAdd_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 io.Reader + if args[2] != nil { + arg2 = args[2].(io.Reader) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *Repository_ThemeAdd_Call) Return(err error) *Repository_ThemeAdd_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Repository_ThemeAdd_Call) RunAndReturn(run func(ctx context.Context, id string, r io.Reader) error) *Repository_ThemeAdd_Call { + _c.Call.Return(run) + return _c +} + +// ThemeExists provides a mock function for the type Repository +func (_mock *Repository) ThemeExists(ctx context.Context, id string) (bool, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for ThemeExists") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Repository_ThemeExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ThemeExists' +type Repository_ThemeExists_Call struct { + *mock.Call +} + +// ThemeExists is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *Repository_Expecter) ThemeExists(ctx interface{}, id interface{}) *Repository_ThemeExists_Call { + return &Repository_ThemeExists_Call{Call: _e.mock.On("ThemeExists", ctx, id)} +} + +func (_c *Repository_ThemeExists_Call) Run(run func(ctx context.Context, id string)) *Repository_ThemeExists_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Repository_ThemeExists_Call) Return(b bool, err error) *Repository_ThemeExists_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *Repository_ThemeExists_Call) RunAndReturn(run func(ctx context.Context, id string) (bool, error)) *Repository_ThemeExists_Call { + _c.Call.Return(run) + return _c +} + +// ThemeRemove provides a mock function for the type Repository +func (_mock *Repository) ThemeRemove(ctx context.Context, id string) error { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for ThemeRemove") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Repository_ThemeRemove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ThemeRemove' +type Repository_ThemeRemove_Call struct { + *mock.Call +} + +// ThemeRemove is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *Repository_Expecter) ThemeRemove(ctx interface{}, id interface{}) *Repository_ThemeRemove_Call { + return &Repository_ThemeRemove_Call{Call: _e.mock.On("ThemeRemove", ctx, id)} +} + +func (_c *Repository_ThemeRemove_Call) Run(run func(ctx context.Context, id string)) *Repository_ThemeRemove_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Repository_ThemeRemove_Call) Return(err error) *Repository_ThemeRemove_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Repository_ThemeRemove_Call) RunAndReturn(run func(ctx context.Context, id string) error) *Repository_ThemeRemove_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/console/pkg/command/health.go b/services/console/pkg/command/health.go new file mode 100644 index 0000000000..17a15bf096 --- /dev/null +++ b/services/console/pkg/command/health.go @@ -0,0 +1,24 @@ +package command + +import ( + "github.com/urfave/cli/v2" + + "github.com/opencloud-eu/opencloud/pkg/config/configlog" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" + "github.com/opencloud-eu/opencloud/services/console/pkg/config/parser" +) + +// Health is the entrypoint for the health command. +func Health(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "health", + Usage: "check health status", + Category: "info", + Before: func(c *cli.Context) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + return nil + }, + } +} diff --git a/services/console/pkg/command/root.go b/services/console/pkg/command/root.go new file mode 100644 index 0000000000..29506fe328 --- /dev/null +++ b/services/console/pkg/command/root.go @@ -0,0 +1,32 @@ +package command + +import ( + "os" + + "github.com/opencloud-eu/opencloud/pkg/clihelper" + + "github.com/urfave/cli/v2" + + "github.com/opencloud-eu/opencloud/services/console/pkg/command/web" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" +) + +// GetCommands provides all commands for this service +func GetCommands(cfg *config.Config) cli.Commands { + return []*cli.Command{ + Server(cfg), + Health(cfg), + Version(cfg), + web.Commands(cfg), + } +} + +// Execute is the entry point for the opencloud-console command. +func Execute(cfg *config.Config) error { + app := clihelper.DefaultApp(&cli.App{ + Name: "console", + Usage: "Serve console API for OpenCloud", + Commands: GetCommands(cfg), + }) + return app.RunContext(cfg.Context, os.Args) +} diff --git a/services/console/pkg/command/server.go b/services/console/pkg/command/server.go new file mode 100644 index 0000000000..2c45ee9f7f --- /dev/null +++ b/services/console/pkg/command/server.go @@ -0,0 +1,26 @@ +package command + +import ( + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/opencloud-eu/opencloud/pkg/config/configlog" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" + "github.com/opencloud-eu/opencloud/services/console/pkg/config/parser" +) + +// Server is the entrypoint for the server command. +func Server(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "server", + Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name), + Category: "server", + Before: func(c *cli.Context) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + return nil + }, + } +} diff --git a/services/console/pkg/command/version.go b/services/console/pkg/command/version.go new file mode 100644 index 0000000000..8b0141a3d3 --- /dev/null +++ b/services/console/pkg/command/version.go @@ -0,0 +1,53 @@ +package command + +import ( + "fmt" + "os" + + "github.com/opencloud-eu/opencloud/pkg/registry" + "github.com/opencloud-eu/opencloud/pkg/version" + + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/tw" + "github.com/urfave/cli/v2" + + "github.com/opencloud-eu/opencloud/services/console/pkg/config" +) + +// Version prints the service versions of all running instances. +func Version(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "version", + Usage: "print the version of this binary and the running service instances", + Category: "info", + Action: func(c *cli.Context) error { + fmt.Println("Version: " + version.GetString()) + fmt.Printf("Compiled: %s\n", version.Compiled()) + fmt.Println("") + + reg := registry.GetRegistry() + services, err := reg.GetService(cfg.GRPC.Namespace + "." + cfg.Service.Name) + if err != nil { + fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err)) + return err + } + + if len(services) == 0 { + fmt.Println("No running " + cfg.Service.Name + " service found.") + return nil + } + + table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off)) + table.Header([]string{"Version", "Address", "Id"}) + for _, s := range services { + for _, n := range s.Nodes { + if err := table.Append([]string{s.Version, n.Address, n.Id}); err != nil { + return err + } + } + } + + return table.Render() + }, + } +} diff --git a/services/console/pkg/command/web/theme/pull.go b/services/console/pkg/command/web/theme/pull.go new file mode 100644 index 0000000000..d76d69f909 --- /dev/null +++ b/services/console/pkg/command/web/theme/pull.go @@ -0,0 +1,65 @@ +package theme + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/opencloud-eu/opencloud/pkg/config/configlog" + websvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/web/v0" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" + "github.com/opencloud-eu/opencloud/services/console/pkg/config/parser" + "github.com/opencloud-eu/opencloud/services/console/pkg/console" + "github.com/opencloud-eu/opencloud/services/console/pkg/web" +) + +func Pull(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "pull", + Usage: "pull the latest theme from the configured source", + Before: func(c *cli.Context) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + Action: func(cCtx *cli.Context) error { + grpcClient, err := console.NewGRPCClient(cfg) + if err != nil { + return err + } + + token, err := console.ParseJWTToken(cfg.RemoteConsole.JWTToken, cfg.RemoteConsole.JWTTokenKey) + if err != nil { + return err + } + + webRepository, err := web.NewGRPCRepository(web.GRPCRepositoryOptions{ + WebService: websvc.NewWebService("eu.opencloud.api.web", grpcClient), + }) + if err != nil { + return fmt.Errorf("failed to create web repository: %w", err) + } + + consoleRepository, err := web.NewConsoleHTTPRepository(web.ConsoleHTTPRepositoryOptions{ + ConsoleAPIRoot: "https://host.docker.internal:3000/api/", + HTTPClient: console.DefaultHTTPClient, + }) + if err != nil { + return fmt.Errorf("failed to create console repository: %w", err) + } + + webService, err := web.NewService(web.ServiceOptions{ + Repository: webRepository, + ConsoleRepository: consoleRepository, + }) + if err != nil { + return fmt.Errorf("failed to create web service: %w", err) + } + + if err := webService.ThemeApply(context.Background(), token); err != nil { + return fmt.Errorf("failed to apply theme: %w", err) + } + + return nil + }, + } +} diff --git a/services/console/pkg/command/web/theme/theme.go b/services/console/pkg/command/web/theme/theme.go new file mode 100644 index 0000000000..432e67bf30 --- /dev/null +++ b/services/console/pkg/command/web/theme/theme.go @@ -0,0 +1,17 @@ +package theme + +import ( + "github.com/urfave/cli/v2" + + "github.com/opencloud-eu/opencloud/services/console/pkg/config" +) + +func Commands(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "theme", + Usage: "web-theme related commands", + Subcommands: []*cli.Command{ + Pull(cfg), + }, + } +} diff --git a/services/console/pkg/command/web/web.go b/services/console/pkg/command/web/web.go new file mode 100644 index 0000000000..b3cc6d76bc --- /dev/null +++ b/services/console/pkg/command/web/web.go @@ -0,0 +1,18 @@ +package web + +import ( + "github.com/urfave/cli/v2" + + "github.com/opencloud-eu/opencloud/services/console/pkg/command/web/theme" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" +) + +func Commands(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "web", + Usage: "web related commands", + Subcommands: []*cli.Command{ + theme.Commands(cfg), + }, + } +} diff --git a/services/console/pkg/config/config.go b/services/console/pkg/config/config.go new file mode 100644 index 0000000000..07b81b4be7 --- /dev/null +++ b/services/console/pkg/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/shared" + "github.com/opencloud-eu/opencloud/pkg/tracing" +) + +// Config combines all available configuration parts. +type Config struct { + Commons *shared.Commons `yaml:"-"` + Service Service `yaml:"-"` + GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` + Tracing *Tracing `yaml:"tracing"` + Log *Log `yaml:"log"` + Debug Debug `yaml:"debug"` + Context context.Context `yaml:"-"` + GRPC GRPCConfig `yaml:"grpc"` + RemoteConsole *ConsoleRemote `yaml:"remote"` +} + +// Service defines the available service configuration. +type Service struct { + Name string `yaml:"-"` +} + +// Log defines the available log configuration. +type Log struct { + Level string `mapstructure:"level" env:"OC_LOG_LEVEL;CONSOLE_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"%%NEXT%%"` + Pretty bool `mapstructure:"pretty" env:"OC_LOG_PRETTY;CONSOLE_LOG_PRETTY" desc:"Activates pretty log output." introductionVersion:"%%NEXT%%"` + Color bool `mapstructure:"color" env:"OC_LOG_COLOR;CONSOLE_LOG_COLOR" desc:"Activates colorized log output." introductionVersion:"%%NEXT%%"` + File string `mapstructure:"file" env:"OC_LOG_FILE;CONSOLE_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set." introductionVersion:"%%NEXT%%"` +} + +// Debug defines the available debug configuration. +type Debug struct { + Addr string `yaml:"addr" env:"CONSOLE_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"%%NEXT%%"` + Token string `yaml:"token" env:"CONSOLE_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"%%NEXT%%"` + Pprof bool `yaml:"pprof" env:"CONSOLE_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"%%NEXT%%"` + Zpages bool `yaml:"zpages" env:"CONSOLE_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"%%NEXT%%"` +} + +// GRPCConfig defines the available grpc configuration. +type GRPCConfig struct { + Disabled bool `yaml:"disabled" env:"CONSOLE_GRPC_DISABLED" desc:"Disables the GRPC service. Set this to true if the service should only handle events." introductionVersion:"%%NEXT%%"` + Addr string `yaml:"addr" env:"CONSOLE_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"%%NEXT%%"` + Namespace string `yaml:"-"` + TLS *shared.GRPCServiceTLS `yaml:"tls"` +} + +// Tracing defines the available tracing configuration. +type Tracing struct { + Enabled bool `yaml:"enabled" env:"OC_TRACING_ENABLED;CONSOLE_TRACING_ENABLED" desc:"Activates tracing." introductionVersion:"%%NEXT%%"` + Type string `yaml:"type" env:"OC_TRACING_TYPE;CONSOLE_TRACING_TYPE" desc:"The type of tracing. Defaults to '', which is the same as 'jaeger'. Allowed tracing types are 'jaeger' and '' as of now." introductionVersion:"%%NEXT%%"` + Endpoint string `yaml:"endpoint" env:"OC_TRACING_ENDPOINT;CONSOLE_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent." introductionVersion:"%%NEXT%%"` + Collector string `yaml:"collector" env:"OC_TRACING_COLLECTOR;CONSOLE_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset." introductionVersion:"%%NEXT%%"` +} + +// Convert Tracing to the tracing package's Config struct. +func (t Tracing) Convert() tracing.Config { + return tracing.Config{ + Enabled: t.Enabled, + Type: t.Type, + Endpoint: t.Endpoint, + Collector: t.Collector, + } +} diff --git a/services/console/pkg/config/console.go b/services/console/pkg/config/console.go new file mode 100644 index 0000000000..4c508f585b --- /dev/null +++ b/services/console/pkg/config/console.go @@ -0,0 +1,6 @@ +package config + +type ConsoleRemote struct { + JWTToken string `yaml:"jwt_token" env:"CONSOLE_REMOTE_JWT_TOKEN" desc:"The JWT token used to authenticate requests to the console service." introductionVersion:"%%NEXT%%"` + JWTTokenKey string `yaml:"jwt_token_key" env:"CONSOLE_REMOTE_JWT_TOKEN_KEY" desc:"The key used to sign JWT tokens for authenticating requests to the console service." introductionVersion:"%%NEXT%%"` +} diff --git a/services/console/pkg/config/defaults/defaultconfig.go b/services/console/pkg/config/defaults/defaultconfig.go new file mode 100644 index 0000000000..a6447cf5b1 --- /dev/null +++ b/services/console/pkg/config/defaults/defaultconfig.go @@ -0,0 +1,72 @@ +package defaults + +import ( + "github.com/opencloud-eu/opencloud/pkg/structs" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" +) + +// FullDefaultConfig returns a fully initialized default configuration +func FullDefaultConfig() *config.Config { + cfg := DefaultConfig() + + EnsureDefaults(cfg) + + return cfg +} + +// DefaultConfig returns a basic default configuration +func DefaultConfig() *config.Config { + return &config.Config{ + Debug: config.Debug{ + Addr: "127.0.0.1:9225", + Token: "", + }, + GRPC: config.GRPCConfig{ + Addr: "127.0.0.1:9222", + Namespace: "eu.opencloud.api", + }, + Service: config.Service{ + Name: "console", + }, + } +} + +// EnsureDefaults adds default values to the configuration if they are not set yet +func EnsureDefaults(cfg *config.Config) { + if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil { + cfg.Log = &config.Log{ + Level: cfg.Commons.Log.Level, + Pretty: cfg.Commons.Log.Pretty, + Color: cfg.Commons.Log.Color, + File: cfg.Commons.Log.File, + } + } else if cfg.Log == nil { + cfg.Log = &config.Log{} + } + + if cfg.GRPC.TLS == nil && cfg.Commons != nil { + cfg.GRPC.TLS = structs.CopyOrZeroValue(cfg.Commons.GRPCServiceTLS) + } + + if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil { + cfg.Tracing = &config.Tracing{ + Enabled: cfg.Commons.Tracing.Enabled, + Type: cfg.Commons.Tracing.Type, + Endpoint: cfg.Commons.Tracing.Endpoint, + Collector: cfg.Commons.Tracing.Collector, + } + } else if cfg.Tracing == nil { + cfg.Tracing = &config.Tracing{} + } + + if cfg.RemoteConsole == nil { + cfg.RemoteConsole = &config.ConsoleRemote{} + } +} + +// Sanitize sanitizes the configuration +func Sanitize(cfg *config.Config) { + if cfg.GRPCClientTLS == nil && cfg.Commons != nil { + cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS) + } +} diff --git a/services/console/pkg/config/parser/parse.go b/services/console/pkg/config/parser/parse.go new file mode 100644 index 0000000000..530a4beffb --- /dev/null +++ b/services/console/pkg/config/parser/parse.go @@ -0,0 +1,45 @@ +package parser + +import ( + "errors" + + occfg "github.com/opencloud-eu/opencloud/pkg/config" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" + "github.com/opencloud-eu/opencloud/services/console/pkg/config/defaults" + + "github.com/opencloud-eu/opencloud/pkg/config/envdecode" +) + +// ParseConfig loads configuration from known paths. +func ParseConfig(cfg *config.Config) error { + err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg) + if err != nil { + return err + } + + defaults.EnsureDefaults(cfg) + + // load all env variables relevant to the config in the current context. + if err := envdecode.Decode(cfg); err != nil { + // no environment variable set for this config is an expected "error" + if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { + return err + } + } + + defaults.Sanitize(cfg) + + return Validate(cfg) +} + +func Validate(cfg *config.Config) error { + if cfg.RemoteConsole.JWTToken == "" { + return errors.New("jwt_token must be provided in remote console config") + } + + if cfg.RemoteConsole.JWTTokenKey == "" { + return errors.New("jwt_token_key must be provided in remote console config") + } + + return nil +} diff --git a/services/console/pkg/console/grpc.go b/services/console/pkg/console/grpc.go new file mode 100644 index 0000000000..369a4a38f2 --- /dev/null +++ b/services/console/pkg/console/grpc.go @@ -0,0 +1,28 @@ +package console + +import ( + "go-micro.dev/v4/client" + + "github.com/opencloud-eu/opencloud/pkg/service/grpc" + + "github.com/opencloud-eu/opencloud/pkg/tracing" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" +) + +func NewGRPCClient(cfg *config.Config) (client.Client, error) { + traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name) + if err != nil { + return nil, err + } + + grpcClient, err := grpc.NewClient( + append(grpc.GetClientOptions(cfg.GRPCClientTLS), + grpc.WithTraceProvider(traceProvider), + )..., + ) + if err != nil { + return nil, err + } + + return grpcClient, nil +} diff --git a/services/console/pkg/console/http.go b/services/console/pkg/console/http.go new file mode 100644 index 0000000000..e44664b6bc --- /dev/null +++ b/services/console/pkg/console/http.go @@ -0,0 +1,35 @@ +package console + +import ( + "crypto/tls" + "fmt" + "io" + "net/http" +) + +var DefaultHTTPClient = &http.Client{Transport: func() *http.Transport { + t := http.DefaultTransport.(*http.Transport).Clone() + t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + return t +}()} + +type HTTPRequest struct { + *http.Request +} + +func NewHTTPRequest(method, url string, body io.Reader) (*HTTPRequest, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create http request: %w", err) + } + + return &HTTPRequest{req}, nil +} + +func (r *HTTPRequest) SetBearerAuth(bearer string) { + r.Header.Add("Authorization", "Bearer "+bearer) +} + +func (r *HTTPRequest) AsDefault() *http.Request { + return r.Request +} diff --git a/services/console/pkg/console/jwt.go b/services/console/pkg/console/jwt.go new file mode 100644 index 0000000000..e23241518f --- /dev/null +++ b/services/console/pkg/console/jwt.go @@ -0,0 +1,39 @@ +package console + +import ( + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +var ErrJWTTokenUnknownClaim = errors.New("unknown claim in JWT token") + +func ParseJWTToken(tokenString, key string) (*jwt.Token, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(key), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + if token == nil || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return token, nil +} + +func GetJWTClaim[T any](token *jwt.Token, claimKey string) (T, error) { + var zero T + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return zero, fmt.Errorf("failed to parse token claims") + } + + claimValue, ok := claims[claimKey].(T) + if !ok { + return zero, fmt.Errorf("(%w) failed to get claim %s", ErrJWTTokenUnknownClaim, claimKey) + } + + return claimValue, nil +} diff --git a/services/console/pkg/logging/logging.go b/services/console/pkg/logging/logging.go new file mode 100644 index 0000000000..4fd2693aeb --- /dev/null +++ b/services/console/pkg/logging/logging.go @@ -0,0 +1,17 @@ +package logging + +import ( + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/console/pkg/config" +) + +// Configure initializes a service-specific logger instance. +func Configure(name string, cfg *config.Log) log.Logger { + return log.NewLogger( + log.Name(name), + log.Level(cfg.Level), + log.Pretty(cfg.Pretty), + log.Color(cfg.Color), + log.File(cfg.File), + ) +} diff --git a/services/console/pkg/web/repository.go b/services/console/pkg/web/repository.go new file mode 100644 index 0000000000..5ebf2bb9fe --- /dev/null +++ b/services/console/pkg/web/repository.go @@ -0,0 +1,123 @@ +package web + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + + webService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/web/v0" + "github.com/opencloud-eu/opencloud/services/console/pkg/console" +) + +type GRPCRepositoryOptions struct { + WebService webService.WebService `validate:"required"` +} + +func (o GRPCRepositoryOptions) Validate() error { + if err := validate.Struct(o); err != nil { + return fmt.Errorf("(%w): %w", ErrOptionsInvalid, err) + } + + return nil +} + +type GRPCRepository struct { + webService webService.WebService +} + +func NewGRPCRepository(o GRPCRepositoryOptions) (GRPCRepository, error) { + if err := o.Validate(); err != nil { + return GRPCRepository{}, err + } + + return GRPCRepository{ + webService: o.WebService, + }, nil +} + +func (r GRPCRepository) ThemeExists(ctx context.Context, id string) (bool, error) { + resp, err := r.webService.ThemeExists(ctx, &webService.ThemeExistsRequest{Id: id}) + if err != nil { + return false, fmt.Errorf("(%w) %w", ErrRequest, err) + } + + return resp.Exists, nil +} + +func (r GRPCRepository) ThemeAdd(ctx context.Context, id string, tr io.Reader) error { + tb, err := io.ReadAll(tr) + if err != nil { + return fmt.Errorf("failed to read theme data: %w", err) + } + + if _, err := r.webService.ThemeAdd(ctx, &webService.ThemeAddRequest{Id: id, Data: tb}); err != nil { + return fmt.Errorf("(%w) %w", ErrRequest, err) + } + + return nil +} + +func (r GRPCRepository) ThemeRemove(ctx context.Context, id string) error { + if _, err := r.webService.ThemeRemove(ctx, &webService.ThemeRemoveRequest{Id: id}); err != nil { + return fmt.Errorf("(%w) %w", ErrRequest, err) + } + + return nil +} + +// ################################ + +type ConsoleHTTPRepositoryOptions struct { + HTTPClient *http.Client `validate:"required"` + ConsoleAPIRoot string `validate:"http_url"` +} + +func (o ConsoleHTTPRepositoryOptions) Validate() error { + if err := validate.Struct(o); err != nil { + return fmt.Errorf("(%w): %w", ErrOptionsInvalid, err) + } + + return nil +} + +type ConsoleHTTPRepository struct { + httpClient *http.Client `validate:"required"` + consoleAPIRoot string `validate:"http_url"` +} + +func NewConsoleHTTPRepository(o ConsoleHTTPRepositoryOptions) (ConsoleHTTPRepository, error) { + if err := o.Validate(); err != nil { + return ConsoleHTTPRepository{}, err + } + + return ConsoleHTTPRepository{ + httpClient: o.HTTPClient, + consoleAPIRoot: o.ConsoleAPIRoot, + }, nil +} + +func (r ConsoleHTTPRepository) ThemeGet(ctx context.Context, bearer, tenantID string) (io.ReadCloser, error) { + u, err := url.Parse(r.consoleAPIRoot) + if err != nil { + return nil, fmt.Errorf("failed to parse console api root url: %v", err) + } + + req, err := console.NewHTTPRequest(http.MethodGet, u.JoinPath("tenant", tenantID, "client", "v1", "theme").String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create http request: %w", err) + } + req.SetBearerAuth(bearer) + req.WithContext(ctx) + + res, err := r.httpClient.Do(req.AsDefault()) + switch { + case err != nil: + return nil, fmt.Errorf("(%w) failed to execute http request: %w", ErrRequest, err) + case res.StatusCode != http.StatusOK: + return nil, fmt.Errorf("(%w) failed to fetch theme, status code: %d", ErrRequest, res.StatusCode) + } + + return res.Body, nil +} diff --git a/services/console/pkg/web/service.go b/services/console/pkg/web/service.go new file mode 100644 index 0000000000..fc8c5d0263 --- /dev/null +++ b/services/console/pkg/web/service.go @@ -0,0 +1,87 @@ +package web + +import ( + "context" + "fmt" + "io" + + "github.com/golang-jwt/jwt/v5" + + "github.com/opencloud-eu/opencloud/services/console/pkg/console" +) + +const ( + tenantIDClaim = "tenantId" + themeID = "_console" +) + +type Repository interface { + ThemeExists(ctx context.Context, id string) (bool, error) + ThemeRemove(ctx context.Context, id string) error + ThemeAdd(ctx context.Context, id string, r io.Reader) error +} + +type ConsoleRepository interface { + ThemeGet(ctx context.Context, token, tenantID string) (io.ReadCloser, error) +} + +type ServiceOptions struct { + Repository Repository `validate:"required"` + ConsoleRepository ConsoleRepository `validate:"required"` +} + +func (o ServiceOptions) Validate() error { + if err := validate.Struct(o); err != nil { + return fmt.Errorf("(%w): %w", ErrOptionsInvalid, err) + } + + return nil +} + +type Service struct { + repository Repository + consoleRepository ConsoleRepository +} + +func NewService(o ServiceOptions) (Service, error) { + if err := o.Validate(); err != nil { + return Service{}, err + } + + return Service{ + repository: o.Repository, + //consoleRepository: o.consoleRepository, + }, nil +} + +func (s Service) ThemeApply(ctx context.Context, token *jwt.Token) error { + tenantId, err := console.GetJWTClaim[string](token, tenantIDClaim) + if err != nil { + return fmt.Errorf("could not get tenantId claim from token: %w", err) + } + + data, err := s.consoleRepository.ThemeGet(context.Background(), token.Raw, tenantId) + if err != nil { + return fmt.Errorf("could not get theme for tenant %s: %w", tenantId, err) + } + defer func() { + _ = data.Close() + }() + + exists, err := s.repository.ThemeExists(ctx, themeID) + if err != nil { + return fmt.Errorf("could not check if theme %s exists: %w", themeID, err) + } + + if exists { + if err := s.repository.ThemeRemove(ctx, themeID); err != nil { + return fmt.Errorf("could not remove existing theme %s: %w", themeID, err) + } + } + + if err := s.repository.ThemeAdd(ctx, themeID, data); err != nil { + return fmt.Errorf("could not add theme %s: %w", themeID, err) + } + + return nil +} diff --git a/services/console/pkg/web/service_test.go b/services/console/pkg/web/service_test.go new file mode 100644 index 0000000000..1d950dea97 --- /dev/null +++ b/services/console/pkg/web/service_test.go @@ -0,0 +1,33 @@ +package web_test + +import ( + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + + "github.com/opencloud-eu/opencloud/services/console/mocks" + "github.com/opencloud-eu/opencloud/services/console/pkg/console" + "github.com/opencloud-eu/opencloud/services/console/pkg/web" +) + +func TestService_NewService(t *testing.T) { + t.Run("valid options", func(t *testing.T) { + _, err := web.NewService(web.ServiceOptions{}) + assert.ErrorIs(t, err, web.ErrOptionsInvalid) + }) +} + +func TestService_ThemeApply(t *testing.T) { + options := web.ServiceOptions{ + Repository: mocks.NewRepository(t), + ConsoleRepository: mocks.NewConsoleRepository(t), + } + service, err := web.NewService(options) + assert.NoError(t, err) + + t.Run("fails without tenantID claim", func(t *testing.T) { + err := service.ThemeApply(t.Context(), jwt.New(jwt.SigningMethodHS256)) + assert.ErrorIs(t, err, console.ErrJWTTokenUnknownClaim) + }) +} diff --git a/services/console/pkg/web/web.go b/services/console/pkg/web/web.go new file mode 100644 index 0000000000..65f4d8e7c7 --- /dev/null +++ b/services/console/pkg/web/web.go @@ -0,0 +1,13 @@ +package web + +import ( + "errors" + + "github.com/go-playground/validator/v10" +) + +var ( + validate = validator.New() + ErrOptionsInvalid = errors.New("options are invalid") + ErrRequest = errors.New("request failed") +) diff --git a/services/web/Makefile b/services/web/Makefile index a30b85f5ae..17157fa5f7 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -11,6 +11,13 @@ include ../../.make/default.mk include ../../.make/go.mk include ../../.make/release.mk include ../../.make/docs.mk +include ../../.make/protobuf.mk + +.PHONY: go-generate +go-generate: protobuf + +.PHONY: protobuf +protobuf: buf-generate .PHONY: node-generate-dev node-generate-dev: pull-assets @@ -18,7 +25,6 @@ node-generate-dev: pull-assets .PHONY: node-generate-prod node-generate-prod: download-assets - .PHONY: pull-assets pull-assets: echo "using unreleased assets from branch $(WEB_ASSETS_BRANCH), this should not be used for official releases" diff --git a/services/web/pkg/command/server.go b/services/web/pkg/command/server.go index 6b0c4a6244..5bd354b922 100644 --- a/services/web/pkg/command/server.go +++ b/services/web/pkg/command/server.go @@ -5,18 +5,20 @@ import ( "encoding/json" "fmt" "os" - "os/signal" + + "github.com/oklog/run" + "github.com/urfave/cli/v2" "github.com/opencloud-eu/opencloud/pkg/config/configlog" - "github.com/opencloud-eu/opencloud/pkg/runner" + ogrpc "github.com/opencloud-eu/opencloud/pkg/service/grpc" "github.com/opencloud-eu/opencloud/pkg/tracing" "github.com/opencloud-eu/opencloud/services/web/pkg/config" "github.com/opencloud-eu/opencloud/services/web/pkg/config/parser" "github.com/opencloud-eu/opencloud/services/web/pkg/logging" "github.com/opencloud-eu/opencloud/services/web/pkg/metrics" "github.com/opencloud-eu/opencloud/services/web/pkg/server/debug" + "github.com/opencloud-eu/opencloud/services/web/pkg/server/grpc" "github.com/opencloud-eu/opencloud/services/web/pkg/server/http" - "github.com/urfave/cli/v2" ) // Server is the entrypoint for the server command. @@ -48,16 +50,18 @@ func Server(cfg *config.Config) *cli.Command { } } - var cancel context.CancelFunc - if cfg.Context == nil { - cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...) - defer cancel() - } - ctx := cfg.Context + cfg.GrpcClient, err = ogrpc.NewClient( + append(ogrpc.GetClientOptions(cfg.GRPCClientTLS), ogrpc.WithTraceProvider(traceProvider))..., + ) + + var ( + gr = run.Group{} + ctx, cancel = context.WithCancel(c.Context) + m = metrics.New() + ) - m := metrics.New() + defer cancel() - gr := runner.NewGroup() { server, err := http.Server( http.Logger(logger), @@ -76,7 +80,73 @@ func Server(cfg *config.Config) *cli.Command { return err } - gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server)) + gr.Add(func() error { + err := server.Run() + if err != nil { + logger.Error(). + Err(err). + Str("transport", "http"). + Msg("Failed to start server") + } + return err + }, func(err error) { + if err == nil { + logger.Info(). + Str("transport", "http"). + Str("server", cfg.Service.Name). + Msg("Shutting down server") + } else { + logger.Error().Err(err). + Str("transport", "http"). + Str("server", cfg.Service.Name). + Msg("Shutting down server") + } + + cancel() + }) + } + + { + grpcServer, err := grpc.Server( + grpc.Config(cfg), + grpc.Logger(logger), + grpc.Name(cfg.Service.Name), + grpc.Context(ctx), + grpc.JWTSecret(cfg.TokenManager.JWTSecret), + grpc.TraceProvider(traceProvider), + ) + if err != nil { + logger.Info(). + Err(err). + Str("transport", "grpc"). + Msg("Failed to initialize server") + return err + } + + gr.Add(func() error { + err := grpcServer.Run() + if err != nil { + logger.Error(). + Err(err). + Str("transport", "grpc"). + Msg("Failed to start server") + } + return err + }, func(err error) { + if err == nil { + logger.Info(). + Str("transport", "grpc"). + Str("server", cfg.Service.Name). + Msg("Shutting down server") + } else { + logger.Error().Err(err). + Str("transport", "grpc"). + Str("server", cfg.Service.Name). + Msg("Shutting down server") + } + + cancel() + }) } { @@ -90,18 +160,13 @@ func Server(cfg *config.Config) *cli.Command { return err } - gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer)) + gr.Add(debugServer.ListenAndServe, func(_ error) { + _ = debugServer.Shutdown(ctx) + cancel() + }) } - grResults := gr.Run(ctx) - - // return the first non-nil error found in the results - for _, grResult := range grResults { - if grResult.RunnerError != nil { - return grResult.RunnerError - } - } - return nil + return gr.Run() }, } } diff --git a/services/web/pkg/config/config.go b/services/web/pkg/config/config.go index 4fe5217080..80c0b82454 100644 --- a/services/web/pkg/config/config.go +++ b/services/web/pkg/config/config.go @@ -3,6 +3,8 @@ package config import ( "context" + "go-micro.dev/v4/client" + "github.com/opencloud-eu/opencloud/pkg/shared" ) @@ -18,6 +20,9 @@ type Config struct { HTTP HTTP `yaml:"http"` + GRPC GRPCConfig `yaml:"grpc"` + GrpcClient client.Client `yaml:"-"` + Asset Asset `yaml:"asset"` File string `yaml:"file" env:"WEB_UI_CONFIG_FILE" desc:"Read the OpenCloud Web json based configuration from this path/file. The config file takes precedence over WEB_OPTION_xxx environment variables. See the text description for more details." introductionVersion:"1.0.0"` Web Web `yaml:"web"` @@ -25,8 +30,21 @@ 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"` - Context context.Context `yaml:"-"` + GatewayAddress string `yaml:"gateway_addr" env:"WEB_GATEWAY_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"` + Context context.Context `yaml:"-"` + GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` + + Metadata Metadata `yaml:"metadata_config"` +} + +// Metadata configures the metadata store to use +type Metadata struct { + GatewayAddress string `yaml:"gateway_addr" env:"WEB_STORAGE_GATEWAY_GRPC_ADDR;STORAGE_GATEWAY_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"%%NEXT%%"` + StorageAddress string `yaml:"storage_addr" env:"WEB_STORAGE_GRPC_ADDR;STORAGE_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"%%NEXT%%"` + + SystemUserID string `yaml:"system_user_id" env:"OC_SYSTEM_USER_ID;WEB_SYSTEM_USER_ID" desc:"ID of the OpenCloud STORAGE-SYSTEM system user. Admins need to set the ID for the STORAGE-SYSTEM system user in this config option which is then used to reference the user. Any reasonable long string is possible, preferably this would be an UUIDv4 format." introductionVersion:"%%NEXT%%"` + SystemUserIDP string `yaml:"system_user_idp" env:"OC_SYSTEM_USER_IDP;WEB_SYSTEM_USER_IDP" desc:"IDP of the OpenCloud STORAGE-SYSTEM system user." introductionVersion:"%%NEXT%%"` + SystemUserAPIKey string `yaml:"system_user_api_key" env:"OC_SYSTEM_USER_API_KEY" desc:"API key for the STORAGE-SYSTEM system user." introductionVersion:"%%NEXT%%"` } // Asset defines the available asset configuration. diff --git a/services/web/pkg/config/defaults/defaultconfig.go b/services/web/pkg/config/defaults/defaultconfig.go index 68e41a60d5..2278243a0f 100644 --- a/services/web/pkg/config/defaults/defaultconfig.go +++ b/services/web/pkg/config/defaults/defaultconfig.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/opencloud-eu/opencloud/pkg/config/defaults" + "github.com/opencloud-eu/opencloud/pkg/structs" "github.com/opencloud-eu/opencloud/services/web/pkg/config" ) @@ -25,6 +26,11 @@ func DefaultConfig() *config.Config { Pprof: false, Zpages: false, }, + GRPC: config.GRPCConfig{ + Addr: "127.0.0.1:9221", + Namespace: "eu.opencloud.api", + Protocol: "tcp", + }, HTTP: config.HTTP{ Addr: "127.0.0.1:9100", Root: "/", @@ -85,12 +91,16 @@ func DefaultConfig() *config.Config { ThemesPath: filepath.Join(defaults.BaseDataPath(), "web/assets/themes"), }, GatewayAddress: "eu.opencloud.api.gateway", + Metadata: config.Metadata{ + GatewayAddress: "eu.opencloud.api.storage-system", + StorageAddress: "eu.opencloud.api.storage-system", + SystemUserIDP: "internal", + }, Web: config.Web{ ThemeServer: "https://localhost:9200", ThemePath: "/themes/opencloud/theme.json", Config: config.WebConfig{ Server: "https://localhost:9200", - Theme: "", OpenIDConnect: config.OIDC{ MetadataURL: "", Authority: "https://localhost:9200", @@ -159,6 +169,14 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") { cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.OpenCloudURL} } + + if cfg.Metadata.SystemUserAPIKey == "" && cfg.Commons != nil && cfg.Commons.SystemUserAPIKey != "" { + cfg.Metadata.SystemUserAPIKey = cfg.Commons.SystemUserAPIKey + } + + if cfg.Metadata.SystemUserID == "" && cfg.Commons != nil && cfg.Commons.SystemUserID != "" { + cfg.Metadata.SystemUserID = cfg.Commons.SystemUserID + } } // Sanitize sanitized the configuration @@ -197,4 +215,11 @@ func Sanitize(cfg *config.Config) { cfg.Web.Config.Options.Embed.DelegateAuthenticationOrigin == "" { cfg.Web.Config.Options.Embed = nil } + if cfg.GRPCClientTLS == nil && cfg.Commons != nil { + cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS) + } + + if cfg.GRPC.TLS == nil && cfg.Commons != nil { + cfg.GRPC.TLS = structs.CopyOrZeroValue(cfg.Commons.GRPCServiceTLS) + } } diff --git a/services/web/pkg/config/grpc.go b/services/web/pkg/config/grpc.go new file mode 100644 index 0000000000..44eca8a5f6 --- /dev/null +++ b/services/web/pkg/config/grpc.go @@ -0,0 +1,11 @@ +package config + +import "github.com/opencloud-eu/opencloud/pkg/shared" + +// GRPCConfig defines the available grpc configuration. +type GRPCConfig struct { + Addr string `yaml:"addr" env:"WEB_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"%%NEXT%%"` + Namespace string `yaml:"-"` + TLS *shared.GRPCServiceTLS `yaml:"tls"` + Protocol string `yaml:"protocol" env:"OC_GRPC_PROTOCOL;WEB_GRPC_PROTOCOL" desc:"The transport protocol of the GRPC service." introductionVersion:"%%NEXT%%"` +} diff --git a/services/web/pkg/fs/fs.go b/services/web/pkg/fs/fs.go new file mode 100644 index 0000000000..bb6c74e7af --- /dev/null +++ b/services/web/pkg/fs/fs.go @@ -0,0 +1,52 @@ +package fs + +import ( + "context" + "fmt" + "time" + + revaMetadata "github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata" + + "github.com/opencloud-eu/opencloud/pkg/storage/metadata" + "github.com/opencloud-eu/opencloud/pkg/x/io/fsx" + metadataFs "github.com/opencloud-eu/opencloud/pkg/x/io/fsx/cs3/metadata" + "github.com/opencloud-eu/opencloud/services/web" + "github.com/opencloud-eu/opencloud/services/web/pkg/config" +) + +func NewThemeFS(c *config.Config) (*fsx.FallbackFS, error) { + storage, err := revaMetadata.NewCS3Storage( + c.Metadata.GatewayAddress, + c.Metadata.StorageAddress, + c.Metadata.SystemUserID, + c.Metadata.SystemUserIDP, + c.Metadata.SystemUserAPIKey, + ) + if err != nil { + return nil, err + } + + storage, err = metadata.NewLazyStorage(storage) + if err != nil { + return nil, err + } + + time.Sleep(3 * time.Second) // fixme: wait for the storage to be initialized + + if err := storage.Init(context.Background(), "web-storage"); err != nil { + return nil, err + } + + storageFS := metadataFs.NewMetadataFs(storage) + if err := storageFS.MkdirAll("assets/themes", 0755); err != nil { + return nil, fmt.Errorf("failed to create themes directory: %w", err) + } + + return fsx.NewFallbackFS( + fsx.NewBasePathFs(fsx.FromAfero(storageFS), "assets/themes"), + fsx.NewFallbackFS( + fsx.NewReadOnlyFs(fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes")), + fsx.NewReadOnlyFs(fsx.NewBasePathFs(fsx.NewOsFs(), c.Asset.ThemesPath)), + ), + ), nil +} diff --git a/services/web/pkg/server/grpc/option.go b/services/web/pkg/server/grpc/option.go new file mode 100644 index 0000000000..d83163d748 --- /dev/null +++ b/services/web/pkg/server/grpc/option.go @@ -0,0 +1,94 @@ +package grpc + +import ( + "context" + + "go.opentelemetry.io/otel/trace" + + "github.com/opencloud-eu/opencloud/pkg/log" + svc "github.com/opencloud-eu/opencloud/services/search/pkg/service/grpc/v0" + "github.com/opencloud-eu/opencloud/services/web/pkg/config" + "github.com/opencloud-eu/opencloud/services/web/pkg/metrics" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Name string + Logger log.Logger + Context context.Context + Config *config.Config + Metrics *metrics.Metrics + Handler *svc.Service + JWTSecret string + TraceProvider trace.TracerProvider +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Name provides a name for the service. +func Name(val string) Option { + return func(o *Options) { + o.Name = val + } +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// Metrics provides a function to set the metrics option. +func Metrics(val *metrics.Metrics) Option { + return func(o *Options) { + o.Metrics = val + } +} + +// Handler provides a function to set the handler option. +func Handler(val *svc.Service) Option { + return func(o *Options) { + o.Handler = val + } +} + +// JWTSecret provides a function to set the Config option. +func JWTSecret(val string) Option { + return func(o *Options) { + o.JWTSecret = val + } +} + +// TraceProvider provides a function to set the trace provider option. +func TraceProvider(val trace.TracerProvider) Option { + return func(o *Options) { + o.TraceProvider = val + } +} diff --git a/services/web/pkg/server/grpc/server.go b/services/web/pkg/server/grpc/server.go new file mode 100644 index 0000000000..b349411323 --- /dev/null +++ b/services/web/pkg/server/grpc/server.go @@ -0,0 +1,67 @@ +package grpc + +import ( + "fmt" + + "github.com/opencloud-eu/opencloud/pkg/service/grpc" + "github.com/opencloud-eu/opencloud/pkg/version" + websvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/web/v0" + "github.com/opencloud-eu/opencloud/services/web/pkg/fs" + svc "github.com/opencloud-eu/opencloud/services/web/pkg/service/grpc/v0" +) + +// Server initializes a new go-micro service ready to run +func Server(opts ...Option) (grpc.Service, error) { + options := newOptions(opts...) + + service, err := grpc.NewServiceWithClient( + options.Config.GrpcClient, + grpc.TLSEnabled(options.Config.GRPC.TLS.Enabled), + grpc.TLSCert( + options.Config.GRPC.TLS.Cert, + options.Config.GRPC.TLS.Key, + ), + grpc.Name(options.Config.Service.Name), + grpc.Context(options.Context), + grpc.Address(options.Config.GRPC.Addr), + grpc.Namespace(options.Config.GRPC.Namespace), + grpc.Logger(options.Logger), + grpc.Version(version.GetString()), + grpc.TraceProvider(options.TraceProvider), + ) + if err != nil { + options.Logger.Fatal().Err(err).Msg("Error creating web service") + return grpc.Service{}, err + } + + themeFS, err := fs.NewThemeFS(options.Config) + if err != nil { + return grpc.Service{}, fmt.Errorf("could not initialize theme filesystem: %w", err) + } + + handle, err := svc.NewHandler( + svc.Config(options.Config), + svc.Logger(options.Logger), + svc.JWTSecret(options.JWTSecret), + svc.TracerProvider(options.TraceProvider), + svc.ThemeFS(themeFS), + ) + if err != nil { + options.Logger.Error(). + Err(err). + Msg("Error initializing web service") + return grpc.Service{}, err + } + + if err := websvc.RegisterWebServiceHandler( + service.Server(), + handle, + ); err != nil { + options.Logger.Error(). + Err(err). + Msg("Error registering web provider handler") + return grpc.Service{}, err + } + + return service, nil +} diff --git a/services/web/pkg/server/http/server.go b/services/web/pkg/server/http/server.go index 25f210b13b..b435248ad3 100644 --- a/services/web/pkg/server/http/server.go +++ b/services/web/pkg/server/http/server.go @@ -6,7 +6,6 @@ import ( chimiddleware "github.com/go-chi/chi/v5/middleware" "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" @@ -16,7 +15,10 @@ import ( "github.com/opencloud-eu/opencloud/pkg/x/io/fsx" "github.com/opencloud-eu/opencloud/services/web" "github.com/opencloud-eu/opencloud/services/web/pkg/apps" + "github.com/opencloud-eu/opencloud/services/web/pkg/fs" svc "github.com/opencloud-eu/opencloud/services/web/pkg/service/v0" + + "go-micro.dev/v4" ) var ( @@ -71,10 +73,11 @@ func Server(opts ...Option) (http.Service, error) { fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.CorePath), fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/core"), ) - themeFS := fsx.NewFallbackFS( - fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.ThemesPath), - fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"), - ) + + themeFS, err := fs.NewThemeFS(options.Config) + if err != nil { + return http.Service{}, fmt.Errorf("could not initialize theme filesystem: %w", err) + } handle, err := svc.NewService( svc.Logger(options.Logger), diff --git a/services/web/pkg/service/grpc/v0/option.go b/services/web/pkg/service/grpc/v0/option.go new file mode 100644 index 0000000000..e0903033c4 --- /dev/null +++ b/services/web/pkg/service/grpc/v0/option.go @@ -0,0 +1,68 @@ +package service + +import ( + "go.opentelemetry.io/otel/trace" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/x/io/fsx" + "github.com/opencloud-eu/opencloud/services/web/pkg/config" + "github.com/opencloud-eu/opencloud/services/web/pkg/theme" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger + Config *config.Config + JWTSecret string + TracerProvider trace.TracerProvider + ThemeService *theme.Service + ThemeFS *fsx.FallbackFS +} + +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the Logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Config provides a function to set the Config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// JWTSecret provides a function to set the Config option. +func JWTSecret(val string) Option { + return func(o *Options) { + o.JWTSecret = val + } +} + +// TracerProvider provides a function to set the TracerProvider option +func TracerProvider(val trace.TracerProvider) Option { + return func(o *Options) { + o.TracerProvider = val + } +} + +// ThemeFS provides a function to set the themeFS option. +func ThemeFS(val *fsx.FallbackFS) Option { + return func(o *Options) { + o.ThemeFS = val + } +} diff --git a/services/web/pkg/service/grpc/v0/service.go b/services/web/pkg/service/grpc/v0/service.go new file mode 100644 index 0000000000..a305a1c6d7 --- /dev/null +++ b/services/web/pkg/service/grpc/v0/service.go @@ -0,0 +1,67 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + + "github.com/opencloud-eu/opencloud/pkg/log" + websvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/web/v0" + "github.com/opencloud-eu/opencloud/services/web/pkg/config" + "github.com/opencloud-eu/opencloud/services/web/pkg/theme" +) + +// NewHandler returns a service implementation for Service. +func NewHandler(opts ...Option) (websvc.WebServiceHandler, error) { + options := newOptions(opts...) + logger := options.Logger + cfg := options.Config + + themeService, err := theme.NewService( + theme.ServiceOptions{}. + WithThemeFS(options.ThemeFS.Primary()), + ) + if err != nil { + return nil, fmt.Errorf("could not initialize theme service: %w", err) + } + + return &Service{ + log: logger, + cfg: cfg, + themeService: themeService, + }, nil +} + +type Service struct { + log log.Logger + cfg *config.Config + themeService *theme.Service +} + +func (s Service) ThemeAdd(_ context.Context, req *websvc.ThemeAddRequest, res *websvc.ThemeAddResponse) error { + zr, err := zip.NewReader(bytes.NewReader(req.Data), int64(len(req.Data))) + if err != nil { + return err + } + + if err := s.themeService.Add(req.Id, zr); err != nil { + return fmt.Errorf("could not add theme %s: %w", req.Id, err) + } + + return nil +} + +func (s Service) ThemeRemove(_ context.Context, req *websvc.ThemeRemoveRequest, res *websvc.ThemeRemoveResponse) error { + if err := s.themeService.Remove(req.Id); err != nil { + return fmt.Errorf("could not remove theme %s: %w", req.Id, err) + } + + return nil +} + +func (s Service) ThemeExists(_ context.Context, req *websvc.ThemeExistsRequest, res *websvc.ThemeExistsResponse) error { + res.Exists = s.themeService.Exists(req.Id) + + return nil +} diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index 6b6cc89b63..6bf67b8a9f 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -10,23 +10,24 @@ import ( "strings" "time" - gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/go-chi/chi/v5" - "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/riandyrn/otelchi" "github.com/opencloud-eu/opencloud/pkg/account" "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/pkg/x/io/fsx" "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" ) -// ErrConfigInvalid is returned when the config parse is invalid. -var ErrConfigInvalid = `Invalid or missing config` +var ( + _consoleThemeID = "_console" + _forcedThemeID = _consoleThemeID + _consoleThemePath = path.Join("themes", _forcedThemeID, "theme.json") + _forcedThemePath = _consoleThemePath +) // Service defines the service handlers. type Service interface { @@ -50,22 +51,28 @@ func NewService(opts ...Option) (Service, error) { ), ) - svc := Web{ - logger: options.Logger, - config: options.Config, - mux: m, - coreFS: options.CoreFS, - themeFS: options.ThemeFS, - gatewaySelector: options.GatewaySelector, - } - themeService, err := theme.NewService( theme.ServiceOptions{}. - WithThemeFS(options.ThemeFS). - WithGatewaySelector(options.GatewaySelector), + WithThemeFS(options.ThemeFS), ) if err != nil { - return svc, err + return Web{}, err + } + + themeAPI, err := theme.NewHTTP( + theme.HTTPOptions{}. + WithService(themeService). + WithLogger(options.Logger), + ) + if err != nil { + return Web{}, err + } + + svc := Web{ + logger: options.Logger, + config: options.Config, + mux: m, + themeService: themeService, } m.Route(options.Config.HTTP.Root, func(r chi.Router) { @@ -75,11 +82,9 @@ func NewService(opts ...Option) (Service, error) { account.Logger(options.Logger), account.JWTSecret(options.Config.TokenManager.JWTSecret), )) - r.Post("/", themeService.LogoUpload) - r.Delete("/", themeService.LogoReset) }) r.Route("/themes", func(r chi.Router) { - r.Get("/{id}/theme.json", themeService.Get) + r.Get("/{id}/theme.json", themeAPI.Get) r.Mount("/", svc.Static( options.ThemeFS.IOFS(), path.Join(svc.config.HTTP.Root, "/themes"), @@ -92,7 +97,7 @@ func NewService(opts ...Option) (Service, error) { options.Config.HTTP.CacheTTL, )) r.Mount("/", svc.Static( - svc.coreFS, + options.CoreFS, svc.config.HTTP.Root, options.Config.HTTP.CacheTTL, )) @@ -107,12 +112,10 @@ 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 - coreFS fs.FS - themeFS *fsx.FallbackFS - gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + logger log.Logger + config *config.Config + mux *chi.Mux + themeService *theme.Service } // ServeHTTP implements the Service interface. @@ -120,30 +123,32 @@ 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 +// Config implements the Service interface. +func (p Web) Config(w http.ResponseWriter, _ *http.Request) { + // fixMe: fishy.... p.config.Web.ThemeServer == p.config.Web.Config.Server + if p.config.Web.ThemeServer == p.config.Web.Config.Server && p.themeService.Exists(_forcedThemeID) { + p.config.Web.ThemePath = _forcedThemePath + } // 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 + themeServer.Path = p.config.Web.ThemePath + p.config.Web.Config.Theme = themeServer.String() } else { p.config.Web.Config.Theme = p.config.Web.ThemePath } - // make apps render as empty array if it is empty + // make apps render as an 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) } - return json.Marshal(p.config.Web.Config) -} - -// Config implements the Service interface. -func (p Web) Config(w http.ResponseWriter, _ *http.Request) { - payload, err := p.getPayload() + payload, err := json.Marshal(p.config.Web.Config) if err != nil { - http.Error(w, ErrConfigInvalid, http.StatusUnprocessableEntity) + msg := "Invalid or missing config" + p.logger.Error().Err(err).Msg(msg) + http.Error(w, msg, http.StatusUnprocessableEntity) return } diff --git a/services/web/pkg/theme/http.go b/services/web/pkg/theme/http.go new file mode 100644 index 0000000000..4612005e8e --- /dev/null +++ b/services/web/pkg/theme/http.go @@ -0,0 +1,72 @@ +package theme + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +// HTTPOptions defines the options to configure HTTP. +type HTTPOptions struct { + service *Service + logger log.Logger +} + +// WithService sets the service for HTTPOptions. +func (o HTTPOptions) WithService(s *Service) HTTPOptions { + o.service = s + return o +} + +// WithLogger sets the logger for the Service. +func (o HTTPOptions) WithLogger(logger log.Logger) HTTPOptions { + o.logger = logger + return o +} + +// validate validates the input parameters. +func (o HTTPOptions) validate() error { + if o.service == nil { + return errors.New("service is required") + } + + return nil +} + +type HTTP struct { + service *Service + logger log.Logger +} + +// NewHTTP initializes a new HTTP. +func NewHTTP(options HTTPOptions) (HTTP, error) { + if err := options.validate(); err != nil { + return HTTP{}, err + } + + return HTTP(options), nil +} + +// Get renders the theme for the given ID. +func (h HTTP) Get(w http.ResponseWriter, r *http.Request) { + theme, err := h.service.Build(r.PathValue("id")) + if err != nil { + h.logger.Error().Err(err).Msg("failed to merge themes") + http.Error(w, ErrBuildingThemeFailed.Error(), http.StatusInternalServerError) + } + + b, err := json.Marshal(theme) + if err != nil { + h.logger.Error().Err(err).Msg("failed to marshal theme") + http.Error(w, ErrBuildingThemeFailed.Error(), http.StatusInternalServerError) + return + } + + if _, err = w.Write(b); err != nil { + h.logger.Error().Err(err).Msg("failed to write response") + http.Error(w, ErrBuildingThemeFailed.Error(), http.StatusInternalServerError) + return + } +} diff --git a/services/web/pkg/theme/http_test.go b/services/web/pkg/theme/http_test.go new file mode 100644 index 0000000000..28a667241b --- /dev/null +++ b/services/web/pkg/theme/http_test.go @@ -0,0 +1,57 @@ +package theme_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" + + "github.com/opencloud-eu/opencloud/pkg/x/io/fsx" + "github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole" + "github.com/opencloud-eu/opencloud/services/web/pkg/theme" +) + +func TestHTTP_Get(t *testing.T) { + primaryFS := fsx.NewMemMapFs() + fallbackFS := fsx.NewFallbackFS(primaryFS, fsx.NewMemMapFs()) + + add := func(filename string, content interface{}) { + b, err := json.Marshal(content) + assert.Nil(t, err) + + assert.Nil(t, afero.WriteFile(primaryFS, filename, b, 0644)) + } + + // baseTheme + add("base/theme.json", map[string]interface{}{ + "base": "base", + }) + // brandingTheme + add("_branding/theme.json", map[string]interface{}{ + "_branding": "_branding", + }) + + service, err := theme.NewService(theme.ServiceOptions{}.WithThemeFS(fallbackFS)) + assert.NoError(t, err) + + handlers, err := theme.NewHTTP(theme.HTTPOptions{}.WithService(service)) + assert.NoError(t, err) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.SetPathValue("id", "base") + + w := httptest.NewRecorder() + handlers.Get(w, r) + + jsonData := gjson.Parse(w.Body.String()) + // baseTheme + assert.Equal(t, jsonData.Get("base").String(), "base") + // brandingTheme + assert.Equal(t, jsonData.Get("_branding").String(), "_branding") + // themeDefaults + assert.Equal(t, jsonData.Get("common.shareRoles."+unifiedrole.UnifiedRoleViewerID+".name").String(), "UnifiedRoleViewer") +} diff --git a/services/web/pkg/theme/kv.go b/services/web/pkg/theme/kv.go index 1c8c7f9117..981c153840 100644 --- a/services/web/pkg/theme/kv.go +++ b/services/web/pkg/theme/kv.go @@ -1,12 +1,10 @@ package theme import ( - "bytes" "encoding/json" - "strings" + "io/fs" "dario.cat/mergo" - "github.com/spf13/afero" ) // KV is a generic key-value map. @@ -26,43 +24,15 @@ func MergeKV(values ...KV) (KV, error) { return kv, nil } -// PatchKV injects the given values into to v. -func PatchKV(v map[string]interface{}, values KV) KV { - if v == nil { - v = KV{} - } - for k, val := range values { - t := v - path := strings.Split(k, ".") - for i, p := range path { - if i == len(path)-1 { - switch val { - // if the value is nil, we delete the key - case nil: - delete(t, p) - default: - t[p] = val - } - break - } - - if _, ok := t[p]; !ok { - t[p] = map[string]interface{}{} - } - - t = t[p].(map[string]interface{}) - } - } - return v -} - // LoadKV loads a key-value map from the given file system. -func LoadKV(fsys afero.Fs, p string) (KV, error) { +func LoadKV(fsys fs.FS, p string) (KV, error) { f, err := fsys.Open(p) if err != nil { return nil, err } - defer f.Close() + defer func() { + _ = f.Close() + }() var kv KV err = json.NewDecoder(f).Decode(&kv) @@ -72,27 +42,3 @@ func LoadKV(fsys afero.Fs, p string) (KV, error) { return kv, nil } - -// WriteKV writes the given key-value map to the file system. -func WriteKV(fsys afero.Fs, p string, kv KV) error { - data, err := json.Marshal(kv) - if err != nil { - return err - } - - return afero.WriteReader(fsys, p, bytes.NewReader(data)) -} - -// UpdateKV updates the key-value map at the given path with the given values. -func UpdateKV(fsys afero.Fs, p string, values KV) error { - var kv KV - - existing, err := LoadKV(fsys, p) - if err == nil { - kv = existing - } - - kv = PatchKV(kv, values) - - return WriteKV(fsys, p, kv) -} diff --git a/services/web/pkg/theme/kv_test.go b/services/web/pkg/theme/kv_test.go index 11901619d7..b3f6b780bc 100644 --- a/services/web/pkg/theme/kv_test.go +++ b/services/web/pkg/theme/kv_test.go @@ -30,82 +30,6 @@ func TestMergeKV(t *testing.T) { }) } -func TestPatchKV(t *testing.T) { - in := theme.KV{ - "a": map[string]interface{}{ - "value": "a", - }, - "b": map[string]interface{}{ - "value": "b", - }, - } - out := theme.PatchKV(in, theme.KV{ - "b.value": "b-new", - "c.value": "c-new", - "d": "d-new", - "e.value.subvalue": "e-new", - }) - assert.Equal(t, theme.KV{ - "a": map[string]interface{}{ - "value": "a", - }, - "b": map[string]interface{}{ - "value": "b-new", - }, - "c": map[string]interface{}{ - "value": "c-new", - }, - "d": "d-new", - "e": map[string]interface{}{ - "value": map[string]interface{}{ - "subvalue": "e-new", - }, - }, - }, out) -} - -func TestPatchKVUnset(t *testing.T) { - in := theme.KV{ - "a": map[string]interface{}{ - "value": "a", - }, - "b": map[string]interface{}{ - "value": "b", - }, - } - out := theme.PatchKV(in, theme.KV{ - "a.value": nil, - "b": nil, - }) - assert.Equal(t, theme.KV{ - "a": map[string]interface{}{}, - }, out) -} - -func TestPatchKVwithNil(t *testing.T) { - var in theme.KV - out := theme.PatchKV(in, theme.KV{ - "b.value": "b-new", - "c.value": "c-new", - "d": "d-new", - "e.value.subvalue": "e-new", - }) - assert.Equal(t, theme.KV{ - "b": map[string]interface{}{ - "value": "b-new", - }, - "c": map[string]interface{}{ - "value": "c-new", - }, - "d": "d-new", - "e": map[string]interface{}{ - "value": map[string]interface{}{ - "subvalue": "e-new", - }, - }, - }, out) -} - func TestLoadKV(t *testing.T) { in := theme.KV{ "a": map[string]interface{}{ @@ -121,66 +45,7 @@ func TestLoadKV(t *testing.T) { fsys := fsx.NewMemMapFs() assert.Nil(t, afero.WriteFile(fsys, "some.json", b, 0644)) - out, err := theme.LoadKV(fsys, "some.json") - assert.Nil(t, err) - assert.Equal(t, in, out) -} - -func TestWriteKV(t *testing.T) { - in := theme.KV{ - "a": map[string]interface{}{ - "value": "a", - }, - "b": map[string]interface{}{ - "value": "b", - }, - } - - fsys := fsx.NewMemMapFs() - assert.Nil(t, theme.WriteKV(fsys, "some.json", in)) - - f, err := fsys.Open("some.json") + out, err := theme.LoadKV(fsys.IOFS(), "some.json") assert.Nil(t, err) - - var out theme.KV - assert.Nil(t, json.NewDecoder(f).Decode(&out)) assert.Equal(t, in, out) } - -func TestUpdateKV(t *testing.T) { - fileKV := theme.KV{ - "a": map[string]interface{}{ - "value": "a", - }, - "b": map[string]interface{}{ - "value": "b", - }, - } - - wb, err := json.Marshal(fileKV) - assert.Nil(t, err) - - fsys := fsx.NewMemMapFs() - assert.Nil(t, afero.WriteFile(fsys, "some.json", wb, 0644)) - _ = theme.UpdateKV(fsys, "some.json", theme.KV{ - "b.value": "b-new", - "c.value": "c-new", - }) - - f, err := fsys.Open("some.json") - assert.Nil(t, err) - - var out theme.KV - assert.Nil(t, json.NewDecoder(f).Decode(&out)) - assert.Equal(t, out, theme.KV{ - "a": map[string]interface{}{ - "value": "a", - }, - "b": map[string]interface{}{ - "value": "b-new", - }, - "c": map[string]interface{}{ - "value": "c-new", - }, - }) -} diff --git a/services/web/pkg/theme/service.go b/services/web/pkg/theme/service.go index 511671614c..1bce1d29f4 100644 --- a/services/web/pkg/theme/service.go +++ b/services/web/pkg/theme/service.go @@ -1,17 +1,12 @@ package theme import ( - "encoding/json" - "net/http" + "archive/zip" + "io" + "os" + "path/filepath" - 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" - "github.com/spf13/afero" - - 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/x/io/fsx" "github.com/opencloud-eu/opencloud/pkg/x/path/filepathx" @@ -19,57 +14,49 @@ import ( // ServiceOptions defines the options to configure the Service. type ServiceOptions struct { - themeFS *fsx.FallbackFS - gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + themeFS fsx.FS } // WithThemeFS sets the theme filesystem. -func (o ServiceOptions) WithThemeFS(fSys *fsx.FallbackFS) ServiceOptions { +func (o ServiceOptions) WithThemeFS(fSys fsx.FS) ServiceOptions { o.themeFS = fSys 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.themeFS == nil { return errors.New("themeFS is required") } - if o.gatewaySelector == nil { - return errors.New("gatewaySelector is required") - } - return nil } // Service defines the http service. type Service struct { - themeFS *fsx.FallbackFS - gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + themeFS fsx.FS } // NewService initializes a new Service. -func NewService(options ServiceOptions) (Service, error) { +func NewService(options ServiceOptions) (*Service, error) { if err := options.validate(); err != nil { - return Service{}, err + return nil, err } - return Service(options), nil + return &Service{ + themeFS: options.themeFS, + }, nil } -// Get renders the theme, the theme is a merge of the default theme, the base theme, and the branding theme. -func (s Service) Get(w http.ResponseWriter, r *http.Request) { +// Build builds the theme, the theme is a merge of the default theme, the base theme, and the branding theme. +func (s Service) Build(id string) (KV, error) { + themeFS := s.themeFS.IOFS() + // there is no guarantee that the theme exists, its optional; therefore, we ignore the error - baseTheme, _ := LoadKV(s.themeFS, filepathx.JailJoin(r.PathValue("id"), _themeFileName)) + baseTheme, _ := LoadKV(themeFS, filepathx.JailJoin(id, _themeFileName)) // there is no guarantee that the theme exists, its optional; therefore, we ignore the error here too - brandingTheme, _ := LoadKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName)) + brandingTheme, _ := LoadKV(themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName)) // merge the themes, the order is important, the last one wins and overrides the previous ones // themeDefaults: contains all the default values, this is guaranteed to exist @@ -78,118 +65,63 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) { // mergedTheme = themeDefaults < baseTheme < brandingTheme mergedTheme, err := MergeKV(themeDefaults, baseTheme, brandingTheme) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return nil, errors.Wrap(err, "failed to merge themes") } - b, err := json.Marshal(mergedTheme) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + return mergedTheme, nil +} - _, err = w.Write(b) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } +func (s Service) Exists(id string) bool { + info, err := s.themeFS.Stat(filepathx.JailJoin(id, _themeFileName)) + return err == nil && !info.IsDir() && info.Size() > 0 } -// LogoUpload implements the endpoint to upload a custom logo for the OpenCloud instance. -func (s Service) LogoUpload(w http.ResponseWriter, r *http.Request) { - gatewayClient, err := s.gatewaySelector.Next() - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return +func (s Service) Remove(id string) error { + if !s.Exists(id) { + return errors.Errorf("theme %s does not exist", id) } - user := revactx.ContextMustGetUser(r.Context()) - rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ - Permission: "Logo.Write", - 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 - } + // remove the theme directory + return s.themeFS.RemoveAll(id) +} - file, fileHeader, err := r.FormFile("logo") - if err != nil { - if errors.Is(err, http.ErrMissingFile) { - w.WriteHeader(http.StatusBadRequest) - } - w.WriteHeader(http.StatusInternalServerError) - return +func (s Service) Add(id string, r *zip.Reader) error { + if s.Exists(id) { + return errors.Errorf("theme %s already exists", id) } - defer file.Close() - if !isFiletypePermitted(fileHeader.Filename, fileHeader.Header.Get("Content-Type")) { - w.WriteHeader(http.StatusBadRequest) - return - } + for _, f := range r.File { + filePath := filepath.Join(id, f.Name) + if f.FileInfo().IsDir() { + err := s.themeFS.MkdirAll(filePath, os.ModePerm) + if err != nil { + return err + } - fp := filepathx.JailJoin(_brandingRoot, fileHeader.Filename) - err = afero.WriteReader(s.themeFS, fp, file) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } + continue + } - err = UpdateKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName), KV{ - "common.logo": filepathx.JailJoin("themes", fp), - "clients.web.defaults.logo": filepathx.JailJoin("themes", fp), - }) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } + if err := s.themeFS.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { + return err + } - w.WriteHeader(http.StatusOK) -} + source, err := f.Open() + if err != nil { + return err + } -// LogoReset implements the endpoint to reset the instance logo. -// The config will be changed back to use the embedded logo asset. -func (s Service) LogoReset(w http.ResponseWriter, r *http.Request) { - gatewayClient, err := s.gatewaySelector.Next() - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } + dest, err := s.themeFS.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } - user := revactx.ContextMustGetUser(r.Context()) - rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ - Permission: "Logo.Write", - 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 - } + if _, err := io.Copy(dest, source); err != nil { + return err + } - err = UpdateKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName), KV{ - "common.logo": nil, - "clients.web.defaults.logo": nil, - }) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return + _ = dest.Close() + _ = source.Close() } - w.WriteHeader(http.StatusOK) + return nil } diff --git a/services/web/pkg/theme/service_test.go b/services/web/pkg/theme/service_test.go index ca392582df..9418da4b2f 100644 --- a/services/web/pkg/theme/service_test.go +++ b/services/web/pkg/theme/service_test.go @@ -1,19 +1,11 @@ package theme_test import ( - "encoding/json" - "net/http" - "net/http/httptest" "testing" - gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" - "github.com/tidwall/gjson" "github.com/opencloud-eu/opencloud/pkg/x/io/fsx" - "github.com/opencloud-eu/opencloud/services/graph/mocks" - "github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole" "github.com/opencloud-eu/opencloud/services/web/pkg/theme" ) @@ -26,49 +18,8 @@ func TestNewService(t *testing.T) { t.Run("success if the options are valid", func(t *testing.T) { _, err := theme.NewService( theme.ServiceOptions{}. - WithThemeFS(fsx.NewFallbackFS(fsx.NewMemMapFs(), fsx.NewMemMapFs())). - WithGatewaySelector(mocks.NewSelectable[gateway.GatewayAPIClient](t)), + WithThemeFS(fsx.NewFallbackFS(fsx.NewMemMapFs(), fsx.NewMemMapFs())), ) assert.NoError(t, err) }) } - -func TestService_Get(t *testing.T) { - primaryFS := fsx.NewMemMapFs() - fallbackFS := fsx.NewFallbackFS(primaryFS, fsx.NewMemMapFs()) - - add := func(filename string, content interface{}) { - b, err := json.Marshal(content) - assert.Nil(t, err) - - assert.Nil(t, afero.WriteFile(primaryFS, filename, b, 0644)) - } - - // baseTheme - add("base/theme.json", map[string]interface{}{ - "base": "base", - }) - // brandingTheme - add("_branding/theme.json", map[string]interface{}{ - "_branding": "_branding", - }) - - service, _ := theme.NewService( - theme.ServiceOptions{}. - WithThemeFS(fallbackFS). - WithGatewaySelector(mocks.NewSelectable[gateway.GatewayAPIClient](t)), - ) - r := httptest.NewRequest(http.MethodGet, "/", nil) - r.SetPathValue("id", "base") - - w := httptest.NewRecorder() - service.Get(w, r) - - jsonData := gjson.Parse(w.Body.String()) - // baseTheme - assert.Equal(t, jsonData.Get("base").String(), "base") - // brandingTheme - assert.Equal(t, jsonData.Get("_branding").String(), "_branding") - // themeDefaults - assert.Equal(t, jsonData.Get("common.shareRoles."+unifiedrole.UnifiedRoleViewerID+".name").String(), "UnifiedRoleViewer") -} diff --git a/services/web/pkg/theme/theme.go b/services/web/pkg/theme/theme.go index 56f190d04e..af23ccb788 100644 --- a/services/web/pkg/theme/theme.go +++ b/services/web/pkg/theme/theme.go @@ -1,6 +1,7 @@ package theme import ( + "errors" "path" "github.com/opencloud-eu/opencloud/pkg/capabilities" @@ -8,8 +9,9 @@ import ( ) var ( - _brandingRoot = "_branding" - _themeFileName = "theme.json" + _brandingRoot = "_branding" + _themeFileName = "theme.json" + ErrBuildingThemeFailed = errors.New("building theme failed") ) // themeDefaults contains the default values for the theme.