refactor(thumbnails): remove the pull-based pipeline

The thumbnails service is now a stateless image resizer; remove everything
the old gRPC-based pipeline needed:

- proto definitions and generated code (service + messages)
- gRPC server, handler and decorators
- JWT transfer token code and the /data HTTP download endpoint
- filesystem storage layer and source fetchers (webdav + CS3 imgsource)
- duplicated encoding/generator/processor/resolution utilities
- the preprocessor packages moved to webdav in the previous commit
- dead config fields (Thumbnail struct, GRPC config, unused go-micro client),
  no-op metrics/instrumentation wrappers, errors package, stale testdata and
  the Makefile protobuf target

Also remove the now-meaningless thumbnails transfer secret generation from
opencloud init and point the graph service at the new webdav thumbnail
package. The README is rewritten to document the imagor-like push endpoint.
This commit is contained in:
Jörn Friedrich Dreyer committed 2026-09-04 23:05:11 +02:00
1 parent 5c3b7f8890
commit efb1c648dc
74 files changed
+60 -5862

No files matched your search

+1 -14
View File
@@ -68,7 +68,7 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
systemUserID, adminUserID, graphApplicationID, storageUsersMountID, serviceAccountID string
idmServicePassword, idpServicePassword, ocAdminServicePassword, revaServicePassword string
tokenManagerJwtSecret, collaborationWOPISecret, machineAuthAPIKey, systemUserAPIKey string
revaTransferSecret, thumbnailsTransferSecret, serviceAccountSecret, urlSigningSecret string
revaTransferSecret, serviceAccountSecret, urlSigningSecret string
adminPasswdwordGenerated bool
)
@@ -94,7 +94,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
machineAuthAPIKey = oldCfg.MachineAuthAPIKey
systemUserAPIKey = oldCfg.SystemUserAPIKey
revaTransferSecret = oldCfg.TransferSecret
thumbnailsTransferSecret = oldCfg.Thumbnails.Thumbnail.TransferSecret
serviceAccountSecret = oldCfg.Graph.ServiceAccount.ServiceAccountSecret
urlSigningSecret = oldCfg.URLSigningSecret
if urlSigningSecret == "" {
@@ -155,10 +154,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
if err != nil {
return fmt.Errorf("could not generate random secret for urlSigningSecret: %s", err)
}
thumbnailsTransferSecret, err = generators.GenerateRandomPassword(passwordLength)
if err != nil {
return fmt.Errorf("could not generate random password for thumbnailsTransferSecret: %s", err)
}
serviceAccountSecret, err = generators.GenerateRandomPassword(passwordLength)
if err != nil {
return fmt.Errorf("could not generate random secret for serviceAccountSecret: %s", err)
@@ -230,11 +225,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
},
ServiceAccount: serviceAccount,
},
Thumbnails: ThumbnailService{
Thumbnail: ThumbnailSettings{
TransferSecret: thumbnailsTransferSecret,
},
},
Gateway: Gateway{
StorageRegistry: StorageRegistry{
StorageUsersMountID: storageUsersMountID,
@@ -302,9 +292,6 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
},
ServiceAccount: serviceAccount,
}
cfg.Thumbnails.Thumbnail.WebdavAllowInsecure = true
cfg.Thumbnails.Thumbnail.Cs3AllowInsecure = true
}
yamlOutput, err := yaml.Marshal(cfg)
if err != nil {
-13
View File
@@ -33,7 +33,6 @@ type OpenCloudConfig struct {
Users UsersAndGroupsService `yaml:"users"`
Groups UsersAndGroupsService `yaml:"groups"`
Ocm OcmService `yaml:"ocm"`
Thumbnails ThumbnailService `yaml:"thumbnails"`
Search Search `yaml:"search"`
Audit Audit `yaml:"audit"`
Settings SettingsService `yaml:"settings"`
@@ -220,18 +219,6 @@ type StorageUsers struct {
ServiceAccount ServiceAccount `yaml:"service_account"`
}
// ThumbnailSettings is the configuration for the thumbnail settings
type ThumbnailSettings struct {
TransferSecret string `yaml:"transfer_secret"`
WebdavAllowInsecure bool `yaml:"webdav_allow_insecure"`
Cs3AllowInsecure bool `yaml:"cs3_allow_insecure"`
}
// ThumbnailService is the configuration for the thumbnail service
type ThumbnailService struct {
Thumbnail ThumbnailSettings
}
// TokenManager is the configuration for the token manager
type TokenManager struct {
JWTSecret string `yaml:"jwt_secret"`
@@ -1,326 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc (unknown)
// source: opencloud/messages/thumbnails/v0/thumbnails.proto
package v0
import (
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)
)
// The file types to which the thumbnail can be encoded to.
type ThumbnailType int32
const (
ThumbnailType_PNG ThumbnailType = 0 // Represents PNG type
ThumbnailType_JPG ThumbnailType = 1 // Represents JPG type
ThumbnailType_GIF ThumbnailType = 2 // Represents GIF type
)
// Enum value maps for ThumbnailType.
var (
ThumbnailType_name = map[int32]string{
0: "PNG",
1: "JPG",
2: "GIF",
}
ThumbnailType_value = map[string]int32{
"PNG": 0,
"JPG": 1,
"GIF": 2,
}
)
func (x ThumbnailType) Enum() *ThumbnailType {
p := new(ThumbnailType)
*p = x
return p
}
func (x ThumbnailType) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (ThumbnailType) Descriptor() protoreflect.EnumDescriptor {
return file_opencloud_messages_thumbnails_v0_thumbnails_proto_enumTypes[0].Descriptor()
}
func (ThumbnailType) Type() protoreflect.EnumType {
return &file_opencloud_messages_thumbnails_v0_thumbnails_proto_enumTypes[0]
}
func (x ThumbnailType) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use ThumbnailType.Descriptor instead.
func (ThumbnailType) EnumDescriptor() ([]byte, []int) {
return file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescGZIP(), []int{0}
}
type WebdavSource struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// REQUIRED.
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
// REQUIRED.
IsPublicLink bool `protobuf:"varint,2,opt,name=is_public_link,json=isPublicLink,proto3" json:"is_public_link,omitempty"`
// OPTIONAL.
WebdavAuthorization string `protobuf:"bytes,3,opt,name=webdav_authorization,json=webdavAuthorization,proto3" json:"webdav_authorization,omitempty"`
// OPTIONAL.
RevaAuthorization string `protobuf:"bytes,4,opt,name=reva_authorization,json=revaAuthorization,proto3" json:"reva_authorization,omitempty"`
// OPTIONAL.
PublicLinkToken string `protobuf:"bytes,5,opt,name=public_link_token,json=publicLinkToken,proto3" json:"public_link_token,omitempty"`
}
func (x *WebdavSource) Reset() {
*x = WebdavSource{}
if protoimpl.UnsafeEnabled {
mi := &file_opencloud_messages_thumbnails_v0_thumbnails_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *WebdavSource) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WebdavSource) ProtoMessage() {}
func (x *WebdavSource) ProtoReflect() protoreflect.Message {
mi := &file_opencloud_messages_thumbnails_v0_thumbnails_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 WebdavSource.ProtoReflect.Descriptor instead.
func (*WebdavSource) Descriptor() ([]byte, []int) {
return file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescGZIP(), []int{0}
}
func (x *WebdavSource) GetUrl() string {
if x != nil {
return x.Url
}
return ""
}
func (x *WebdavSource) GetIsPublicLink() bool {
if x != nil {
return x.IsPublicLink
}
return false
}
func (x *WebdavSource) GetWebdavAuthorization() string {
if x != nil {
return x.WebdavAuthorization
}
return ""
}
func (x *WebdavSource) GetRevaAuthorization() string {
if x != nil {
return x.RevaAuthorization
}
return ""
}
func (x *WebdavSource) GetPublicLinkToken() string {
if x != nil {
return x.PublicLinkToken
}
return ""
}
type CS3Source struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
Authorization string `protobuf:"bytes,2,opt,name=authorization,proto3" json:"authorization,omitempty"`
}
func (x *CS3Source) Reset() {
*x = CS3Source{}
if protoimpl.UnsafeEnabled {
mi := &file_opencloud_messages_thumbnails_v0_thumbnails_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CS3Source) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CS3Source) ProtoMessage() {}
func (x *CS3Source) ProtoReflect() protoreflect.Message {
mi := &file_opencloud_messages_thumbnails_v0_thumbnails_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 CS3Source.ProtoReflect.Descriptor instead.
func (*CS3Source) Descriptor() ([]byte, []int) {
return file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescGZIP(), []int{1}
}
func (x *CS3Source) GetPath() string {
if x != nil {
return x.Path
}
return ""
}
func (x *CS3Source) GetAuthorization() string {
if x != nil {
return x.Authorization
}
return ""
}
var File_opencloud_messages_thumbnails_v0_thumbnails_proto protoreflect.FileDescriptor
var file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDesc = []byte{
0x0a, 0x31, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2f, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2f,
0x76, 0x30, 0x2f, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x12, 0x20, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69,
0x6c, 0x73, 0x2e, 0x76, 0x30, 0x22, 0xd4, 0x01, 0x0a, 0x0c, 0x57, 0x65, 0x62, 0x64, 0x61, 0x76,
0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x24, 0x0a, 0x0e, 0x69, 0x73, 0x5f, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
0x52, 0x0c, 0x69, 0x73, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x12, 0x31,
0x0a, 0x14, 0x77, 0x65, 0x62, 0x64, 0x61, 0x76, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x65,
0x62, 0x64, 0x61, 0x76, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f,
0x6e, 0x12, 0x2d, 0x0a, 0x12, 0x72, 0x65, 0x76, 0x61, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72,
0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x72,
0x65, 0x76, 0x61, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x12, 0x2a, 0x0a, 0x11, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x5f,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x75, 0x62,
0x6c, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x45, 0x0a, 0x09,
0x43, 0x53, 0x33, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74,
0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x24, 0x0a,
0x0d, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x2a, 0x2a, 0x0a, 0x0d, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c,
0x54, 0x79, 0x70, 0x65, 0x12, 0x07, 0x0a, 0x03, 0x50, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x07, 0x0a,
0x03, 0x4a, 0x50, 0x47, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x47, 0x49, 0x46, 0x10, 0x02, 0x42,
0x51, 0x5a, 0x4f, 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, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2f, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2f,
0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescOnce sync.Once
file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescData = file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDesc
)
func file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescGZIP() []byte {
file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescOnce.Do(func() {
file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescData = protoimpl.X.CompressGZIP(file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescData)
})
return file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDescData
}
var file_opencloud_messages_thumbnails_v0_thumbnails_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_opencloud_messages_thumbnails_v0_thumbnails_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_opencloud_messages_thumbnails_v0_thumbnails_proto_goTypes = []interface{}{
(ThumbnailType)(0), // 0: opencloud.messages.thumbnails.v0.ThumbnailType
(*WebdavSource)(nil), // 1: opencloud.messages.thumbnails.v0.WebdavSource
(*CS3Source)(nil), // 2: opencloud.messages.thumbnails.v0.CS3Source
}
var file_opencloud_messages_thumbnails_v0_thumbnails_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] 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_messages_thumbnails_v0_thumbnails_proto_init() }
func file_opencloud_messages_thumbnails_v0_thumbnails_proto_init() {
if File_opencloud_messages_thumbnails_v0_thumbnails_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_opencloud_messages_thumbnails_v0_thumbnails_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WebdavSource); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_opencloud_messages_thumbnails_v0_thumbnails_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*CS3Source); 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_messages_thumbnails_v0_thumbnails_proto_rawDesc,
NumEnums: 1,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_opencloud_messages_thumbnails_v0_thumbnails_proto_goTypes,
DependencyIndexes: file_opencloud_messages_thumbnails_v0_thumbnails_proto_depIdxs,
EnumInfos: file_opencloud_messages_thumbnails_v0_thumbnails_proto_enumTypes,
MessageInfos: file_opencloud_messages_thumbnails_v0_thumbnails_proto_msgTypes,
}.Build()
File_opencloud_messages_thumbnails_v0_thumbnails_proto = out.File
file_opencloud_messages_thumbnails_v0_thumbnails_proto_rawDesc = nil
file_opencloud_messages_thumbnails_v0_thumbnails_proto_goTypes = nil
file_opencloud_messages_thumbnails_v0_thumbnails_proto_depIdxs = nil
}
@@ -1,15 +0,0 @@
// Code generated by protoc-gen-micro. DO NOT EDIT.
// source: opencloud/messages/thumbnails/v0/thumbnails.proto
package v0
import (
fmt "fmt"
proto "google.golang.org/protobuf/proto"
math "math"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
@@ -1,43 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "opencloud/messages/thumbnails/v0/thumbnails.proto",
"version": "version not set"
},
"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"
}
}
}
}
}
}
@@ -1,123 +0,0 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
mock "github.com/stretchr/testify/mock"
"go-micro.dev/v4/client"
)
// NewThumbnailService creates a new instance of ThumbnailService. 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 NewThumbnailService(t interface {
mock.TestingT
Cleanup(func())
}) *ThumbnailService {
mock := &ThumbnailService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// ThumbnailService is an autogenerated mock type for the ThumbnailService type
type ThumbnailService struct {
mock.Mock
}
type ThumbnailService_Expecter struct {
mock *mock.Mock
}
func (_m *ThumbnailService) EXPECT() *ThumbnailService_Expecter {
return &ThumbnailService_Expecter{mock: &_m.Mock}
}
// GetThumbnail provides a mock function for the type ThumbnailService
func (_mock *ThumbnailService) GetThumbnail(ctx context.Context, in *v0.GetThumbnailRequest, opts ...client.CallOption) (*v0.GetThumbnailResponse, error) {
var tmpRet mock.Arguments
if len(opts) > 0 {
tmpRet = _mock.Called(ctx, in, opts)
} else {
tmpRet = _mock.Called(ctx, in)
}
ret := tmpRet
if len(ret) == 0 {
panic("no return value specified for GetThumbnail")
}
var r0 *v0.GetThumbnailResponse
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.GetThumbnailRequest, ...client.CallOption) (*v0.GetThumbnailResponse, error)); ok {
return returnFunc(ctx, in, opts...)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.GetThumbnailRequest, ...client.CallOption) *v0.GetThumbnailResponse); ok {
r0 = returnFunc(ctx, in, opts...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0.GetThumbnailResponse)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *v0.GetThumbnailRequest, ...client.CallOption) error); ok {
r1 = returnFunc(ctx, in, opts...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ThumbnailService_GetThumbnail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetThumbnail'
type ThumbnailService_GetThumbnail_Call struct {
*mock.Call
}
// GetThumbnail is a helper method to define mock.On call
// - ctx context.Context
// - in *v0.GetThumbnailRequest
// - opts ...client.CallOption
func (_e *ThumbnailService_Expecter) GetThumbnail(ctx interface{}, in interface{}, opts ...interface{}) *ThumbnailService_GetThumbnail_Call {
return &ThumbnailService_GetThumbnail_Call{Call: _e.mock.On("GetThumbnail",
append([]interface{}{ctx, in}, opts...)...)}
}
func (_c *ThumbnailService_GetThumbnail_Call) Run(run func(ctx context.Context, in *v0.GetThumbnailRequest, opts ...client.CallOption)) *ThumbnailService_GetThumbnail_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *v0.GetThumbnailRequest
if args[1] != nil {
arg1 = args[1].(*v0.GetThumbnailRequest)
}
var arg2 []client.CallOption
var variadicArgs []client.CallOption
if len(args) > 2 {
variadicArgs = args[2].([]client.CallOption)
}
arg2 = variadicArgs
run(
arg0,
arg1,
arg2...,
)
})
return _c
}
func (_c *ThumbnailService_GetThumbnail_Call) Return(getThumbnailResponse *v0.GetThumbnailResponse, err error) *ThumbnailService_GetThumbnail_Call {
_c.Call.Return(getThumbnailResponse, err)
return _c
}
func (_c *ThumbnailService_GetThumbnail_Call) RunAndReturn(run func(ctx context.Context, in *v0.GetThumbnailRequest, opts ...client.CallOption) (*v0.GetThumbnailResponse, error)) *ThumbnailService_GetThumbnail_Call {
_c.Call.Return(run)
return _c
}
@@ -1,388 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc (unknown)
// source: opencloud/services/thumbnails/v0/thumbnails.proto
package v0
import (
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options"
v0 "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/thumbnails/v0"
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)
)
// A request to retrieve a thumbnail
type GetThumbnailRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// The path to the source image
Filepath string `protobuf:"bytes,1,opt,name=filepath,proto3" json:"filepath,omitempty"`
// The type to which the thumbnail should get encoded to.
ThumbnailType v0.ThumbnailType `protobuf:"varint,2,opt,name=thumbnail_type,json=thumbnailType,proto3,enum=opencloud.messages.thumbnails.v0.ThumbnailType" json:"thumbnail_type,omitempty"`
// The width of the thumbnail
Width int32 `protobuf:"varint,3,opt,name=width,proto3" json:"width,omitempty"`
// The height of the thumbnail
Height int32 `protobuf:"varint,4,opt,name=height,proto3" json:"height,omitempty"`
// Indicates which image processor to use
Processor string `protobuf:"bytes,5,opt,name=processor,proto3" json:"processor,omitempty"`
// Types that are assignable to Source:
//
// *GetThumbnailRequest_WebdavSource
// *GetThumbnailRequest_Cs3Source
Source isGetThumbnailRequest_Source `protobuf_oneof:"source"`
}
func (x *GetThumbnailRequest) Reset() {
*x = GetThumbnailRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_opencloud_services_thumbnails_v0_thumbnails_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetThumbnailRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetThumbnailRequest) ProtoMessage() {}
func (x *GetThumbnailRequest) ProtoReflect() protoreflect.Message {
mi := &file_opencloud_services_thumbnails_v0_thumbnails_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 GetThumbnailRequest.ProtoReflect.Descriptor instead.
func (*GetThumbnailRequest) Descriptor() ([]byte, []int) {
return file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescGZIP(), []int{0}
}
func (x *GetThumbnailRequest) GetFilepath() string {
if x != nil {
return x.Filepath
}
return ""
}
func (x *GetThumbnailRequest) GetThumbnailType() v0.ThumbnailType {
if x != nil {
return x.ThumbnailType
}
return v0.ThumbnailType(0)
}
func (x *GetThumbnailRequest) GetWidth() int32 {
if x != nil {
return x.Width
}
return 0
}
func (x *GetThumbnailRequest) GetHeight() int32 {
if x != nil {
return x.Height
}
return 0
}
func (x *GetThumbnailRequest) GetProcessor() string {
if x != nil {
return x.Processor
}
return ""
}
func (m *GetThumbnailRequest) GetSource() isGetThumbnailRequest_Source {
if m != nil {
return m.Source
}
return nil
}
func (x *GetThumbnailRequest) GetWebdavSource() *v0.WebdavSource {
if x, ok := x.GetSource().(*GetThumbnailRequest_WebdavSource); ok {
return x.WebdavSource
}
return nil
}
func (x *GetThumbnailRequest) GetCs3Source() *v0.CS3Source {
if x, ok := x.GetSource().(*GetThumbnailRequest_Cs3Source); ok {
return x.Cs3Source
}
return nil
}
type isGetThumbnailRequest_Source interface {
isGetThumbnailRequest_Source()
}
type GetThumbnailRequest_WebdavSource struct {
WebdavSource *v0.WebdavSource `protobuf:"bytes,6,opt,name=webdav_source,json=webdavSource,proto3,oneof"`
}
type GetThumbnailRequest_Cs3Source struct {
Cs3Source *v0.CS3Source `protobuf:"bytes,7,opt,name=cs3_source,json=cs3Source,proto3,oneof"`
}
func (*GetThumbnailRequest_WebdavSource) isGetThumbnailRequest_Source() {}
func (*GetThumbnailRequest_Cs3Source) isGetThumbnailRequest_Source() {}
// The service response
type GetThumbnailResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// The endpoint where the thumbnail can be downloaded.
DataEndpoint string `protobuf:"bytes,1,opt,name=data_endpoint,json=dataEndpoint,proto3" json:"data_endpoint,omitempty"`
// The transfer token to be able to download the thumbnail.
TransferToken string `protobuf:"bytes,2,opt,name=transfer_token,json=transferToken,proto3" json:"transfer_token,omitempty"`
// The mimetype of the thumbnail
Mimetype string `protobuf:"bytes,3,opt,name=mimetype,proto3" json:"mimetype,omitempty"`
}
func (x *GetThumbnailResponse) Reset() {
*x = GetThumbnailResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_opencloud_services_thumbnails_v0_thumbnails_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetThumbnailResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetThumbnailResponse) ProtoMessage() {}
func (x *GetThumbnailResponse) ProtoReflect() protoreflect.Message {
mi := &file_opencloud_services_thumbnails_v0_thumbnails_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 GetThumbnailResponse.ProtoReflect.Descriptor instead.
func (*GetThumbnailResponse) Descriptor() ([]byte, []int) {
return file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescGZIP(), []int{1}
}
func (x *GetThumbnailResponse) GetDataEndpoint() string {
if x != nil {
return x.DataEndpoint
}
return ""
}
func (x *GetThumbnailResponse) GetTransferToken() string {
if x != nil {
return x.TransferToken
}
return ""
}
func (x *GetThumbnailResponse) GetMimetype() string {
if x != nil {
return x.Mimetype
}
return ""
}
var File_opencloud_services_thumbnails_v0_thumbnails_proto protoreflect.FileDescriptor
var file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDesc = []byte{
0x0a, 0x31, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x73, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x73, 0x2f, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2f,
0x76, 0x30, 0x2f, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x12, 0x20, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73,
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69,
0x6c, 0x73, 0x2e, 0x76, 0x30, 0x1a, 0x31, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64,
0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e,
0x61, 0x69, 0x6c, 0x73, 0x2f, 0x76, 0x30, 0x2f, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69,
0x6c, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 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, 0x84, 0x03, 0x0a, 0x13, 0x47, 0x65, 0x74,
0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x70, 0x61, 0x74, 0x68, 0x12, 0x56, 0x0a, 0x0e,
0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0e, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e,
0x61, 0x69, 0x6c, 0x73, 0x2e, 0x76, 0x30, 0x2e, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69,
0x6c, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0d, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c,
0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x03, 0x20,
0x01, 0x28, 0x05, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65,
0x69, 0x67, 0x68, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67,
0x68, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x6f, 0x72, 0x18,
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x6f, 0x72,
0x12, 0x55, 0x0a, 0x0d, 0x77, 0x65, 0x62, 0x64, 0x61, 0x76, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c,
0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x74, 0x68, 0x75,
0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2e, 0x76, 0x30, 0x2e, 0x57, 0x65, 0x62, 0x64, 0x61,
0x76, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x48, 0x00, 0x52, 0x0c, 0x77, 0x65, 0x62, 0x64, 0x61,
0x76, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, 0x0a, 0x63, 0x73, 0x33, 0x5f, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6f, 0x70,
0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x2e, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2e, 0x76, 0x30, 0x2e, 0x43,
0x53, 0x33, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x48, 0x00, 0x52, 0x09, 0x63, 0x73, 0x33, 0x53,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22,
0x7e, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x61, 0x74, 0x61, 0x5f,
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e,
0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x54, 0x6f,
0x6b, 0x65, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x74, 0x79, 0x70, 0x65, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x74, 0x79, 0x70, 0x65, 0x32,
0x91, 0x01, 0x0a, 0x10, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x53, 0x65, 0x72,
0x76, 0x69, 0x63, 0x65, 0x12, 0x7d, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x54, 0x68, 0x75, 0x6d, 0x62,
0x6e, 0x61, 0x69, 0x6c, 0x12, 0x35, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64,
0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e,
0x61, 0x69, 0x6c, 0x73, 0x2e, 0x76, 0x30, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x68, 0x75, 0x6d, 0x62,
0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x36, 0x2e, 0x6f, 0x70,
0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73,
0x2e, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x73, 0x2e, 0x76, 0x30, 0x2e, 0x47,
0x65, 0x74, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x42, 0xff, 0x02, 0x5a, 0x4f, 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, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e,
0x61, 0x69, 0x6c, 0x73, 0x2f, 0x76, 0x30, 0x92, 0x41, 0xaa, 0x02, 0x12, 0xbb, 0x01, 0x0a, 0x14,
0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e,
0x61, 0x69, 0x6c, 0x73, 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, 0x42, 0x0a, 0x10, 0x44, 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, 0x20, 0x4d,
0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, 0x2e, 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, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e,
0x61, 0x69, 0x6c, 0x73, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescOnce sync.Once
file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescData = file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDesc
)
func file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescGZIP() []byte {
file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescOnce.Do(func() {
file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescData = protoimpl.X.CompressGZIP(file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescData)
})
return file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDescData
}
var file_opencloud_services_thumbnails_v0_thumbnails_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_opencloud_services_thumbnails_v0_thumbnails_proto_goTypes = []interface{}{
(*GetThumbnailRequest)(nil), // 0: opencloud.services.thumbnails.v0.GetThumbnailRequest
(*GetThumbnailResponse)(nil), // 1: opencloud.services.thumbnails.v0.GetThumbnailResponse
(v0.ThumbnailType)(0), // 2: opencloud.messages.thumbnails.v0.ThumbnailType
(*v0.WebdavSource)(nil), // 3: opencloud.messages.thumbnails.v0.WebdavSource
(*v0.CS3Source)(nil), // 4: opencloud.messages.thumbnails.v0.CS3Source
}
var file_opencloud_services_thumbnails_v0_thumbnails_proto_depIdxs = []int32{
2, // 0: opencloud.services.thumbnails.v0.GetThumbnailRequest.thumbnail_type:type_name -> opencloud.messages.thumbnails.v0.ThumbnailType
3, // 1: opencloud.services.thumbnails.v0.GetThumbnailRequest.webdav_source:type_name -> opencloud.messages.thumbnails.v0.WebdavSource
4, // 2: opencloud.services.thumbnails.v0.GetThumbnailRequest.cs3_source:type_name -> opencloud.messages.thumbnails.v0.CS3Source
0, // 3: opencloud.services.thumbnails.v0.ThumbnailService.GetThumbnail:input_type -> opencloud.services.thumbnails.v0.GetThumbnailRequest
1, // 4: opencloud.services.thumbnails.v0.ThumbnailService.GetThumbnail:output_type -> opencloud.services.thumbnails.v0.GetThumbnailResponse
4, // [4:5] is the sub-list for method output_type
3, // [3:4] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_opencloud_services_thumbnails_v0_thumbnails_proto_init() }
func file_opencloud_services_thumbnails_v0_thumbnails_proto_init() {
if File_opencloud_services_thumbnails_v0_thumbnails_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_opencloud_services_thumbnails_v0_thumbnails_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetThumbnailRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_opencloud_services_thumbnails_v0_thumbnails_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetThumbnailResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
file_opencloud_services_thumbnails_v0_thumbnails_proto_msgTypes[0].OneofWrappers = []interface{}{
(*GetThumbnailRequest_WebdavSource)(nil),
(*GetThumbnailRequest_Cs3Source)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDesc,
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_opencloud_services_thumbnails_v0_thumbnails_proto_goTypes,
DependencyIndexes: file_opencloud_services_thumbnails_v0_thumbnails_proto_depIdxs,
MessageInfos: file_opencloud_services_thumbnails_v0_thumbnails_proto_msgTypes,
}.Build()
File_opencloud_services_thumbnails_v0_thumbnails_proto = out.File
file_opencloud_services_thumbnails_v0_thumbnails_proto_rawDesc = nil
file_opencloud_services_thumbnails_v0_thumbnails_proto_goTypes = nil
file_opencloud_services_thumbnails_v0_thumbnails_proto_depIdxs = nil
}
@@ -1,91 +0,0 @@
// Code generated by protoc-gen-micro. DO NOT EDIT.
// source: opencloud/services/thumbnails/v0/thumbnails.proto
package v0
import (
fmt "fmt"
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options"
_ "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/thumbnails/v0"
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 ThumbnailService service
func NewThumbnailServiceEndpoints() []*api.Endpoint {
return []*api.Endpoint{}
}
// Client API for ThumbnailService service
type ThumbnailService interface {
// Generates the thumbnail and returns it.
GetThumbnail(ctx context.Context, in *GetThumbnailRequest, opts ...client.CallOption) (*GetThumbnailResponse, error)
}
type thumbnailService struct {
c client.Client
name string
}
func NewThumbnailService(name string, c client.Client) ThumbnailService {
return &thumbnailService{
c: c,
name: name,
}
}
func (c *thumbnailService) GetThumbnail(ctx context.Context, in *GetThumbnailRequest, opts ...client.CallOption) (*GetThumbnailResponse, error) {
req := c.c.NewRequest(c.name, "ThumbnailService.GetThumbnail", in)
out := new(GetThumbnailResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// Server API for ThumbnailService service
type ThumbnailServiceHandler interface {
// Generates the thumbnail and returns it.
GetThumbnail(context.Context, *GetThumbnailRequest, *GetThumbnailResponse) error
}
func RegisterThumbnailServiceHandler(s server.Server, hdlr ThumbnailServiceHandler, opts ...server.HandlerOption) error {
type thumbnailService interface {
GetThumbnail(ctx context.Context, in *GetThumbnailRequest, out *GetThumbnailResponse) error
}
type ThumbnailService struct {
thumbnailService
}
h := &thumbnailServiceHandler{hdlr}
return s.Handle(s.NewHandler(&ThumbnailService{h}, opts...))
}
type thumbnailServiceHandler struct {
ThumbnailServiceHandler
}
func (h *thumbnailServiceHandler) GetThumbnail(ctx context.Context, in *GetThumbnailRequest, out *GetThumbnailResponse) error {
return h.ThumbnailServiceHandler.GetThumbnail(ctx, in, out)
}
@@ -1,129 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "OpenCloud thumbnails",
"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": "ThumbnailService"
}
],
"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"
}
}
}
},
"v0CS3Source": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"authorization": {
"type": "string"
}
}
},
"v0GetThumbnailResponse": {
"type": "object",
"properties": {
"dataEndpoint": {
"type": "string",
"description": "The endpoint where the thumbnail can be downloaded."
},
"transferToken": {
"type": "string",
"description": "The transfer token to be able to download the thumbnail."
},
"mimetype": {
"type": "string",
"title": "The mimetype of the thumbnail"
}
},
"title": "The service response"
},
"v0ThumbnailType": {
"type": "string",
"enum": [
"PNG",
"JPG",
"GIF"
],
"default": "PNG",
"description": "The file types to which the thumbnail can be encoded to.\n\n - PNG: Represents PNG type\n - JPG: Represents JPG type\n - GIF: Represents GIF type"
},
"v0WebdavSource": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "REQUIRED."
},
"isPublicLink": {
"type": "boolean",
"description": "REQUIRED."
},
"webdavAuthorization": {
"type": "string",
"description": "OPTIONAL."
},
"revaAuthorization": {
"type": "string",
"description": "OPTIONAL."
},
"publicLinkToken": {
"type": "string",
"description": "OPTIONAL."
}
}
}
},
"externalDocs": {
"description": "Developer Manual",
"url": "https://docs.opencloud.eu/services/thumbnails/"
}
}
@@ -1,127 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc (unknown)
// source: opencloud/services/thumbnails/v0/thumbnails.proto
package v0
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ThumbnailService_GetThumbnail_FullMethodName = "/opencloud.services.thumbnails.v0.ThumbnailService/GetThumbnail"
)
// ThumbnailServiceClient is the client API for ThumbnailService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// A Service for handling thumbnail generation
type ThumbnailServiceClient interface {
// Generates the thumbnail and returns it.
GetThumbnail(ctx context.Context, in *GetThumbnailRequest, opts ...grpc.CallOption) (*GetThumbnailResponse, error)
}
type thumbnailServiceClient struct {
cc grpc.ClientConnInterface
}
func NewThumbnailServiceClient(cc grpc.ClientConnInterface) ThumbnailServiceClient {
return &thumbnailServiceClient{cc}
}
func (c *thumbnailServiceClient) GetThumbnail(ctx context.Context, in *GetThumbnailRequest, opts ...grpc.CallOption) (*GetThumbnailResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetThumbnailResponse)
err := c.cc.Invoke(ctx, ThumbnailService_GetThumbnail_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ThumbnailServiceServer is the server API for ThumbnailService service.
// All implementations must embed UnimplementedThumbnailServiceServer
// for forward compatibility.
//
// A Service for handling thumbnail generation
type ThumbnailServiceServer interface {
// Generates the thumbnail and returns it.
GetThumbnail(context.Context, *GetThumbnailRequest) (*GetThumbnailResponse, error)
mustEmbedUnimplementedThumbnailServiceServer()
}
// UnimplementedThumbnailServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedThumbnailServiceServer struct{}
func (UnimplementedThumbnailServiceServer) GetThumbnail(context.Context, *GetThumbnailRequest) (*GetThumbnailResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetThumbnail not implemented")
}
func (UnimplementedThumbnailServiceServer) mustEmbedUnimplementedThumbnailServiceServer() {}
func (UnimplementedThumbnailServiceServer) testEmbeddedByValue() {}
// UnsafeThumbnailServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ThumbnailServiceServer will
// result in compilation errors.
type UnsafeThumbnailServiceServer interface {
mustEmbedUnimplementedThumbnailServiceServer()
}
func RegisterThumbnailServiceServer(s grpc.ServiceRegistrar, srv ThumbnailServiceServer) {
// If the following call panics, it indicates UnimplementedThumbnailServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ThumbnailService_ServiceDesc, srv)
}
func _ThumbnailService_GetThumbnail_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetThumbnailRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ThumbnailServiceServer).GetThumbnail(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ThumbnailService_GetThumbnail_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ThumbnailServiceServer).GetThumbnail(ctx, req.(*GetThumbnailRequest))
}
return interceptor(ctx, in, info, handler)
}
// ThumbnailService_ServiceDesc is the grpc.ServiceDesc for ThumbnailService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ThumbnailService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "opencloud.services.thumbnails.v0.ThumbnailService",
HandlerType: (*ThumbnailServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetThumbnail",
Handler: _ThumbnailService_GetThumbnail_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "opencloud/services/thumbnails/v0/thumbnails.proto",
}
@@ -1,30 +0,0 @@
syntax = "proto3";
package opencloud.messages.thumbnails.v0;
option go_package = "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/thumbnails/v0";
message WebdavSource {
// REQUIRED.
string url = 1;
// REQUIRED.
bool is_public_link = 2;
// OPTIONAL.
string webdav_authorization = 3;
// OPTIONAL.
string reva_authorization = 4;
// OPTIONAL.
string public_link_token = 5;
}
message CS3Source {
string path = 1;
string authorization = 2;
}
// The file types to which the thumbnail can be encoded to.
enum ThumbnailType {
PNG = 0; // Represents PNG type
JPG = 1; // Represents JPG type
GIF = 2; // Represents GIF type
}
@@ -1,66 +0,0 @@
syntax = "proto3";
package opencloud.services.thumbnails.v0;
option go_package = "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0";
import "opencloud/messages/thumbnails/v0/thumbnails.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {
title: "OpenCloud thumbnails";
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/thumbnails/";
};
};
// A Service for handling thumbnail generation
service ThumbnailService {
// Generates the thumbnail and returns it.
rpc GetThumbnail(GetThumbnailRequest) returns (GetThumbnailResponse);
}
// A request to retrieve a thumbnail
message GetThumbnailRequest {
// The path to the source image
string filepath = 1;
// The type to which the thumbnail should get encoded to.
opencloud.messages.thumbnails.v0.ThumbnailType thumbnail_type = 2;
// The width of the thumbnail
int32 width = 3;
// The height of the thumbnail
int32 height = 4;
// Indicates which image processor to use
string processor = 5;
oneof source {
opencloud.messages.thumbnails.v0.WebdavSource webdav_source = 6;
opencloud.messages.thumbnails.v0.CS3Source cs3_source = 7;
}
}
// The service response
message GetThumbnailResponse {
// The endpoint where the thumbnail can be downloaded.
string data_endpoint = 1;
// The transfer token to be able to download the thumbnail.
string transfer_token = 2;
// The mimetype of the thumbnail
string mimetype = 3;
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
)
@@ -13,7 +13,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
)
// ListSharedWithMe lists the files shared with the current user.
-7
View File
@@ -9,10 +9,3 @@ 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
-19
View File
@@ -8,14 +8,12 @@ import (
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
"github.com/opencloud-eu/opencloud/pkg/log"
"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/pkg/version"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config/parser"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/metrics"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/server/debug"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/server/grpc"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/server/http"
"github.com/spf13/cobra"
@@ -36,10 +34,6 @@ func Server(cfg *config.Config) *cobra.Command {
if err != nil {
return err
}
cfg.GrpcClient, err = ogrpc.NewClient(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
@@ -53,19 +47,6 @@ func Server(cfg *config.Config) *cobra.Command {
gr := runner.NewGroup()
service := grpc.NewService(
grpc.Logger(logger),
grpc.Context(ctx),
grpc.Config(cfg),
grpc.Name(cfg.Service.Name),
grpc.Namespace(cfg.GRPC.Namespace),
grpc.Address(cfg.GRPC.Addr),
grpc.Metrics(m),
grpc.TraceProvider(traceProvider),
grpc.MaxConcurrentRequests(cfg.GRPC.MaxConcurrentRequests),
)
gr.Add(runner.NewGoMicroGrpcServerRunner(cfg.Service.Name+".grpc", service))
server, err := debug.Server(
debug.Logger(logger),
debug.Config(cfg),
+1 -1
View File
@@ -24,7 +24,7 @@ func Version(cfg *config.Config) *cobra.Command {
fmt.Println("")
reg := registry.GetRegistry()
services, err := reg.GetService(cfg.GRPC.Namespace + "." + cfg.Service.Name)
services, err := reg.GetService(cfg.HTTP.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
+9 -23
View File
@@ -5,7 +5,6 @@ import (
"context"
"github.com/opencloud-eu/opencloud/pkg/shared"
"go-micro.dev/v4/client"
)
// Config combines all available configuration parts.
@@ -17,33 +16,20 @@ type Config struct {
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;THUMBNAILS_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
GRPC GRPCConfig `yaml:"grpc"`
HTTP HTTP `yaml:"http"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
GrpcClient client.Client `yaml:"-"`
HTTP HTTP `yaml:"http"`
Thumbnail Thumbnail `yaml:"thumbnail"`
Context context.Context `yaml:"-"`
}
// FileSystemStorage defines the available filesystem storage configuration.
type FileSystemStorage struct {
RootDirectory string `yaml:"root_directory" env:"THUMBNAILS_FILESYSTEMSTORAGE_ROOT" desc:"The directory where the filesystem storage will store the thumbnails. If not defined, the root directory derives from $OC_BASE_DATA_PATH/thumbnails." introductionVersion:"1.0.0"`
}
// Thumbnail defines the available thumbnail related configuration.
// Thumbnail holds the safety limits enforced by the push endpoint. The limits
// mirror the legacy thumbnail service so a request that was rejected before is
// still rejected, but the enforcement now lives at decode time in this service
// (which owns the image) rather than in webdav.
type Thumbnail struct {
Resolutions []string `yaml:"resolutions" env:"THUMBNAILS_RESOLUTIONS" desc:"The supported list of target resolutions in the format WidthxHeight like 32x32. You can define any resolution as required. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
FileSystemStorage FileSystemStorage `yaml:"filesystem_storage"`
WebdavAllowInsecure bool `yaml:"webdav_allow_insecure" env:"OC_INSECURE;THUMBNAILS_WEBDAVSOURCE_INSECURE" desc:"Ignore untrusted SSL certificates when connecting to the webdav source." introductionVersion:"1.0.0"`
CS3AllowInsecure bool `yaml:"cs3_allow_insecure" env:"OC_INSECURE;THUMBNAILS_CS3SOURCE_INSECURE" desc:"Ignore untrusted SSL certificates when connecting to the CS3 source." introductionVersion:"1.0.0"`
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"1.0.0"`
FontMapFile string `yaml:"font_map_file" env:"THUMBNAILS_TXT_FONTMAP_FILE" desc:"The path to a font file for txt thumbnails." introductionVersion:"1.0.0"`
TransferSecret string `yaml:"transfer_secret" env:"THUMBNAILS_TRANSFER_TOKEN" desc:"The secret to sign JWT to download the actual thumbnail file." introductionVersion:"1.0.0"`
DataEndpoint string `yaml:"data_endpoint" env:"THUMBNAILS_DATA_ENDPOINT" desc:"The HTTP endpoint where the actual thumbnail file can be downloaded." introductionVersion:"1.0.0"`
MaxInputWidth int `yaml:"max_input_width" env:"THUMBNAILS_MAX_INPUT_WIDTH" desc:"The maximum width of an input image which is being processed." introductionVersion:"1.0.0"`
MaxInputHeight int `yaml:"max_input_height" env:"THUMBNAILS_MAX_INPUT_HEIGHT" desc:"The maximum height of an input image which is being processed." introductionVersion:"1.0.0"`
MaxInputImageFileSize string `yaml:"max_input_image_file_size" env:"THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE" desc:"The maximum file size of an input image which is being processed. Usable common abbreviations: [KB, KiB, MB, MiB, GB, GiB, TB, TiB, PB, PiB, EB, EiB], example: 2GB." introductionVersion:"1.0.0"`
// MaxInputWidth is the maximum width of an input image which is being processed.
MaxInputWidth int `yaml:"max_input_width" env:"THUMBNAILS_MAX_INPUT_WIDTH" desc:"The maximum width of an input image which is being processed." introductionVersion:"1.0.0"`
// MaxInputHeight is the maximum height of an input image which is being processed.
MaxInputHeight int `yaml:"max_input_height" env:"THUMBNAILS_MAX_INPUT_HEIGHT" desc:"The maximum height of an input image which is being processed." introductionVersion:"1.0.0"`
}
@@ -1,12 +1,6 @@
package defaults
import (
"path"
"strings"
"github.com/opencloud-eu/opencloud/pkg/config/defaults"
"github.com/opencloud-eu/opencloud/pkg/shared"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
)
@@ -27,14 +21,8 @@ func DefaultConfig() *config.Config {
Pprof: false,
Zpages: false,
},
GRPC: config.GRPCConfig{
Addr: "127.0.0.1:9185",
Namespace: "eu.opencloud.api",
MaxConcurrentRequests: 0,
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9186",
Root: "/thumbnails",
Namespace: "eu.opencloud.web",
CORS: config.CORS{
AllowedOrigins: []string{"*"},
@@ -47,17 +35,8 @@ func DefaultConfig() *config.Config {
Name: "thumbnails",
},
Thumbnail: config.Thumbnail{
Resolutions: []string{"16x16", "32x32", "64x64", "128x128", "500x280", "280x500", "1000x560", "560x1000", "512x2048", "1080x1920", "1920x1080", "2160x3840", "3840x2160", "4320x7680", "7680x4320"},
FileSystemStorage: config.FileSystemStorage{
RootDirectory: path.Join(defaults.BaseDataPath(), "thumbnails"),
},
WebdavAllowInsecure: false,
RevaGateway: shared.DefaultRevaConfig().Address,
CS3AllowInsecure: false,
DataEndpoint: "http://127.0.0.1:9186/thumbnails/data",
MaxInputWidth: 7680,
MaxInputHeight: 7680,
MaxInputImageFileSize: "50MB",
MaxInputWidth: 7680,
MaxInputHeight: 7680,
},
}
}
@@ -68,13 +47,6 @@ func EnsureDefaults(cfg *config.Config) {
cfg.LogLevel = "error"
}
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)
}
if cfg.Commons != nil {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
@@ -82,8 +54,4 @@ func EnsureDefaults(cfg *config.Config) {
// Sanitize sanitized the configuration
func Sanitize(cfg *config.Config) {
// nothing to sanitize here atm
if len(cfg.Thumbnail.Resolutions) == 1 && strings.Contains(cfg.Thumbnail.Resolutions[0], ",") {
cfg.Thumbnail.Resolutions = strings.Split(cfg.Thumbnail.Resolutions[0], ",")
}
}
-11
View File
@@ -1,11 +0,0 @@
package config
import "github.com/opencloud-eu/opencloud/pkg/shared"
// GRPCConfig defines the available grpc configuration.
type GRPCConfig struct {
Addr string `yaml:"addr" env:"THUMBNAILS_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
TLS *shared.GRPCServiceTLS `yaml:"tls"`
MaxConcurrentRequests int `yaml:"max_concurrent_requests" env:"THUMBNAILS_MAX_CONCURRENT_REQUESTS" desc:"Number of maximum concurrent thumbnail requests. Default is 0 which is unlimited." introductionVersion:"1.0.0"`
}
-1
View File
@@ -14,7 +14,6 @@ type CORS struct {
type HTTP struct {
Addr string `yaml:"addr" env:"THUMBNAILS_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
Root string `yaml:"root" env:"THUMBNAILS_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
CORS CORS `yaml:"cors"`
}
-22
View File
@@ -1,22 +0,0 @@
package errors
import "errors"
var (
// ErrImageTooLarge defines an error when an input image is too large
ErrImageTooLarge = errors.New("thumbnails: image is too large")
// ErrInvalidType represents the error when a type can't be encoded.
ErrInvalidType = errors.New("thumbnails: can't encode this type")
// ErrNoEncoderForType represents the error when an encoder couldn't be found for a type.
ErrNoEncoderForType = errors.New("thumbnails: no encoder for this type found")
// ErrNoGeneratorForType represents the error when a generator couldn't be found for a type.
ErrNoGeneratorForType = errors.New("thumbnails: no generator for this type found")
// ErrNoImageFromAudioFile defines an error when an image cannot be extracted from an audio file
ErrNoImageFromAudioFile = errors.New("thumbnails: could not extract image from audio file")
// ErrNoConverterForExtractedImageFromGgsFile defines an error when the extracted image from an ggs file could not be converted
ErrNoConverterForExtractedImageFromGgsFile = errors.New("thumbnails: could not find converter for image extracted from ggs file")
// ErrNoConverterForExtractedImageFromAudioFile defines an error when the extracted image from an audio file could not be converted
ErrNoConverterForExtractedImageFromAudioFile = errors.New("thumbnails: could not find converter for image extracted from audio file")
// ErrCS3AuthorizationMissing defines an error when the CS3 authorization is missing
ErrCS3AuthorizationMissing = errors.New("thumbnails: cs3source - authorization missing")
)
@@ -12,33 +12,12 @@ var (
// Metrics defines the available metrics of this service.
type Metrics struct {
Counter *prometheus.CounterVec
Latency *prometheus.SummaryVec
Duration *prometheus.HistogramVec
BuildInfo *prometheus.GaugeVec
}
// New initializes the available metrics.
func New() *Metrics {
m := &Metrics{
Counter: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "getthumbnail_total",
Help: "How many GetThumbnail requests processed",
}, []string{}),
Latency: prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "getthumbnail_latency_microseconds",
Help: "GetThumbnail request latencies in microseconds",
}, []string{}),
Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "getthumbnail_duration_seconds",
Help: "GetThumbnail method requests time in seconds",
}, []string{}),
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
@@ -47,18 +26,6 @@ func New() *Metrics {
}, []string{"version"}),
}
_ = prometheus.Register(
m.Counter,
)
_ = prometheus.Register(
m.Latency,
)
_ = prometheus.Register(
m.Duration,
)
_ = prometheus.Register(
m.BuildInfo,
)
@@ -1,192 +0,0 @@
package preprocessor
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/opencloud-eu/opencloud/pkg/sync"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/opentype"
)
// FontMap maps a script with the target font to be used for that script
// It also uses a DefaultFont in case there isn't a matching script in the map
//
// For cases like Japanese where multiple scripts are used, we rely on the text
// analyzer to use the script which is unique to japanese (Hiragana or Katakana)
// even if it has to overwrite the "official" detected script (Han). This means
// that "Han" should be used just for chinese while "Hiragana" and "Katakana"
// should be used for japanese
type FontMap struct {
FontMap map[string]string `json:"fontMap"`
DefaultFont string `json:"defaultFont"`
}
// FontMapData contains the location of the loaded file (in FLoc) and the FontMap loaded
// from the file
type FontMapData struct {
FMap *FontMap
FLoc string
}
// LoadedFace contains the location of the font used, and the loaded face (font.Face)
// ready to be used
type LoadedFace struct {
FontFile string
Face font.Face
}
// FontLoader represents a FontLoader. Use the "NewFontLoader" to get a instance
type FontLoader struct {
faceCache sync.Cache
fontMapData *FontMapData
faceOpts *opentype.FaceOptions
}
// NewFontLoader creates a new FontLoader based on the fontMapFile. The FaceOptions will
// be the same for all the font loaded by this instance.
// Note that only the fonts described in the fontMapFile will be used.
//
// The fontMapFile has the following structure
//
// {
// "fontMap": {
// "Han": "packaged/myFont-CJK.otf",
// "Arabic": "packaged/myFont-Arab.otf",
// "Latin": "/fonts/regular/myFont.otf"
// }
// "defaultFont": "/fonts/regular/myFont.otf"
// }
//
// The fontMapFile contains paths to where the fonts are located in the FS.
// Absolute paths can be used as shown above. If a relative path is used,
// it will be relative to the fontMapFile location. This should make the
// packaging easier since all the fonts can be placed in the same directory
// where the fontMapFile is, or in inner directories.
func NewFontLoader(fontMapFile string, faceOpts *opentype.FaceOptions) (*FontLoader, error) {
fontMap := &FontMap{}
if fontMapFile != "" {
file, err := os.Open(fontMapFile)
if err != nil {
return nil, err
}
defer file.Close()
parser := json.NewDecoder(file)
if err = parser.Decode(fontMap); err != nil {
return nil, err
}
}
return &FontLoader{
faceCache: sync.NewCache(5),
fontMapData: &FontMapData{
FMap: fontMap,
FLoc: fontMapFile,
},
faceOpts: faceOpts,
}, nil
}
// LoadFaceForScript loads and returns the font face to be used for that script according to the
// FontMap set when the FontLoader was created. If the script doesn't have
// an associated font, a default font will be used. Note that the default font
// might not be able to handle properly the script
func (fl *FontLoader) LoadFaceForScript(script string) (*LoadedFace, error) {
var parsedFont *opentype.Font
var parsingError error
fontFile := fl.fontMapData.FMap.DefaultFont
if val, ok := fl.fontMapData.FMap.FontMap[script]; ok {
fontFile = val
}
if fontFile != "" && !filepath.IsAbs(fontFile) {
fontFile = filepath.Join(filepath.Dir(fl.fontMapData.FLoc), fontFile)
}
// if the face for the script isn't cached, load the font file and create a new face
cachedFace := fl.faceCache.Load(fontFile)
if cachedFace != nil {
return cachedFace.V.(*LoadedFace), nil
}
if fontFile == "" {
parsedFont, parsingError = opentype.Parse(goregular.TTF)
if parsingError != nil {
return nil, parsingError
}
} else {
// opentype.ParseReaderAt seems to require to keep the file opened
// so read the font file into memory
data, err := os.ReadFile(fontFile)
if err != nil {
return nil, err
}
parsedFont, parsingError = opentype.Parse(data)
if parsingError != nil {
return nil, parsingError
}
}
face, err := opentype.NewFace(parsedFont, fl.faceOpts)
if err != nil {
return nil, err
}
loadedFace := &LoadedFace{
FontFile: fontFile,
Face: face,
}
fl.faceCache.Store(fontFile, loadedFace, time.Now().Add(10*time.Minute))
return loadedFace, nil
}
// GetFaceOptSize returns face opt size
func (fl *FontLoader) GetFaceOptSize() float64 {
return fl.faceOpts.Size
}
// GetFaceOptDPI returns face opt DPI
func (fl *FontLoader) GetFaceOptDPI() float64 {
return fl.faceOpts.DPI
}
// GetScriptList returns script list
func (fl *FontLoader) GetScriptList() []string {
fontMap := fl.fontMapData.FMap.FontMap
arePresent := map[string]bool{
"Common": false,
"Inherited": false,
}
listSize := len(fontMap)
for key := range arePresent {
if _, inFontMap := fontMap[key]; inFontMap {
arePresent[key] = true
} else {
listSize++
}
}
keys := make([]string, listSize)
i := 0
for k := range fontMap {
keys[i] = k
i++
}
for script, isPresent := range arePresent {
if !isPresent {
keys[i] = script
i++
}
}
return keys
}
@@ -1,350 +0,0 @@
package preprocessor
import (
"archive/zip"
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"image"
"image/draw"
"image/gif"
"io"
"math"
"mime"
"strings"
"github.com/pkg/errors"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"github.com/dhowden/tag"
thumbnailerErrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// FileConverter is the interface for the file converter
type FileConverter interface {
Convert(r io.Reader) (any, error)
}
// GifDecoder is a converter for the gif file
type GifDecoder struct{}
// Convert reads the gif file and returns the thumbnail image
func (i GifDecoder) Convert(r io.Reader) (any, error) {
img, err := gif.DecodeAll(r)
if err != nil {
return nil, errors.Wrap(err, `could not decode the image`)
}
return img, nil
}
// GgsDecoder is a converter for the geogebra slides file
type GgsDecoder struct{ thumbnailpath string }
// Convert reads the ggs file and returns the thumbnail image
func (g GgsDecoder) Convert(r io.Reader) (any, error) {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
if err != nil {
return nil, err
}
zipReader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
return nil, err
}
for _, file := range zipReader.File {
if file.Name == g.thumbnailpath {
thumbnail, err := file.Open()
if err != nil {
return nil, err
}
converter := ForType("image/png", nil)
if converter == nil {
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromGgsFile
}
img, err := converter.Convert(thumbnail)
if err != nil {
return nil, errors.Wrap(err, `could not decode the image`)
}
return img, nil
}
}
return nil, errors.Errorf("%s not found", g.thumbnailpath)
}
// AudioDecoder is a converter for the audio file
type AudioDecoder struct{}
// Convert reads the audio file and extracts the thumbnail image from the id3 tag
func (i AudioDecoder) Convert(r io.Reader) (any, error) {
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}
m, err := tag.ReadFrom(bytes.NewReader(b))
if err != nil {
return nil, err
}
picture := m.Picture()
if picture == nil {
return nil, thumbnailerErrors.ErrNoImageFromAudioFile
}
converter := ForType(picture.MIMEType, nil)
if converter == nil {
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromAudioFile
}
return converter.Convert(bytes.NewReader(picture.Data))
}
// TxtToImageConverter is a converter for the text file
type TxtToImageConverter struct {
fontLoader *FontLoader
}
// Convert reads the text file and renders it into a thumbnail image
func (t TxtToImageConverter) Convert(r io.Reader) (any, error) {
img := image.NewRGBA(image.Rect(0, 0, 640, 480))
imgBounds := img.Bounds()
draw.Draw(img, imgBounds, image.White, image.Point{}, draw.Src)
fontSizeAsInt := int(math.Ceil(t.fontLoader.GetFaceOptSize()))
margin := 10
minX := fixed.I(imgBounds.Min.X + margin)
maxX := fixed.I(imgBounds.Max.X - margin)
maxY := fixed.I(imgBounds.Max.Y - margin)
initialPoint := fixed.P(imgBounds.Min.X+margin, imgBounds.Min.Y+margin+fontSizeAsInt)
canvas := &font.Drawer{
Dst: img,
Src: image.Black,
Dot: initialPoint,
}
scriptList := t.fontLoader.GetScriptList()
textAnalyzer := NewTextAnalyzer(scriptList)
taOpts := AnalysisOpts{
UseMergeMap: true,
MergeMap: DefaultMergeMap,
}
scanner := bufio.NewScanner(r)
Scan: // Label for the scanner loop, so we can break it easily
for scanner.Scan() {
txt := scanner.Text()
height := fixed.I(fontSizeAsInt) // reset to default height
textResult := textAnalyzer.AnalyzeString(txt, taOpts)
textResult.MergeCommon(DefaultMergeMap)
for _, sRange := range textResult.ScriptRanges {
targetFontFace, err := t.fontLoader.LoadFaceForScript(sRange.TargetScript)
if err != nil {
return nil, err
}
// if the target script is "_unknown" it's expected that the loaded face
// uses the default font
faceHeight := targetFontFace.Face.Metrics().Height
if faceHeight > height {
height = faceHeight
}
canvas.Face = targetFontFace.Face
initialByte := sRange.Low
for _, sRangeSpace := range sRange.Spaces {
if canvas.Dot.Y > maxY {
break Scan
}
drawWord(canvas, textResult.Text[initialByte:sRangeSpace], minX, maxX, height, maxY)
initialByte = sRangeSpace
}
if initialByte <= sRange.High {
// some bytes left to be written
if canvas.Dot.Y > maxY {
break Scan
}
drawWord(canvas, textResult.Text[initialByte:sRange.High+1], minX, maxX, height, maxY)
}
}
canvas.Dot.X = minX
canvas.Dot.Y += height.Mul(fixed.Int26_6(1<<6 + 1<<5)) // height * 1.5
if canvas.Dot.Y > maxY {
break
}
}
return img, scanner.Err()
}
// GGPStruct is the layout of a ggp file (which is basically json)
type GGPStruct struct {
Sections []struct {
Cards []struct {
Element struct {
Image struct {
Base64Image string
}
}
}
}
}
// GgpDecoder is a converter for the geogebra pinboard file
type GgpDecoder struct{}
// Convert reads the ggp file and returns the first thumbnail image
func (j GgpDecoder) Convert(r io.Reader) (any, error) {
ggp := &GGPStruct{}
err := json.NewDecoder(r).Decode(ggp)
if err != nil {
return nil, err
}
elem, err := extractBase64ImageFromGGP(ggp)
if err != nil {
return nil, err
}
b, err := base64.StdEncoding.DecodeString(elem)
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(b))
return img, err
}
func extractBase64ImageFromGGP(ggp *GGPStruct) (string, error) {
if len(ggp.Sections) < 1 || len(ggp.Sections[0].Cards) < 1 {
return "", errors.New("cant find thumbnail in ggp file")
}
raw := strings.Split(ggp.Sections[0].Cards[0].Element.Image.Base64Image, "base64,")
if len(raw) < 2 {
return "", errors.New("cant decode ggp thumbnail")
}
return raw[1], nil
}
// Draw the word in the canvas. The mixX and maxX defines the drawable range
// (X axis) where the word can be drawn (in case the word is too big and doesn't
// fit in the canvas), and the incY defines the increment in the Y axis if we
// need to draw the word in a new line
//
// Note that the word will likely start with a white space char
func drawWord(canvas *font.Drawer, word string, minX, maxX, incY, maxY fixed.Int26_6) {
// calculate the actual measurement of the string at a given X position
measure := func(s string, dotX fixed.Int26_6) (min, max fixed.Int26_6) {
bbox, _ := canvas.BoundString(s)
return dotX + bbox.Min.X, dotX + bbox.Max.X
}
// first try to draw the whole word
absMin, absMax := measure(word, canvas.Dot.X)
if absMin >= minX && absMax <= maxX {
canvas.DrawString(word)
return
}
// try to draw the trimmed word in a new line
trimmed := strings.TrimSpace(word)
oldDot := canvas.Dot
canvas.Dot.X = minX
canvas.Dot.Y += incY
if canvas.Dot.Y <= maxY {
tMin, tMax := measure(trimmed, canvas.Dot.X)
if tMin >= minX && tMax <= maxX {
canvas.DrawString(trimmed)
return
}
}
// if the trimmed word is still too big, draw it char by char
canvas.Dot = oldDot
for _, char := range trimmed {
s := string(char)
_, cMax := measure(s, canvas.Dot.X)
if cMax > maxX {
canvas.Dot.X = minX
canvas.Dot.Y += incY
}
// stop drawing if we exceed maxY
if canvas.Dot.Y > maxY {
return
}
// ensure that we don't start drawing before minX
cMin, _ := measure(s, canvas.Dot.X)
if cMin < minX {
canvas.Dot.X += minX - cMin
}
canvas.DrawString(s)
}
}
// ForType returns the converter for the specified mimeType
func ForType(mimeType string, opts map[string]any) FileConverter {
// We can ignore the error here because we parse it in IsMimeTypeSupported before and if it fails
// return the service call. So we should only get here when the mimeType parses fine.
mimeType, _, _ = mime.ParseMediaType(mimeType)
switch mimeType {
case "text/plain":
fontFileMap := ""
fontFaceOpts := &opentype.FaceOptions{
Size: 12,
DPI: 72,
Hinting: font.HintingNone,
}
if optedFontFileMap, ok := opts["fontFileMap"]; ok {
if stringFontFileMap, ok := optedFontFileMap.(string); ok {
fontFileMap = stringFontFileMap
}
}
if optedFontFaceOpts, ok := opts["fontFaceOpts"]; ok {
if typedFontFaceOpts, ok := optedFontFaceOpts.(*opentype.FaceOptions); ok {
fontFaceOpts = typedFontFaceOpts
}
}
fontLoader, err := NewFontLoader(fontFileMap, fontFaceOpts)
if err != nil {
// if it couldn't create the FontLoader with the specified fontFileMap,
// try to use the default font
fontLoader, _ = NewFontLoader("", fontFaceOpts)
}
return TxtToImageConverter{
fontLoader: fontLoader,
}
case "application/vnd.geogebra.slides":
return GgsDecoder{"_slide0/geogebra_thumbnail.png"}
case "application/vnd.geogebra.pinboard":
return GgpDecoder{}
case "image/gif":
return GifDecoder{}
case "audio/flac":
fallthrough
case "audio/mpeg":
fallthrough
case "audio/ogg":
return AudioDecoder{}
default:
return ImageDecoder{}
}
}
@@ -1,22 +0,0 @@
//go:build !enable_vips
package preprocessor
import (
"io"
"github.com/kovidgoyal/imaging"
"github.com/pkg/errors"
)
// ImageDecoder is a converter for the image file
type ImageDecoder struct{}
// Convert reads the image file and returns the thumbnail image
func (i ImageDecoder) Convert(r io.Reader) (any, error) {
img, err := imaging.Decode(r, imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, `could not decode the image`)
}
return img, nil
}
@@ -1,197 +0,0 @@
package preprocessor
import (
"bytes"
"io"
"os"
"testing"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestImageDecoder(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "ImageDecoder Suite")
}
var _ = Describe("ImageDecoder", func() {
Describe("ImageDecoder", func() {
var fileReader io.Reader
BeforeEach(func() {
fileContent, err := os.ReadFile("test_assets/noise.png")
if err != nil {
panic(err)
}
fileReader = bytes.NewReader(fileContent)
})
It("should decode an image", func() {
decoder := ImageDecoder{}
img, err := decoder.Convert(fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("should return an error if the image is invalid", func() {
decoder := ImageDecoder{}
img, err := decoder.Convert(bytes.NewReader([]byte("not an image")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
})
Describe("GifDecoder", func() {
var fileReader io.Reader
BeforeEach(func() {
fileContent, err := os.ReadFile("test_assets/noise.gif")
if err != nil {
panic(err)
}
fileReader = bytes.NewReader(fileContent)
})
It("should decode a gif", func() {
decoder := GifDecoder{}
img, err := decoder.Convert(fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("should return an error if the gif is invalid", func() {
decoder := GifDecoder{}
img, err := decoder.Convert(bytes.NewReader([]byte("not a gif")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
})
Describe("GgsDecoder", func() {
var fileReader io.Reader
BeforeEach(func() {
fileContent, err := os.ReadFile("test_assets/ggs_test.ggs")
if err != nil {
panic(err)
}
fileReader = bytes.NewReader(fileContent)
})
It("should decode a ggs", func() {
decoder := GgsDecoder{"_slide0/geogebra_thumbnail.png"}
img, err := decoder.Convert(fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("should return an error if the ggs is invalid", func() {
decoder := GgsDecoder{"_slide0/geogebra_thumbnail.png"}
img, err := decoder.Convert(bytes.NewReader([]byte("not a ggs")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
})
Describe("should decode audio", func() {
var fileReader io.Reader
It("should decode an audio", func() {
fileContent, err := os.ReadFile("test_assets/empty.mp3")
if err != nil {
panic(err)
}
fileReader = bytes.NewReader(fileContent)
decoder := AudioDecoder{}
img, err := decoder.Convert(fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("should decode an audio", func() {
fileContent, err := os.ReadFile("test_assets/empty_no_image.mp3")
if err != nil {
panic(err)
}
fileReader = bytes.NewReader(fileContent)
decoder := AudioDecoder{}
img, err := decoder.Convert(fileReader)
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
It("should return an error if the audio is invalid", func() {
decoder := AudioDecoder{}
img, err := decoder.Convert(bytes.NewReader([]byte("not an audio")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
})
Describe("should decode text", func() {
var decoder TxtToImageConverter
BeforeEach(func() {
fontFaceOpts := &opentype.FaceOptions{
Size: 12,
DPI: 72,
Hinting: font.HintingNone,
}
fontLoader, err := NewFontLoader("", fontFaceOpts)
if err != nil {
fontLoader, _ = NewFontLoader("", fontFaceOpts)
}
decoder = TxtToImageConverter{
fontLoader: fontLoader,
}
})
It("should decode a text", func() {
img, err := decoder.Convert(bytes.NewReader([]byte("This is a test text")))
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("fails if the font is missing", func() {
decoder.fontLoader.fontMapData = &FontMapData{
FMap: &FontMap{
DefaultFont: "/some/unknown/font.otf",
},
}
img, err := decoder.Convert(bytes.NewReader([]byte("This is a test text")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
})
Describe("test ForType", func() {
It("should return an ImageDecoder for image types", func() {
decoder := ForType("image/png", nil)
Expect(decoder).To(BeAssignableToTypeOf(ImageDecoder{}))
})
It("should return an GifDecoder for gif types", func() {
decoder := ForType("image/gif", nil)
Expect(decoder).To(BeAssignableToTypeOf(GifDecoder{}))
})
It("should return an GgsDecoder for ggs types", func() {
decoder := ForType("application/vnd.geogebra.ggs", nil)
// This will not return the expected ggsDecoder, but an ImageDecoder since ggs contains an embedded png.
Expect(decoder).To(BeAssignableToTypeOf(ImageDecoder{}))
})
It("should return an AudioDecoder for audio types", func() {
decoder := ForType("audio/mpeg", nil)
Expect(decoder).To(BeAssignableToTypeOf(AudioDecoder{}))
})
It("should return an TxtToImageConverter for text types", func() {
decoder := ForType("text/plain", nil)
Expect(decoder).To(BeAssignableToTypeOf(TxtToImageConverter{}))
})
It("should return an ImageDecoder for unknown types", func() {
decoder := ForType("unknown", nil)
Expect(decoder).To(BeAssignableToTypeOf(ImageDecoder{}))
})
})
})
@@ -1,20 +0,0 @@
//go:build enable_vips
package preprocessor
import (
"io"
"github.com/davidbyttow/govips/v2/vips"
)
func init() {
vips.LoggingSettings(nil, vips.LogLevelError)
}
type ImageDecoder struct{}
func (v ImageDecoder) Convert(r io.Reader) (interface{}, error) {
img, err := vips.NewImageFromReader(r)
return img, err
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

@@ -1,309 +0,0 @@
package preprocessor
import (
"unicode"
"unicode/utf8"
)
// Default list of scripts to be analyzed within the string.
//
// Scripts that aren't present in the list will be considered as part
// of the last "known" script. For example, if "Avestan" script (which isn't
// present) is preceeded by "Arabic" script, then the "Avestan" script will
// be considered as "Arabic"
//
// Punctuation symbols are usually considered part of the "Common" script
var DefaultScripts = []string{
"Arabic",
"Common",
"Devanagari",
"Han",
"Hangul",
"Hiragana",
"Inherited",
"Katakana",
"Latin",
}
// Convenient map[string]map[string]string type used to merge multiple
// scripts into one. This is mainly used for japanese language which uses
// "Han", "Hiragana" and "Katakana" scripts.
//
// The map contains the expected previous script as first key, the expected
// current script as second key, and the resulting script (if both keys
// match) as value
type MergeMap map[string]map[string]string
// The default mergeMap containing info for the japanese scripts
var DefaultMergeMap = MergeMap{
"Han": map[string]string{
"Hiragana": "Hiragana",
"Katakana": "Katakana",
},
"Hiragana": map[string]string{
"Han": "Hiragana",
"Katakana": "Hiragana",
},
"Katakana": map[string]string{
"Han": "Katakana",
"Hiragana": "Hiragana",
},
}
// Analysis options.
type AnalysisOpts struct {
UseMergeMap bool
MergeMap MergeMap
}
// A script range. The range should be attached to a string which could contain
// multiple scripts. The "TargetScript" will go from bytes "Low" to "High"
// (both inclusive), and contains a "RuneCount" number of runes or chars
// (mostly for debugging purposes).
// The Space contains the bytes (inside the range) that are considered as
// white space.
type ScriptRange struct {
Low, High int
Spaces []int
TargetScript string
RuneCount int
}
// The result of a text analysis. It contains the analyzed text, a list of
// script ranges (see the ScriptRange type) and a map containing how many
// runes have been detected for a particular script.
type TextAnalysis struct {
ScriptRanges []ScriptRange
RuneCount map[string]int
Text string
}
// The TextAnalyzer object contains private members. It should be created via
// "NewTextAnalyzer" function.
type TextAnalyzer struct {
scripts map[string]*unicode.RangeTable
scriptListCache []string
}
// Create a new TextAnalyzer. A list of scripts must be provided.
// You can use the "DefaultScripts" variable for a default list,
// although it doesn't contain all the available scripts.
// See the unicode.Scripts variable (in the unicode package) for a
// full list. Note that using invalid scripts will cause an undefined
// behavior
func NewTextAnalyzer(scriptList []string) TextAnalyzer {
scriptRanges := make(map[string]*unicode.RangeTable, len(scriptList))
for _, script := range scriptList {
scriptRanges[script] = unicode.Scripts[script]
}
return TextAnalyzer{
scripts: scriptRanges,
scriptListCache: scriptList,
}
}
// Analyze the target string using the specified options.
// A TextAnalysis will be returned with the result of the analysis.
func (ta *TextAnalyzer) AnalyzeString(word string, opts AnalysisOpts) TextAnalysis {
analysis := TextAnalysis{
ScriptRanges: []ScriptRange{},
RuneCount: make(map[string]int),
Text: word,
}
if len(word) < 1 {
return analysis
}
firstRune, runeLen := utf8.DecodeRuneInString(word)
lastRange := &ScriptRange{
Low: 0,
Spaces: make([]int, 0),
TargetScript: ta.chooseScriptFor(firstRune),
}
firstRuneIsWhiteSpace := unicode.Is(unicode.White_Space, firstRune)
if firstRuneIsWhiteSpace {
lastRange.Spaces = append(lastRange.Spaces, 0)
}
runeCount := 1
for wordIndex, char := range word[runeLen:] {
wordIndex += runeLen // shifted from the original string
script := ta.chooseScriptFor(char)
isWhiteSpace := unicode.Is(unicode.White_Space, char)
if script != lastRange.TargetScript {
if mapScript, isOk := ta.getMergeMapValue(opts, lastRange.TargetScript, script); isOk {
lastRange.TargetScript = mapScript
if isWhiteSpace {
// TODO: Check if this is dead code.
// whitespace should be part of the "Common" script, and the Common
// script shouldn't be part of a mergeMap
lastRange.Spaces = append(lastRange.Spaces, wordIndex)
}
runeCount++
continue
}
lastRange.High = wordIndex - 1
lastRange.RuneCount = runeCount
analysis.ScriptRanges = append(analysis.ScriptRanges, *lastRange)
if _, exists := analysis.RuneCount[lastRange.TargetScript]; !exists {
analysis.RuneCount[lastRange.TargetScript] = 0
}
analysis.RuneCount[lastRange.TargetScript] += runeCount
lastRange = &ScriptRange{
Low: wordIndex,
Spaces: make([]int, 0),
TargetScript: script,
}
runeCount = 0
}
runeCount++
if isWhiteSpace {
lastRange.Spaces = append(lastRange.Spaces, wordIndex)
}
}
// close the last range
lastRange.High = len(word) - 1
lastRange.RuneCount = runeCount
analysis.RuneCount[lastRange.TargetScript] += runeCount
analysis.ScriptRanges = append(analysis.ScriptRanges, *lastRange)
return analysis
}
func (ta *TextAnalyzer) chooseScriptFor(char rune) string {
script := "_unknown"
for scriptIndex, scriptFound := range ta.scriptListCache {
// if we can't match with a known script, do nothing and jump to the next char
if unicode.Is(ta.scripts[scriptFound], char) {
if scriptIndex > 3 {
// we might expect more chars with the same script
// so move the script first to match it faster next time
ta.reorderScriptList(scriptFound)
}
return scriptFound
}
}
return script
}
// Reorder the scriptListCache in the TextAnalyzer in order to speed up
// the next script searches. A "Latin" script is expected to be surrounded
// by "Latin" chars, although "Common" script chars might be present too
func (ta *TextAnalyzer) reorderScriptList(matchedScript string) {
for index, script := range ta.scriptListCache {
if script == matchedScript {
if index != 0 {
// move the script to the first position for a faster matching
newList := append([]string{script}, ta.scriptListCache[:index]...)
ta.scriptListCache = append(newList, ta.scriptListCache[index+1:]...)
}
// if index == 0 there is nothing to do: the element is already the first
break
}
}
}
// Get the value from the merge map based on the previous and current scripts.
// The information about using the merge map and the actual merge map will be
// gotten from the AnalysisOpts passed as parameter
func (ta *TextAnalyzer) getMergeMapValue(opts AnalysisOpts, previous, current string) (string, bool) {
if opts.UseMergeMap {
// This option mainly target japanese chars; multiple scripts can be used
// in the same piece of text (Han, Hiragana and Katakana)
// Instead of starting a new range, adjust the target script of the last range
if expCurrent, currentOk := opts.MergeMap[previous]; currentOk {
if expFinal, finalOk := expCurrent[current]; finalOk {
return expFinal, finalOk
}
}
}
return "", false
}
// Change the "Common" script to the one used in the previous script range.
// The ranges will be readjusted and merged if they're adjacent.
// This naive approach should be good enough for normal use cases
//
// The MergeMap is needed in case of the japanese language: the ranges
// "Han"-"Common"-"Katakana" might be replaced to "Han"-"Hiragana"-"Katakana"
// However, the ranges should be merged together into a big "Hiragana" range.
// If the MergeMap isn't needed, use an empty one
func (tr *TextAnalysis) MergeCommon(mergeMap MergeMap) {
var finalRanges []ScriptRange
if len(tr.ScriptRanges) < 1 {
// no ranges -> nothing to do
return
}
previousRange := &ScriptRange{}
*previousRange = tr.ScriptRanges[0]
for _, sRange := range tr.ScriptRanges[1:] {
if previousRange.TargetScript == sRange.TargetScript {
previousRange.High = sRange.High
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
previousRange.RuneCount += sRange.RuneCount
} else if sRange.TargetScript == "Common" || sRange.TargetScript == "Inherited" {
// new range will be absorbed into the previous one
previousRange.High = sRange.High
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
previousRange.RuneCount += sRange.RuneCount
tr.RuneCount[previousRange.TargetScript] += sRange.RuneCount
tr.RuneCount[sRange.TargetScript] -= sRange.RuneCount
} else if previousRange.TargetScript == "Common" || previousRange.TargetScript == "Inherited" {
// might happen if the text starts with a Common script
previousRange.High = sRange.High
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
tr.RuneCount[sRange.TargetScript] += previousRange.RuneCount
tr.RuneCount[previousRange.TargetScript] -= previousRange.RuneCount
previousRange.RuneCount += sRange.RuneCount
previousRange.TargetScript = sRange.TargetScript
} else {
if mapScript, isOk := tr.getMergeMapValue(mergeMap, previousRange.TargetScript, sRange.TargetScript); isOk {
if sRange.TargetScript == mapScript {
// the previous range has changed the target script
tr.RuneCount[previousRange.TargetScript] -= previousRange.RuneCount
tr.RuneCount[sRange.TargetScript] += previousRange.RuneCount
} else {
// new range has been absorbed
tr.RuneCount[sRange.TargetScript] -= sRange.RuneCount
tr.RuneCount[previousRange.TargetScript] += sRange.RuneCount
}
previousRange.TargetScript = mapScript
previousRange.High = sRange.High
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
previousRange.RuneCount += sRange.RuneCount
continue
}
finalRanges = append(finalRanges, *previousRange)
*previousRange = sRange
}
}
finalRanges = append(finalRanges, *previousRange)
tr.ScriptRanges = finalRanges
delete(tr.RuneCount, "Common")
delete(tr.RuneCount, "Inherited")
for index, rCount := range tr.RuneCount {
if rCount == 0 {
delete(tr.RuneCount, index)
}
}
}
func (tr *TextAnalysis) getMergeMapValue(mMap MergeMap, previous, current string) (string, bool) {
// This option mainly target japanese chars; multiple scripts can be used
// in the same piece of text (Han, Hiragana and Katakana)
// Instead of starting a new range, adjust the target script of the last range
if expCurrent, currentOk := mMap[previous]; currentOk {
if expFinal, finalOk := expCurrent[current]; finalOk {
return expFinal, finalOk
}
}
return "", false
}
@@ -1,614 +0,0 @@
package preprocessor
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
var (
inputs = [18]string{
"basic latin",
"trailing tab ",
"Small text. \"$\", \"£\" and \"¥\" are currencies.",
"latin with 🖖",
"기본 한국어",
"基本的な日本語",
"ウーロン茶",
"私はエンジニアです",
"ティー私はエンジニアです",
"私はエンジニアです ティー",
"आधारभूत देवनागरी",
"mixed 언어 传入 🚀!",
"/k͜p/",
// ä and a + ¨
"ä ä",
"базовый русский", // cyrillic script isn't part of our default
"latin русский", // latin + cyrillic (cyrillic not supported)
" space justified ",
"",
}
)
func TestAnalyzeString(t *testing.T) {
defaultOpts := AnalysisOpts{
UseMergeMap: true,
MergeMap: DefaultMergeMap,
}
tables := []struct {
input string
opts AnalysisOpts
eOut TextAnalysis
}{
{
input: inputs[0],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 10, Spaces: []int{5}, TargetScript: "Latin", RuneCount: 11},
},
RuneCount: map[string]int{
"Latin": 11,
},
Text: inputs[0],
},
},
{
input: inputs[1],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 12, Spaces: []int{8, 12}, TargetScript: "Latin", RuneCount: 13},
},
RuneCount: map[string]int{
"Latin": 13,
},
Text: inputs[1],
},
},
{
input: inputs[2],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 45, Spaces: []int{5, 11, 16, 21, 25, 30, 34}, TargetScript: "Latin", RuneCount: 44},
},
RuneCount: map[string]int{
"Latin": 44,
},
Text: inputs[2],
},
},
{
input: inputs[3],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 14, Spaces: []int{5, 10}, TargetScript: "Latin", RuneCount: 12},
},
RuneCount: map[string]int{
"Latin": 12,
},
Text: inputs[3],
},
},
{
input: inputs[4],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 15, Spaces: []int{6}, TargetScript: "Hangul", RuneCount: 6},
},
RuneCount: map[string]int{
"Hangul": 6,
},
Text: inputs[4],
},
},
{
input: inputs[5],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 20, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 7},
},
RuneCount: map[string]int{
"Hiragana": 7,
},
Text: inputs[5],
},
},
{
input: inputs[6],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 14, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 5},
},
RuneCount: map[string]int{
"Katakana": 5,
},
Text: inputs[6],
},
},
{
input: inputs[7],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 26, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 9},
},
RuneCount: map[string]int{
"Hiragana": 9,
},
Text: inputs[7],
},
},
{
input: inputs[8],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 35, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 12},
},
RuneCount: map[string]int{
"Hiragana": 12,
},
Text: inputs[8],
},
},
{
input: inputs[9],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 36, Spaces: []int{27}, TargetScript: "Hiragana", RuneCount: 13},
},
RuneCount: map[string]int{
"Hiragana": 13,
},
Text: inputs[9],
},
},
{
input: inputs[10],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 45, Spaces: []int{21}, TargetScript: "Devanagari", RuneCount: 16},
},
RuneCount: map[string]int{
"Devanagari": 16,
},
Text: inputs[10],
},
},
{
input: inputs[11],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 5, Spaces: []int{5}, TargetScript: "Latin", RuneCount: 6},
{Low: 6, High: 12, Spaces: []int{12}, TargetScript: "Hangul", RuneCount: 3},
{Low: 13, High: 24, Spaces: []int{19}, TargetScript: "Han", RuneCount: 5}, // 🚀 and ! are "Common" script and will be merged with "Han"
},
RuneCount: map[string]int{
"Latin": 6,
"Hangul": 3,
"Han": 5,
},
Text: inputs[11],
},
},
{
input: inputs[12],
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 5, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
},
RuneCount: map[string]int{
"Latin": 5,
},
Text: inputs[12],
},
},
{
input: inputs[13], // ä and a + ¨
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 5, Spaces: []int{2}, TargetScript: "Latin", RuneCount: 4},
},
RuneCount: map[string]int{
"Latin": 4,
},
Text: inputs[13],
},
},
{
input: inputs[14], // cyrillic script isn't part of our default
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 28, Spaces: []int{14}, TargetScript: "_unknown", RuneCount: 15},
},
RuneCount: map[string]int{
"_unknown": 15,
},
Text: inputs[14],
},
},
{
input: inputs[15], // latin + cyrillic (cyrillic script isn't part of our default)
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 5, Spaces: []int{5}, TargetScript: "Latin", RuneCount: 6},
{Low: 6, High: 19, Spaces: []int{}, TargetScript: "_unknown", RuneCount: 7},
},
RuneCount: map[string]int{
"Latin": 6,
"_unknown": 7,
},
Text: inputs[15],
},
},
{
input: inputs[16], // latin + cyrillic (cyrillic script isn't part of our default)
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 16, Spaces: []int{0, 6, 16}, TargetScript: "Latin", RuneCount: 17},
},
RuneCount: map[string]int{
"Latin": 17,
},
Text: inputs[16],
},
},
{
input: inputs[17], // latin + cyrillic (cyrillic script isn't part of our default)
opts: defaultOpts,
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{},
RuneCount: map[string]int{},
Text: inputs[17],
},
},
}
for _, table := range tables {
testname := fmt.Sprintf("Analyzing \"%s\" string", table.input)
t.Run(testname, func(t *testing.T) {
ta := NewTextAnalyzer(DefaultScripts)
result := ta.AnalyzeString(table.input, table.opts)
if table.opts.UseMergeMap {
result.MergeCommon(table.opts.MergeMap)
} else {
result.MergeCommon(MergeMap{})
}
assert.Equal(t, table.eOut, result)
})
}
}
func TestAnalyzeStringRaw(t *testing.T) {
tables := []struct {
input string
eOut TextAnalysis
}{
{
input: inputs[0],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 4, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
{Low: 5, High: 5, Spaces: []int{5}, TargetScript: "Common", RuneCount: 1},
{Low: 6, High: 10, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
},
RuneCount: map[string]int{
"Latin": 10,
"Common": 1,
},
Text: inputs[0],
},
},
{
input: inputs[1],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 7, Spaces: []int{}, TargetScript: "Latin", RuneCount: 8},
{Low: 8, High: 8, Spaces: []int{8}, TargetScript: "Common", RuneCount: 1},
{Low: 9, High: 11, Spaces: []int{}, TargetScript: "Latin", RuneCount: 3},
{Low: 12, High: 12, Spaces: []int{12}, TargetScript: "Common", RuneCount: 1},
},
RuneCount: map[string]int{
"Latin": 11,
"Common": 2,
},
Text: inputs[1],
},
},
{
input: inputs[2],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 4, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
{Low: 5, High: 5, Spaces: []int{5}, TargetScript: "Common", RuneCount: 1},
{Low: 6, High: 9, Spaces: []int{}, TargetScript: "Latin", RuneCount: 4},
{Low: 10, High: 21, Spaces: []int{11, 16, 21}, TargetScript: "Common", RuneCount: 11}, // £ takes 2 bytes
{Low: 22, High: 24, Spaces: []int{}, TargetScript: "Latin", RuneCount: 3},
{Low: 25, High: 30, Spaces: []int{25, 30}, TargetScript: "Common", RuneCount: 5}, // ¥ takes 2 bytes
{Low: 31, High: 33, Spaces: []int{}, TargetScript: "Latin", RuneCount: 3},
{Low: 34, High: 34, Spaces: []int{34}, TargetScript: "Common", RuneCount: 1},
{Low: 35, High: 44, Spaces: []int{}, TargetScript: "Latin", RuneCount: 10},
{Low: 45, High: 45, Spaces: []int{}, TargetScript: "Common", RuneCount: 1},
},
RuneCount: map[string]int{
"Latin": 25,
"Common": 19,
},
Text: inputs[2],
},
},
{
input: inputs[3],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 4, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
{Low: 5, High: 5, Spaces: []int{5}, TargetScript: "Common", RuneCount: 1},
{Low: 6, High: 9, Spaces: []int{}, TargetScript: "Latin", RuneCount: 4},
{Low: 10, High: 14, Spaces: []int{10}, TargetScript: "Common", RuneCount: 2},
},
RuneCount: map[string]int{
"Latin": 9,
"Common": 3,
},
Text: inputs[3],
},
},
{
input: inputs[4],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 5, Spaces: []int{}, TargetScript: "Hangul", RuneCount: 2},
{Low: 6, High: 6, Spaces: []int{6}, TargetScript: "Common", RuneCount: 1},
{Low: 7, High: 15, Spaces: []int{}, TargetScript: "Hangul", RuneCount: 3},
},
RuneCount: map[string]int{
"Hangul": 5,
"Common": 1,
},
Text: inputs[4],
},
},
{
input: inputs[5],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 8, Spaces: []int{}, TargetScript: "Han", RuneCount: 3},
{Low: 9, High: 11, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 1},
{Low: 12, High: 20, Spaces: []int{}, TargetScript: "Han", RuneCount: 3},
},
RuneCount: map[string]int{
"Hiragana": 1,
"Han": 6,
},
Text: inputs[5],
},
},
{
input: inputs[6],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 2, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 1},
{Low: 3, High: 5, Spaces: []int{}, TargetScript: "Common", RuneCount: 1}, // ー U+30FC (KATAKANA-HIRAGANA PROLONGED SOUND MARK) seems to be counted as Common
{Low: 6, High: 11, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 2},
{Low: 12, High: 14, Spaces: []int{}, TargetScript: "Han", RuneCount: 1},
},
RuneCount: map[string]int{
"Katakana": 3,
"Common": 1,
"Han": 1,
},
Text: inputs[6],
},
},
{
input: inputs[7],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 2, Spaces: []int{}, TargetScript: "Han", RuneCount: 1},
{Low: 3, High: 5, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 1},
{Low: 6, High: 20, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 5},
{Low: 21, High: 26, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 2},
},
RuneCount: map[string]int{
"Han": 1,
"Hiragana": 3,
"Katakana": 5,
},
Text: inputs[7],
},
},
{
input: inputs[8],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 5, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 2},
{Low: 6, High: 8, Spaces: []int{}, TargetScript: "Common", RuneCount: 1},
{Low: 9, High: 11, Spaces: []int{}, TargetScript: "Han", RuneCount: 1},
{Low: 12, High: 14, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 1},
{Low: 15, High: 29, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 5},
{Low: 30, High: 35, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 2},
},
RuneCount: map[string]int{
"Han": 1,
"Hiragana": 3,
"Katakana": 7,
"Common": 1,
},
Text: inputs[8],
},
},
{
input: inputs[9],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 2, Spaces: []int{}, TargetScript: "Han", RuneCount: 1},
{Low: 3, High: 5, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 1},
{Low: 6, High: 20, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 5},
{Low: 21, High: 26, Spaces: []int{}, TargetScript: "Hiragana", RuneCount: 2},
{Low: 27, High: 27, Spaces: []int{27}, TargetScript: "Common", RuneCount: 1},
{Low: 28, High: 33, Spaces: []int{}, TargetScript: "Katakana", RuneCount: 2},
{Low: 34, High: 36, Spaces: []int{}, TargetScript: "Common", RuneCount: 1},
},
RuneCount: map[string]int{
"Han": 1,
"Hiragana": 3,
"Katakana": 7,
"Common": 2,
},
Text: inputs[9],
},
},
{
input: inputs[10],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 20, Spaces: []int{}, TargetScript: "Devanagari", RuneCount: 7},
{Low: 21, High: 21, Spaces: []int{21}, TargetScript: "Common", RuneCount: 1},
{Low: 22, High: 45, Spaces: []int{}, TargetScript: "Devanagari", RuneCount: 8},
},
RuneCount: map[string]int{
"Devanagari": 15,
"Common": 1,
},
Text: inputs[10],
},
},
{
input: inputs[11],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 4, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
{Low: 5, High: 5, Spaces: []int{5}, TargetScript: "Common", RuneCount: 1},
{Low: 6, High: 11, Spaces: []int{}, TargetScript: "Hangul", RuneCount: 2},
{Low: 12, High: 12, Spaces: []int{12}, TargetScript: "Common", RuneCount: 1},
{Low: 13, High: 18, Spaces: []int{}, TargetScript: "Han", RuneCount: 2},
{Low: 19, High: 24, Spaces: []int{19}, TargetScript: "Common", RuneCount: 3},
},
RuneCount: map[string]int{
"Latin": 5,
"Hangul": 2,
"Han": 2,
"Common": 5,
},
Text: inputs[11],
},
},
{
input: inputs[12],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 0, Spaces: []int{}, TargetScript: "Common", RuneCount: 1},
{Low: 1, High: 1, Spaces: []int{}, TargetScript: "Latin", RuneCount: 1},
{Low: 2, High: 3, Spaces: []int{}, TargetScript: "Inherited", RuneCount: 1},
{Low: 4, High: 4, Spaces: []int{}, TargetScript: "Latin", RuneCount: 1},
{Low: 5, High: 5, Spaces: []int{}, TargetScript: "Common", RuneCount: 1},
},
RuneCount: map[string]int{
"Latin": 2,
"Common": 2,
"Inherited": 1,
},
Text: inputs[12],
},
},
{
input: inputs[13], // ä and a + ¨
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 1, Spaces: []int{}, TargetScript: "Latin", RuneCount: 1},
{Low: 2, High: 2, Spaces: []int{2}, TargetScript: "Common", RuneCount: 1},
{Low: 3, High: 3, Spaces: []int{}, TargetScript: "Latin", RuneCount: 1},
{Low: 4, High: 5, Spaces: []int{}, TargetScript: "Inherited", RuneCount: 1},
},
RuneCount: map[string]int{
"Latin": 2,
"Common": 1,
"Inherited": 1,
},
Text: inputs[13],
},
},
{
input: inputs[14], // cyrillic script isn't part of our default
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 13, Spaces: []int{}, TargetScript: "_unknown", RuneCount: 7},
{Low: 14, High: 14, Spaces: []int{14}, TargetScript: "Common", RuneCount: 1},
{Low: 15, High: 28, Spaces: []int{}, TargetScript: "_unknown", RuneCount: 7},
},
RuneCount: map[string]int{
"_unknown": 14,
"Common": 1,
},
Text: inputs[14],
},
},
{
input: inputs[15], // latin + cyrillic (cyrillic script isn't part of our default)
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 4, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
{Low: 5, High: 5, Spaces: []int{5}, TargetScript: "Common", RuneCount: 1},
{Low: 6, High: 19, Spaces: []int{}, TargetScript: "_unknown", RuneCount: 7},
},
RuneCount: map[string]int{
"Latin": 5,
"Common": 1,
"_unknown": 7,
},
Text: inputs[15],
},
},
{
input: inputs[16],
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{
{Low: 0, High: 0, Spaces: []int{0}, TargetScript: "Common", RuneCount: 1},
{Low: 1, High: 5, Spaces: []int{}, TargetScript: "Latin", RuneCount: 5},
{Low: 6, High: 6, Spaces: []int{6}, TargetScript: "Common", RuneCount: 1},
{Low: 7, High: 15, Spaces: []int{}, TargetScript: "Latin", RuneCount: 9},
{Low: 16, High: 16, Spaces: []int{16}, TargetScript: "Common", RuneCount: 1},
},
RuneCount: map[string]int{
"Latin": 14,
"Common": 3,
},
Text: inputs[16],
},
},
{
input: inputs[17], // empty string
eOut: TextAnalysis{
ScriptRanges: []ScriptRange{},
RuneCount: map[string]int{},
Text: inputs[17],
},
},
}
for _, table := range tables {
testname := fmt.Sprintf("Raw-Analyzing \"%s\" string", table.input)
t.Run(testname, func(t *testing.T) {
ta := NewTextAnalyzer(DefaultScripts)
result := ta.AnalyzeString(table.input, AnalysisOpts{})
assert.Equal(t, table.eOut, result)
})
}
}
@@ -16,8 +16,7 @@ func Server(opts ...Option) (*http.Server, error) {
checkHandler := handlers.NewCheckHandler(
handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)).
WithCheck("grpc reachability", checks.NewGRPCCheck(options.Config.GRPC.Addr)),
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)),
)
return debug.NewService(
@@ -1,100 +0,0 @@
package grpc
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/metrics"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Name string
Address string
Logger log.Logger
Context context.Context
Config *config.Config
Metrics *metrics.Metrics
Namespace string
TraceProvider trace.TracerProvider
MaxConcurrentRequests int
}
// newOptions initializes the available default options.
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
}
}
// Name provides a name for the service.
func Name(val string) Option {
return func(o *Options) {
o.Name = val
}
}
// Address provides an address for the service.
func Address(val string) Option {
return func(o *Options) {
o.Address = 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
}
}
// Namespace provides a function to set the namespace option.
func Namespace(val string) Option {
return func(o *Options) {
o.Namespace = val
}
}
// TraceProvider provides a function to set the TraceProvider option
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
// MaxConcurrentRequests provides a function to set the MaxConcurrentRequests option.
func MaxConcurrentRequests(val int) Option {
return func(o *Options) {
o.MaxConcurrentRequests = val
}
}
@@ -1,92 +0,0 @@
package grpc
import (
"github.com/opencloud-eu/opencloud/pkg/registry"
"github.com/opencloud-eu/opencloud/pkg/service/grpc"
"github.com/opencloud-eu/opencloud/pkg/service/grpc/handler/ratelimiter"
"github.com/opencloud-eu/opencloud/pkg/version"
thumbnailssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
svc "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/service/grpc/v0"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/service/grpc/v0/decorators"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/imgsource"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/storage"
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// NewService initializes the grpc service and server.
func NewService(opts ...Option) grpc.Service {
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.Logger(options.Logger),
grpc.Namespace(options.Namespace),
grpc.Name(options.Name),
grpc.Version(version.GetString()),
grpc.Address(options.Address),
grpc.Context(options.Context),
grpc.Version(version.GetString()),
grpc.TraceProvider(options.TraceProvider),
grpc.HandlerWrappers(ratelimiter.NewHandlerWrapper(options.MaxConcurrentRequests)),
)
if err != nil {
options.Logger.Fatal().Err(err).Msg("Error creating thumbnail service")
return grpc.Service{}
}
tconf := options.Config.Thumbnail
tm, err := pool.StringToTLSMode(options.Config.GRPCClientTLS.Mode)
if err != nil {
options.Logger.Error().Err(err).Msg("could not get gateway client tls mode")
return grpc.Service{}
}
gatewaySelector, err := pool.GatewaySelector(tconf.RevaGateway,
pool.WithTLSCACert(options.Config.GRPCClientTLS.CACert),
pool.WithTLSMode(tm),
pool.WithRegistry(registry.GetRegistry()),
pool.WithTracerProvider(options.TraceProvider),
)
if err != nil {
options.Logger.Error().Err(err).Msg("could not get gateway selector")
return grpc.Service{}
}
b, err := bytesize.Parse(tconf.MaxInputImageFileSize)
if err != nil {
options.Logger.Error().Err(err).Msg("could not parse MaxInputImageFileSize")
return grpc.Service{}
}
var thumbnail decorators.DecoratedService
{
thumbnail = svc.NewService(
svc.Config(options.Config),
svc.Logger(options.Logger),
svc.ThumbnailSource(imgsource.NewWebDavSource(tconf, b)),
svc.ThumbnailStorage(
storage.NewFileSystemStorage(
tconf.FileSystemStorage,
options.Logger,
),
),
svc.CS3Source(imgsource.NewCS3Source(tconf, gatewaySelector, b)),
svc.GatewaySelector(gatewaySelector),
)
thumbnail = decorators.NewInstrument(thumbnail, options.Metrics)
thumbnail = decorators.NewLogging(thumbnail, options.Logger)
thumbnail = decorators.NewTracing(thumbnail, options.TraceProvider)
}
_ = thumbnailssvc.RegisterThumbnailServiceHandler(
service.Server(),
thumbnail,
)
return service
}
@@ -9,7 +9,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/service/http"
"github.com/opencloud-eu/opencloud/pkg/version"
svc "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/service/http/v0"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/storage"
"go-micro.dev/v4"
)
@@ -53,19 +52,8 @@ func Server(opts ...Option) (http.Service, error) {
),
opencloudmiddleware.Logger(options.Logger),
),
svc.ThumbnailStorage(
storage.NewFileSystemStorage(
options.Config.Thumbnail.FileSystemStorage,
options.Logger,
),
),
)
{
handle = svc.NewInstrument(handle, options.Metrics)
handle = svc.NewLogging(handle, options.Logger)
}
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
return http.Service{}, err
}
@@ -1,63 +0,0 @@
package decorators
import (
"context"
thumbnailssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
)
// DecoratedService is an interface acting as facade, holding all the interfaces that this
// thumbnails microservice is expecting to implement.
// For now, only the thumbnailssvc.ThumbnailServiceHandler is present,
// but a future configsvc.ConfigServiceHandler is expected to be added here
//
// This interface will also act as the base interface to implement
// a decorator pattern.
type DecoratedService interface {
thumbnailssvc.ThumbnailServiceHandler
}
// Decorator is the base type to implement the decorators. It will provide a basic implementation
// by delegating to the decoratedService
//
// Expected implementations will be like:
// ```
//
// type MyDecorator struct {
// Decorator
// myCustomOpts *opts
// additionalSrv *srv
// }
//
// func NewMyDecorator(next DecoratedService, customOpts *customOpts) DecoratedService {
// .....
// return MyDecorator{
// Decorator: Decorator{next: next},
// myCustomOpts: opts,
// additionalSrv: srv,
// }
// }
//
// ```
type Decorator struct {
next DecoratedService
}
// GetThumbnail is the base implementation for the thumbnailssvc.GetThumbnail.
// It will just delegate to the underlying decoratedService
//
// Your custom decorator is expected to overwrite this function,
// but it MUST call the underlying decoratedService at some point
// ```
//
// func (d MyDecorator) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest, resp *thumbnailssvc.GetThumbnailResponse) error {
// doSomething()
// err := d.next.GetThumbnail(ctx, req, resp)
// doAnotherThing()
// return err
// }
//
// ```
func (deco Decorator) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest, resp *thumbnailssvc.GetThumbnailResponse) error {
return deco.next.GetThumbnail(ctx, req, resp)
}
@@ -1,39 +0,0 @@
package decorators
import (
"context"
thumbnailssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/metrics"
"github.com/prometheus/client_golang/prometheus"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next DecoratedService, metrics *metrics.Metrics) DecoratedService {
return instrument{
Decorator: Decorator{next: next},
metrics: metrics,
}
}
type instrument struct {
Decorator
metrics *metrics.Metrics
}
// GetThumbnail implements the ThumbnailServiceHandler interface.
func (i instrument) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest, rsp *thumbnailssvc.GetThumbnailResponse) error {
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
us := v * 1000_000
i.metrics.Latency.WithLabelValues().Observe(us)
i.metrics.Duration.WithLabelValues().Observe(v)
}))
defer timer.ObserveDuration()
err := i.next.GetThumbnail(ctx, req, rsp)
if err != nil {
i.metrics.Counter.WithLabelValues().Inc()
}
return err
}
@@ -1,53 +0,0 @@
package decorators
import (
"context"
"net/http"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
thumbnailssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
merrors "go-micro.dev/v4/errors"
)
// NewLogging returns a service that logs messages.
func NewLogging(next DecoratedService, logger log.Logger) DecoratedService {
return logging{
Decorator: Decorator{next: next},
logger: logger,
}
}
type logging struct {
Decorator
logger log.Logger
}
// GetThumbnail implements the ThumbnailServiceHandler interface.
func (l logging) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest, rsp *thumbnailssvc.GetThumbnailResponse) error {
start := time.Now()
err := l.next.GetThumbnail(ctx, req, rsp)
logger := l.logger.With().
Str("method", "Thumbnails.GetThumbnail").
Dur("duration", time.Since(start)).
Logger()
if err != nil {
fromError := merrors.FromError(err)
switch fromError.GetCode() {
case http.StatusNotFound:
logger.Debug().
Str("error_detail", fromError.GetDetail()).
Msg("no thumbnail found")
default:
logger.Warn().
Err(err).
Msg("Failed to execute")
}
} else {
logger.Debug().
Msg("")
}
return err
}
@@ -1,46 +0,0 @@
package decorators
import (
"context"
"go.opentelemetry.io/otel/trace"
thumbnailssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
"go.opentelemetry.io/otel/attribute"
)
// NewTracing returns a service that instruments traces.
func NewTracing(next DecoratedService, tp trace.TracerProvider) DecoratedService {
return tracing{
Decorator: Decorator{next: next},
tp: tp,
}
}
type tracing struct {
Decorator
tp trace.TracerProvider
}
// GetThumbnail implements the ThumbnailServiceHandler interface.
func (t tracing) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest, rsp *thumbnailssvc.GetThumbnailResponse) error {
var span trace.Span
if t.tp != nil {
tracer := t.tp.Tracer("thumbnails")
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
}
ctx, span = tracer.Start(ctx, "Thumbnails.GetThumbnail", spanOpts...)
defer span.End()
span.SetAttributes(
attribute.KeyValue{Key: "filepath", Value: attribute.StringValue(req.GetFilepath())},
attribute.KeyValue{Key: "thumbnail_type", Value: attribute.StringValue(req.GetThumbnailType().String())},
attribute.KeyValue{Key: "width", Value: attribute.IntValue(int(req.GetWidth()))},
attribute.KeyValue{Key: "height", Value: attribute.IntValue(int(req.GetHeight()))},
)
}
return t.next.GetThumbnail(ctx, req, rsp)
}
@@ -1,79 +0,0 @@
package svc
import (
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/imgsource"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/storage"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// 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
Middleware []func(http.Handler) http.Handler
ThumbnailStorage storage.Storage
ImageSource imgsource.Source
CS3Source imgsource.Source
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
// newOptions initializes the available default options.
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
}
}
// ThumbnailStorage provides a function to set the thumbnail storage option.
func ThumbnailStorage(val storage.Storage) Option {
return func(o *Options) {
o.ThumbnailStorage = val
}
}
// ThumbnailSource provides a function to set the image source option.
func ThumbnailSource(val imgsource.Source) Option {
return func(o *Options) {
o.ImageSource = val
}
}
// CS3Source provides a function to set the CS3Source option
func CS3Source(val imgsource.Source) Option {
return func(o *Options) {
o.CS3Source = val
}
}
// GatewaySelector adds a grpc client selector for the gateway service
func GatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.GatewaySelector = val
}
}
@@ -1,331 +0,0 @@
package svc
import (
"context"
"net/http"
"net/url"
"path"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/golang-jwt/jwt/v5"
"github.com/pkg/errors"
merrors "go-micro.dev/v4/errors"
"google.golang.org/grpc/metadata"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/opencloud-eu/opencloud/pkg/log"
thumbnailssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
terrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/preprocessor"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/service/grpc/v0/decorators"
tjwt "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/service/jwt"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/imgsource"
)
// NewService returns a service implementation for Service.
func NewService(opts ...Option) decorators.DecoratedService {
options := newOptions(opts...)
logger := options.Logger
resolutions, err := thumbnail.ParseResolutions(options.Config.Thumbnail.Resolutions)
if err != nil {
logger.Fatal().Err(err).Msg("resolutions not configured correctly")
}
svc := Thumbnail{
serviceID: options.Config.GRPC.Namespace + "." + options.Config.Service.Name,
manager: thumbnail.NewSimpleManager(
resolutions,
options.ThumbnailStorage,
logger,
options.Config.Thumbnail.MaxInputWidth,
options.Config.Thumbnail.MaxInputHeight,
),
webdavSource: options.ImageSource,
cs3Source: options.CS3Source,
logger: logger,
selector: options.GatewaySelector,
preprocessorOpts: PreprocessorOpts{
TxtFontFileMap: options.Config.Thumbnail.FontMapFile,
},
dataEndpoint: options.Config.Thumbnail.DataEndpoint,
transferSecret: options.Config.Thumbnail.TransferSecret,
}
return svc
}
// Thumbnail implements the GRPC handler.
type Thumbnail struct {
serviceID string
dataEndpoint string
transferSecret string
manager thumbnail.Manager
webdavSource imgsource.Source
cs3Source imgsource.Source
logger log.Logger
selector pool.Selectable[gateway.GatewayAPIClient]
preprocessorOpts PreprocessorOpts
}
// PreprocessorOpts holds the options for the preprocessor
type PreprocessorOpts struct {
TxtFontFileMap string
}
// GetThumbnail retrieves a thumbnail for an image
func (g Thumbnail) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest, rsp *thumbnailssvc.GetThumbnailResponse) error {
var err error
var key string
switch {
case req.GetWebdavSource() != nil:
key, err = g.handleWebdavSource(ctx, req)
case req.GetCs3Source() != nil:
key, err = g.handleCS3Source(ctx, req)
default:
g.logger.Error().Msg("no image source provided")
return merrors.BadRequest(g.serviceID, "image source is missing")
}
if err != nil {
return err
}
claims := tjwt.ThumbnailClaims{
Key: key,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Minute)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
transferToken, err := token.SignedString([]byte(g.transferSecret))
if err != nil {
g.logger.Error().
Err(err).
Msg("GetThumbnail: failed to sign token")
return merrors.InternalServerError(g.serviceID, "couldn't finish request")
}
rsp.DataEndpoint = g.dataEndpoint
rsp.TransferToken = transferToken
return nil
}
func (g Thumbnail) checkThumbnail(req *thumbnailssvc.GetThumbnailRequest, sRes *provider.StatResponse) (string, thumbnail.Request, error) {
tr := thumbnail.Request{}
if !sRes.GetInfo().GetPermissionSet().GetInitiateFileDownload() {
return "", tr, merrors.Forbidden(g.serviceID, "no download permission")
}
tType := thumbnail.GetExtForMime(sRes.GetInfo().GetMimeType())
if tType == "" {
tType = req.GetThumbnailType().String()
}
tr, err := thumbnail.PrepareRequest(int(req.GetWidth()), int(req.GetHeight()), tType, sRes.GetInfo().GetChecksum().GetSum(), req.GetProcessor())
if err != nil {
return "", tr, merrors.BadRequest(g.serviceID, "%s", err.Error())
}
if key, exists := g.manager.CheckThumbnail(tr); exists {
return key, tr, nil
}
return "", tr, nil
}
func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest) (string, error) {
src := req.GetCs3Source()
sRes, err := g.stat(src.GetPath(), src.GetAuthorization())
if err != nil {
return "", err
}
key, tr, err := g.checkThumbnail(req, sRes)
switch {
case err != nil:
return "", err
case key != "":
// we have matching thumbnail already, use that
return key, nil
}
ctx = imgsource.ContextSetAuthorization(ctx, src.GetAuthorization())
r, err := g.cs3Source.Get(ctx, src.GetPath())
switch {
case errors.Is(err, terrors.ErrImageTooLarge):
return "", merrors.Forbidden(g.serviceID, "%s", err.Error())
case err != nil:
return "", merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error())
}
defer r.Close()
ppOpts := map[string]any{
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
}
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType(), ppOpts)
img, err := pp.Convert(r)
if err != nil {
g.logger.Error().Err(err).Msg("failed to convert image")
}
if img == nil || err != nil {
return "", merrors.NotFound(g.serviceID, "could not get image")
}
key, err = g.manager.Generate(tr, img)
if errors.Is(err, terrors.ErrImageTooLarge) {
return "", merrors.Forbidden(g.serviceID, "%s", err.Error())
}
return key, err
}
func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest) (string, error) {
src := req.GetWebdavSource()
imgURL, err := url.Parse(src.GetUrl())
if err != nil {
return "", errors.Wrap(err, "source url is invalid")
}
var auth, statPath string
if src.GetIsPublicLink() {
q := imgURL.Query()
var rsp *gateway.AuthenticateResponse
client, err := g.selector.Next()
if err != nil {
return "", merrors.InternalServerError(g.serviceID, "could not select next gateway client: %s", err.Error())
}
if q.Get("signature") != "" && q.Get("expiration") != "" {
// Handle pre-signed public links
sig := q.Get("signature")
exp := q.Get("expiration")
rsp, err = client.Authenticate(ctx, &gateway.AuthenticateRequest{
Type: "publicshares",
ClientId: src.GetPublicLinkToken(),
ClientSecret: strings.Join([]string{"signature", sig, exp}, "|"),
})
} else {
rsp, err = client.Authenticate(ctx, &gateway.AuthenticateRequest{
Type: "publicshares",
ClientId: src.GetPublicLinkToken(),
// We pass an empty password because we expect non pre-signed public links
// to not be password protected
ClientSecret: "password|",
})
}
if err != nil {
return "", merrors.InternalServerError(g.serviceID, "could not authenticate: %s", err.Error())
}
auth = rsp.GetToken()
statPath = path.Join("/public", src.GetPublicLinkToken(), req.GetFilepath())
} else {
auth = src.GetRevaAuthorization()
statPath = req.GetFilepath()
}
sRes, err := g.stat(statPath, auth)
if err != nil {
return "", err
}
key, tr, err := g.checkThumbnail(req, sRes)
switch {
case err != nil:
return "", err
case key != "":
// we have matching thumbnail already, use that
return key, nil
}
if src.GetWebdavAuthorization() != "" {
ctx = imgsource.ContextSetAuthorization(ctx, src.GetWebdavAuthorization())
}
// add signature and expiration to webdav url
signature, expiration := imgURL.Query().Get("signature"), imgURL.Query().Get("expiration")
params := url.Values{}
params.Add("signature", signature)
params.Add("expiration", expiration)
imgURL.RawQuery = params.Encode()
r, err := g.webdavSource.Get(ctx, imgURL.String())
switch {
case errors.Is(err, terrors.ErrImageTooLarge):
return "", merrors.Forbidden(g.serviceID, "%s", err.Error())
case err != nil:
return "", merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error())
}
defer r.Close()
ppOpts := map[string]any{
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
}
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType(), ppOpts)
img, err := pp.Convert(r)
if img == nil || err != nil {
return "", merrors.NotFound(g.serviceID, "could not get image")
}
key, err = g.manager.Generate(tr, img)
if errors.Is(err, terrors.ErrImageTooLarge) {
return "", merrors.Forbidden(g.serviceID, "%s", err.Error())
}
return key, err
}
func (g Thumbnail) stat(path, auth string) (*provider.StatResponse, error) {
ctx := metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, auth)
ref, err := storagespace.ParseReference(path)
if err != nil {
// If the path is not a spaces reference try to handle it like a plain
// path reference.
ref = provider.Reference{
Path: path,
}
}
client, err := g.selector.Next()
if err != nil {
return nil, merrors.InternalServerError(g.serviceID, "could not select next gateway client: %s", err.Error())
}
req := &provider.StatRequest{Ref: &ref}
rsp, err := client.Stat(ctx, req)
if err != nil {
g.logger.Error().Err(err).Str("path", path).Msg("could not stat file")
return nil, merrors.InternalServerError(g.serviceID, "could not stat file: %s", err.Error())
}
if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
switch rsp.GetStatus().GetCode() {
case rpc.Code_CODE_NOT_FOUND:
return nil, merrors.NotFound(g.serviceID, "could not stat file: %s", rsp.GetStatus().GetMessage())
default:
g.logger.Error().Str("status_message", rsp.GetStatus().GetMessage()).Str("path", path).Msg("could not stat file")
return nil, merrors.InternalServerError(g.serviceID, "could not stat file: %s", rsp.GetStatus().GetMessage())
}
}
if rsp.GetInfo().GetType() != provider.ResourceType_RESOURCE_TYPE_FILE {
return nil, merrors.BadRequest(g.serviceID, "Unsupported file type")
}
if utils.ReadPlainFromOpaque(rsp.GetInfo().GetOpaque(), "status") == "processing" {
return nil, &merrors.Error{
Id: g.serviceID,
Code: http.StatusTooEarly,
Detail: "File Processing",
Status: http.StatusText(http.StatusTooEarly),
}
}
if rsp.GetInfo().GetChecksum().GetSum() == "" {
g.logger.Error().Msg("resource info is missing checksum")
return nil, merrors.NotFound(g.serviceID, "resource info is missing a checksum")
}
if !thumbnail.IsMimeTypeSupported(rsp.GetInfo().GetMimeType()) {
return nil, merrors.NotFound(g.serviceID, "Unsupported file type")
}
return rsp, nil
}
@@ -1,30 +0,0 @@
package svc
import (
"net/http"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/metrics"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// ServeHTTP implements the Service interface.
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
i.next.ServeHTTP(w, r)
}
// GetThumbnail implements the Service interface.
func (i instrument) GetThumbnail(w http.ResponseWriter, r *http.Request) {
i.next.GetThumbnail(w, r)
}
@@ -1,30 +0,0 @@
package svc
import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/log"
)
// NewLogging returns a service that logs messages.
func NewLogging(next Service, logger log.Logger) Service {
return logging{
next: next,
logger: logger,
}
}
type logging struct {
next Service
logger log.Logger
}
// ServeHTTP implements the Service interface.
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l.next.ServeHTTP(w, r)
}
// GetThumbnail implements the Service interface.
func (l logging) GetThumbnail(w http.ResponseWriter, r *http.Request) {
l.next.GetThumbnail(w, r)
}
@@ -5,7 +5,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/storage"
"go.opentelemetry.io/otel/trace"
)
@@ -14,11 +13,10 @@ type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
ThumbnailStorage storage.Storage
TraceProvider trace.TracerProvider
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
@@ -52,10 +50,3 @@ func Middleware(val ...func(http.Handler) http.Handler) Option {
o.Middleware = val
}
}
// ThumbnailStorage provides a function to set the ThumbnailStorage option.
func ThumbnailStorage(storage storage.Storage) Option {
return func(o *Options) {
o.ThumbnailStorage = storage
}
}
@@ -1,32 +1,18 @@
package svc
import (
"context"
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/riandyrn/otelchi"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/tracing"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
tjwt "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/service/jwt"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
)
type contextKey string
const (
keyContextKey contextKey = "key"
)
// Service defines the service handlers.
type Service interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
GetThumbnail(w http.ResponseWriter, r *http.Request)
}
// NewService returns a service implementation for Service.
@@ -45,30 +31,14 @@ func NewService(opts ...Option) Service {
),
)
logger := options.Logger
resolutions, err := thumbnail.ParseResolutions(options.Config.Thumbnail.Resolutions)
if err != nil {
logger.Fatal().Err(err).Msg("resolutions not configured correctly")
}
svc := Thumbnails{
config: options.Config,
mux: m,
logger: options.Logger,
manager: thumbnail.NewSimpleManager(
resolutions,
options.ThumbnailStorage,
logger,
options.Config.Thumbnail.MaxInputWidth,
options.Config.Thumbnail.MaxInputHeight,
),
maxWidth: options.Config.Thumbnail.MaxInputWidth,
maxHeight: options.Config.Thumbnail.MaxInputHeight,
}
limits := options.Config.Thumbnail
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Use(svc.TransferTokenValidator)
r.Get("/data", svc.GetThumbnail)
})
svc := Thumbnails{
logger: options.Logger,
mux: m,
maxWidth: limits.MaxInputWidth,
maxHeight: limits.MaxInputHeight,
}
// Push-based thumbnail generation endpoint (imagor-compatible). The optional
// operation segment selects the resize/crop mode; its absence is the default
@@ -90,10 +60,8 @@ func NewService(opts ...Option) Service {
// Thumbnails implements the business logic for Service.
type Thumbnails struct {
config *config.Config
logger log.Logger
mux *chi.Mux
manager thumbnail.Manager
maxWidth int
maxHeight int
}
@@ -102,63 +70,3 @@ type Thumbnails struct {
func (s Thumbnails) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// GetThumbnail implements the Service interface.
func (s Thumbnails) GetThumbnail(w http.ResponseWriter, r *http.Request) {
logger := s.logger.SubloggerWithRequestID(r.Context())
key := r.Context().Value(keyContextKey).(string)
thumbnailBytes, err := s.manager.GetThumbnail(key)
if err != nil {
logger.Debug().
Err(err).
Str("key", key).
Msg("could not get the thumbnail")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Length", strconv.Itoa(len(thumbnailBytes)))
if _, err = w.Write(thumbnailBytes); err != nil {
logger.Error().
Err(err).
Str("key", key).
Msg("could not write the thumbnail response")
}
}
// TransferTokenValidator validates a transfer token
func (s Thumbnails) TransferTokenValidator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := r.Header.Get("Transfer-Token")
if tokenString == "" {
next.ServeHTTP(w, r)
return
}
logger := s.logger.SubloggerWithRequestID(r.Context())
token, err := jwt.ParseWithClaims(tokenString, &tjwt.ThumbnailClaims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.config.Thumbnail.TransferSecret), nil
})
if err != nil {
logger.Debug().
Err(err).
Str("transfer-token", tokenString).
Msg("failed to parse transfer token")
w.WriteHeader(http.StatusUnauthorized)
return
}
if claims, ok := token.Claims.(*tjwt.ThumbnailClaims); ok && token.Valid {
ctx := context.WithValue(r.Context(), keyContextKey, claims.Key)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
logger.Debug().Msg("invalid transfer token")
w.WriteHeader(http.StatusUnauthorized)
})
}
@@ -1,9 +0,0 @@
package jwt
import "github.com/golang-jwt/jwt/v5"
// ThumbnailClaims defines the claims for thumb-nailing
type ThumbnailClaims struct {
jwt.RegisteredClaims
Key string `json:"key"`
}
@@ -1,81 +0,0 @@
package thumbnail
import (
"image/gif"
"io"
"strings"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
const (
typePng = "png"
typeJpg = "jpg"
typeJpeg = "jpeg"
typeGif = "gif"
typeGgs = "ggs"
typeGgp = "ggp"
typeWebp = "webp"
)
// Encoder encodes the thumbnail to a specific format.
type Encoder interface {
// Encode encodes the image to a format.
Encode(w io.Writer, img any) error
// Types returns the formats suffixes.
Types() []string
// MimeType returns the mimetype used by the encoder.
MimeType() string
}
// GifEncoder encodes to gif
type GifEncoder struct{}
// Encode encodes the image to a gif format
func (e GifEncoder) Encode(w io.Writer, img any) error {
g, ok := img.(*gif.GIF)
if !ok {
return errors.ErrInvalidType
}
return gif.EncodeAll(w, g)
}
// Types returns the supported types of the GifEncoder
func (e GifEncoder) Types() []string {
return []string{typeGif}
}
// MimeType returns the mimetype used by the encoder.
func (e GifEncoder) MimeType() string {
return "image/gif"
}
// EncoderForType returns the encoder for a given file type
// or nil if the type is not supported.
func EncoderForType(fileType string) (Encoder, error) {
switch strings.ToLower(fileType) {
case typePng, typeGgs, typeGgp:
return PngEncoder{}, nil
case typeJpg, typeJpeg, typeWebp:
return JpegEncoder{}, nil
case typeGif:
return GifEncoder{}, nil
default:
return nil, errors.ErrNoEncoderForType
}
}
// GetExtForMime return the supported extension by mime
func GetExtForMime(fileType string) string {
ext := strings.TrimPrefix(strings.TrimSpace(strings.ToLower(fileType)), "image/")
switch ext {
case typeJpg, typeJpeg, typePng, typeGif, typeWebp:
return ext
case "application/vnd.geogebra.slides":
return typeGgs
case "application/vnd.geogebra.pinboard":
return typeGgp
default:
return ""
}
}
@@ -1,56 +0,0 @@
//go:build !enable_vips
package thumbnail
import (
"image"
"image/jpeg"
"image/png"
"io"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// PngEncoder encodes to png
type PngEncoder struct{}
// Encode encodes to png format
func (e PngEncoder) Encode(w io.Writer, img any) error {
m, ok := img.(image.Image)
if !ok {
return errors.ErrInvalidType
}
return png.Encode(w, m)
}
// Types returns the png suffix
func (e PngEncoder) Types() []string {
return []string{typePng}
}
// MimeType returns the mimetype for png files.
func (e PngEncoder) MimeType() string {
return "image/png"
}
// JpegEncoder encodes to jpg
type JpegEncoder struct{}
// Encode encodes to jpg
func (e JpegEncoder) Encode(w io.Writer, img any) error {
m, ok := img.(image.Image)
if !ok {
return errors.ErrInvalidType
}
return jpeg.Encode(w, m, nil)
}
// Types returns the jpg suffixes.
func (e JpegEncoder) Types() []string {
return []string{typeJpeg, typeJpg}
}
// MimeType returns the mimetype for jpg files.
func (e JpegEncoder) MimeType() string {
return "image/jpeg"
}
@@ -1,22 +0,0 @@
package thumbnail
import "testing"
func TestEncoderForType(t *testing.T) {
table := map[string]Encoder{
"jpg": JpegEncoder{},
"JPG": JpegEncoder{},
"jpeg": JpegEncoder{},
"JPEG": JpegEncoder{},
"png": PngEncoder{},
"PNG": PngEncoder{},
"invalid": nil,
}
for k, v := range table {
e, _ := EncoderForType(k)
if e != v {
t.Fail()
}
}
}
@@ -1,66 +0,0 @@
//go:build enable_vips
package thumbnail
import (
"io"
"github.com/davidbyttow/govips/v2/vips"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// PngEncoder encodes to png
type PngEncoder struct{}
// Encode encodes to png format
func (e PngEncoder) Encode(w io.Writer, img interface{}) error {
m, ok := img.(*vips.ImageRef)
if !ok {
return errors.ErrInvalidType
}
buf, _, err := m.ExportPng(vips.NewPngExportParams())
if err != nil {
return err
}
_, err = w.Write(buf)
return err
}
// Types returns the png suffix
func (e PngEncoder) Types() []string {
return []string{typePng}
}
// MimeType returns the mimetype for png files.
func (e PngEncoder) MimeType() string {
return "image/png"
}
// JpegEncoder encodes to jpg
type JpegEncoder struct{}
// Encode encodes to jpg
func (e JpegEncoder) Encode(w io.Writer, img interface{}) error {
m, ok := img.(*vips.ImageRef)
if !ok {
return errors.ErrInvalidType
}
buf, _, err := m.ExportJpeg(vips.NewJpegExportParams())
if err != nil {
return err
}
_, err = w.Write(buf)
return err
}
// Types returns the jpg suffixes.
func (e JpegEncoder) Types() []string {
return []string{typeJpeg, typeJpg}
}
// MimeType returns the mimetype for jpg files.
func (e JpegEncoder) MimeType() string {
return "image/jpeg"
}
@@ -1,99 +0,0 @@
package thumbnail
import (
"image"
"image/color"
"image/draw"
"image/gif"
"strings"
"github.com/kovidgoyal/imaging"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// Generator generates a web friendly file version.
type Generator interface {
Generate(size image.Rectangle, img any) (any, error)
Dimensions(img any) (image.Rectangle, error)
ProcessorID() string
}
// GifGenerator is used to create a web friendly version of the provided gif image.
type GifGenerator struct {
processor Processor
}
func NewGifGenerator(filetype, process string) (GifGenerator, error) {
processor, err := ProcessorFor(process, filetype)
if err != nil {
return GifGenerator{}, err
}
return GifGenerator{processor: processor}, nil
}
// ProcessorID returns the processor identification.
func (g GifGenerator) ProcessorID() string {
return g.processor.ID()
}
// Generate generates a alternative gif version.
func (g GifGenerator) Generate(size image.Rectangle, img any) (any, error) {
// Code inspired by https://github.com/willnorris/gifresize/blob/db93a7e1dcb1c279f7eeb99cc6d90b9e2e23e871/gifresize.go
m, ok := img.(*gif.GIF)
if !ok {
return nil, errors.ErrInvalidType
}
// Create a new RGBA image to hold the incremental frames.
srcX, srcY := m.Config.Width, m.Config.Height
b := image.Rect(0, 0, srcX, srcY)
tmp := image.NewRGBA(b)
for i, frame := range m.Image {
bounds := frame.Bounds()
prev := tmp
draw.Draw(tmp, bounds, frame, bounds.Min, draw.Over)
processed := g.processor.Process(tmp, size.Dx(), size.Dy(), imaging.Lanczos)
m.Image[i] = g.imageToPaletted(processed, frame.Palette)
switch m.Disposal[i] {
case gif.DisposalBackground:
tmp = image.NewRGBA(b)
case gif.DisposalPrevious:
tmp = prev
}
}
m.Config.Width = size.Dx()
m.Config.Height = size.Dy()
return m, nil
}
func (g GifGenerator) Dimensions(img any) (image.Rectangle, error) {
m, ok := img.(*gif.GIF)
if !ok {
return image.Rectangle{}, errors.ErrInvalidType
}
return m.Image[0].Bounds(), nil
}
func (g GifGenerator) imageToPaletted(img image.Image, p color.Palette) *image.Paletted {
b := img.Bounds()
pm := image.NewPaletted(b, p)
draw.FloydSteinberg.Draw(pm, b, img, image.Point{})
return pm
}
// GeneratorFor returns the generator for a given file type
// or nil if the type is not supported.
func GeneratorFor(fileType, processorID string) (Generator, error) {
switch strings.ToLower(fileType) {
case typePng, typeJpg, typeJpeg, typeGgs, typeGgp, typeWebp:
return NewSimpleGenerator(fileType, processorID)
case typeGif:
return NewGifGenerator(fileType, processorID)
default:
return nil, errors.ErrNoGeneratorForType
}
}
@@ -1,46 +0,0 @@
//go:build !enable_vips
package thumbnail
import (
"image"
"github.com/kovidgoyal/imaging"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// SimpleGenerator is the default image generator and is used for all image types expect gif.
type SimpleGenerator struct {
processor Processor
}
func NewSimpleGenerator(filetype, process string) (SimpleGenerator, error) {
processor, err := ProcessorFor(process, filetype)
if err != nil {
return SimpleGenerator{}, err
}
return SimpleGenerator{processor: processor}, nil
}
// ProcessorID returns the processor identification.
func (g SimpleGenerator) ProcessorID() string {
return g.processor.ID()
}
// Generate generates a alternative image version.
func (g SimpleGenerator) Generate(size image.Rectangle, img any) (any, error) {
m, ok := img.(image.Image)
if !ok {
return nil, errors.ErrInvalidType
}
return g.processor.Process(m, size.Dx(), size.Dy(), imaging.Lanczos), nil
}
func (g SimpleGenerator) Dimensions(img any) (image.Rectangle, error) {
m, ok := img.(image.Image)
if !ok {
return image.Rectangle{}, errors.ErrInvalidType
}
return m.Bounds(), nil
}
@@ -1,84 +0,0 @@
//go:build enable_vips
package thumbnail
import (
"bytes"
"image"
"strings"
"github.com/davidbyttow/govips/v2/vips"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
"golang.org/x/image/bmp"
)
// SimpleGenerator is the default image generator and is used for all image types expect gif.
type SimpleGenerator struct {
crop vips.Interesting
size vips.Size
process string
}
func NewSimpleGenerator(filetype, process string) (SimpleGenerator, error) {
switch strings.ToLower(process) {
case "thumbnail":
return SimpleGenerator{crop: vips.InterestingAttention, process: process, size: vips.SizeBoth}, nil
case "fit":
return SimpleGenerator{crop: vips.InterestingNone, process: process, size: vips.SizeBoth}, nil
case "resize":
return SimpleGenerator{crop: vips.InterestingNone, process: process, size: vips.SizeForce}, nil
default:
return SimpleGenerator{crop: vips.InterestingAttention, process: process, size: vips.SizeBoth}, nil
}
}
// ProcessorID returns the processor identification.
func (g SimpleGenerator) ProcessorID() string {
return g.process
}
// Generate generates a alternative image version.
func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}) (interface{}, error) {
var m *vips.ImageRef
var err error
switch img.(type) {
case *image.RGBA:
// This comes from the txt preprocessor
var buf bytes.Buffer
if err = bmp.Encode(&buf, img.(*image.RGBA)); err != nil {
return nil, err
}
m, err = vips.NewImageFromReader(&buf)
if err != nil {
return nil, err
}
case *vips.ImageRef:
m = img.(*vips.ImageRef)
default:
return nil, errors.ErrInvalidType
}
if err := m.ThumbnailWithSize(size.Dx(), size.Dy(), g.crop, g.size); err != nil {
return nil, err
}
if err := m.RemoveMetadata(); err != nil {
return nil, err
}
return m, nil
}
func (g SimpleGenerator) Dimensions(img interface{}) (image.Rectangle, error) {
switch img.(type) {
case *image.RGBA:
m := img.(*image.RGBA)
return m.Bounds(), nil
case *vips.ImageRef:
m := img.(*vips.ImageRef)
return image.Rect(0, 0, m.Width(), m.Height()), nil
default:
return image.Rectangle{}, errors.ErrInvalidType
}
}
@@ -1,132 +0,0 @@
package imgsource
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/rhttp"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"google.golang.org/grpc/metadata"
)
const (
// TokenTransportHeader holds the header key for the reva transfer token
// "github.com/opencloud-eu/reva/v2/internal/http/services/datagateway" is internal so we redeclare it here
TokenTransportHeader = "X-Reva-Transfer"
)
// CS3 implements a CS3 image source
type CS3 struct {
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
insecure bool
maxImageFileSize uint64
}
// NewCS3Source configures a new CS3 image source
func NewCS3Source(cfg config.Thumbnail, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], b bytesize.ByteSize) CS3 {
return CS3{
gatewaySelector: gatewaySelector,
insecure: cfg.CS3AllowInsecure,
maxImageFileSize: b.Bytes(),
}
}
// Get downloads the file from a cs3 service
// The caller MUST make sure to close the returned ReadCloser
func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) {
auth, ok := ContextGetAuthorization(ctx)
if !ok {
return nil, errors.ErrCS3AuthorizationMissing
}
ref, err := storagespace.ParseReference(path)
if err != nil {
// If the path is not a spaces reference try to handle it like a plain
// path reference.
ref = provider.Reference{
Path: path,
}
}
ctx = metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, auth)
err = s.checkImageFileSize(ctx, ref)
if err != nil {
return nil, err
}
gwc, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
rsp, err := gwc.InitiateFileDownload(ctx, &provider.InitiateFileDownloadRequest{Ref: &ref})
if err != nil {
return nil, err
}
if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, fmt.Errorf("could not load image: %s", rsp.GetStatus().GetMessage())
}
var ep, tk string
for _, p := range rsp.GetProtocols() {
if p.GetProtocol() == "spaces" {
ep, tk = p.GetDownloadEndpoint(), p.GetToken()
break
}
}
if (ep == "" || tk == "") && len(rsp.GetProtocols()) > 0 {
ep, tk = rsp.GetProtocols()[0].GetDownloadEndpoint(), rsp.GetProtocols()[0].GetToken()
}
httpReq, err := rhttp.NewRequest(ctx, "GET", ep, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set(revactx.TokenHeader, auth)
httpReq.Header.Set(TokenTransportHeader, tk)
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: s.insecure, //nolint:gosec
}
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("could not get the image \"%s\". Request returned with statuscode %d ", path, resp.StatusCode)
}
return resp.Body, nil
}
func (s CS3) checkImageFileSize(ctx context.Context, ref provider.Reference) error {
gwc, err := s.gatewaySelector.Next()
if err != nil {
return err
}
stat, err := gwc.Stat(ctx, &provider.StatRequest{Ref: &ref})
if err != nil {
return err
}
if stat.GetStatus().GetCode() != rpc.Code_CODE_OK {
return fmt.Errorf("could not stat image: %s", stat.GetStatus().GetMessage())
}
if stat.GetInfo().GetSize() > s.maxImageFileSize {
return errors.ErrImageTooLarge
}
return nil
}
@@ -1,31 +0,0 @@
package imgsource
import (
"context"
"io"
)
type key int
const (
auth key = iota
)
// Source defines the interface for image sources
type Source interface {
Get(ctx context.Context, path string) (io.ReadCloser, error)
}
// ContextSetAuthorization puts the authorization in the context.
func ContextSetAuthorization(parent context.Context, authorization string) context.Context {
return context.WithValue(parent, auth, authorization)
}
// ContextGetAuthorization gets the authorization from the context.
func ContextGetAuthorization(ctx context.Context) (string, bool) {
val := ctx.Value(auth)
if val == nil {
return "", false
}
return val.(string), true
}
@@ -1,75 +0,0 @@
package imgsource
import (
"context"
"crypto/tls"
"fmt"
_ "image/gif" // Import the gif package so that image.Decode can understand gifs
_ "image/jpeg" // Import the jpeg package so that image.Decode can understand jpegs
_ "image/png" // Import the png package so that image.Decode can understand pngs
"io"
"net/http"
"strconv"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
thumbnailerErrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
"github.com/pkg/errors"
)
// NewWebDavSource creates a new webdav instance.
func NewWebDavSource(cfg config.Thumbnail, b bytesize.ByteSize) WebDav {
return WebDav{
insecure: cfg.WebdavAllowInsecure,
maxImageFileSize: b.Bytes(),
}
}
// WebDav implements the Source interface for webdav services
type WebDav struct {
insecure bool
maxImageFileSize uint64
}
// Get downloads the file from a webdav service
// The caller MUST make sure to close the returned ReadCloser
func (s WebDav) Get(ctx context.Context, url string) (io.ReadCloser, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, errors.Wrapf(err, `could not get the image "%s"`, url)
}
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: s.insecure, //nolint:gosec
}
if auth, ok := ContextGetAuthorization(ctx); ok {
req.Header.Add("Authorization", auth)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, errors.Wrapf(err, `could not get the image "%s"`, url)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("could not get the image \"%s\". Request returned with statuscode %d ", url, resp.StatusCode)
}
contentLength := resp.Header.Get("Content-Length")
if contentLength == "" {
// no size information - let's assume it is too big
return nil, thumbnailerErrors.ErrImageTooLarge
}
c, err := strconv.ParseUint(contentLength, 10, 64)
if err != nil {
return nil, errors.Wrapf(err, `could not parse content length of webdav response "%s"`, url)
}
if c > s.maxImageFileSize {
return nil, thumbnailerErrors.ErrImageTooLarge
}
return resp.Body, nil
}
+16 -18
View File
@@ -2,21 +2,19 @@
package thumbnail
var (
// SupportedMimeTypes contains an all mimetypes which are supported by the thumbnailer.
SupportedMimeTypes = map[string]struct{}{
"image/png": {},
"image/jpg": {},
"image/jpeg": {},
"image/gif": {},
"image/bmp": {},
"image/x-ms-bmp": {},
"image/tiff": {},
"text/plain": {},
"audio/flac": {},
"audio/mpeg": {},
"audio/ogg": {},
"application/vnd.geogebra.slides": {},
"application/vnd.geogebra.pinboard": {},
}
)
// SupportedMimeTypes contains all mimetypes which are supported by the thumbnailer.
var SupportedMimeTypes = map[string]struct{}{
"image/png": {},
"image/jpg": {},
"image/jpeg": {},
"image/gif": {},
"image/bmp": {},
"image/x-ms-bmp": {},
"image/tiff": {},
"text/plain": {},
"audio/flac": {},
"audio/mpeg": {},
"audio/ogg": {},
"application/vnd.geogebra.slides": {},
"application/vnd.geogebra.pinboard": {},
}
@@ -2,22 +2,20 @@
package thumbnail
var (
// SupportedMimeTypes contains an all mimetypes which are supported by the thumbnailer.
SupportedMimeTypes = map[string]struct{}{
"image/png": {},
"image/jpg": {},
"image/jpeg": {},
"image/gif": {},
"image/bmp": {},
"image/x-ms-bmp": {},
"image/tiff": {},
"text/plain": {},
"audio/flac": {},
"audio/mpeg": {},
"audio/ogg": {},
"application/vnd.geogebra.slides": {},
"application/vnd.geogebra.pinboard": {},
"image/webp": {},
}
)
// SupportedMimeTypes contains all mimetypes which are supported by the thumbnailer.
var SupportedMimeTypes = map[string]struct{}{
"image/png": {},
"image/jpg": {},
"image/jpeg": {},
"image/gif": {},
"image/bmp": {},
"image/x-ms-bmp": {},
"image/tiff": {},
"image/webp": {},
"text/plain": {},
"audio/flac": {},
"audio/mpeg": {},
"audio/ogg": {},
"application/vnd.geogebra.slides": {},
"application/vnd.geogebra.pinboard": {},
}
@@ -1,51 +0,0 @@
package thumbnail
import (
"image"
"strings"
"github.com/kovidgoyal/imaging"
)
// Processor processes the thumbnail by applying different transformations to it.
type Processor interface {
ID() string
Process(img image.Image, width, height int, filter imaging.ResampleFilter) image.Image
}
// DefinableProcessor is the simplest processor, it holds a replaceable image converter function.
type DefinableProcessor struct {
Slug string
Converter func(img image.Image, width, height int, filter imaging.ResampleFilter) image.Image
}
// ID returns the processor identification.
func (p DefinableProcessor) ID() string { return p.Slug }
// Process transforms the given image.
func (p DefinableProcessor) Process(img image.Image, width, height int, filter imaging.ResampleFilter) image.Image {
return p.Converter(img, width, height, filter)
}
// ProcessorFor returns a matching Processor
func ProcessorFor(id, fileType string) (DefinableProcessor, error) {
switch strings.ToLower(id) {
case "fit":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: imaging.Fit}, nil
case "resize":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: imaging.Resize}, nil
case "fill":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: func(img image.Image, width, height int, filter imaging.ResampleFilter) image.Image {
return imaging.Fill(img, width, height, imaging.Center, filter)
}}, nil
case "thumbnail":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: imaging.Thumbnail}, nil
default:
switch strings.ToLower(fileType) {
case typeGif:
return DefinableProcessor{Converter: imaging.Resize}, nil
default:
return DefinableProcessor{Converter: imaging.Thumbnail}, nil
}
}
}
@@ -1,97 +0,0 @@
package thumbnail_test
import (
"testing"
"github.com/kovidgoyal/imaging"
tAssert "github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
)
func TestProcessorFor(t *testing.T) {
tests := []struct {
id string
fileType string
wantP thumbnail.Processor
wantE error
}{
{
id: "fit",
fileType: "",
wantP: thumbnail.DefinableProcessor{Slug: "fit", Converter: imaging.Fit},
wantE: nil,
},
{
id: "fit",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fit"},
wantE: nil,
},
{
id: "FIT",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fit"},
wantE: nil,
},
{
id: "resize",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "resize"},
wantE: nil,
},
{
id: "RESIZE",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "resize"},
wantE: nil,
},
{
id: "fill",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fill"},
wantE: nil,
},
{
id: "FILL",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fill"},
wantE: nil,
},
{
id: "thumbnail",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "thumbnail"},
wantE: nil,
},
{
id: "THUMBNAIL",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "thumbnail"},
wantE: nil,
},
{
id: "",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{},
wantE: nil,
},
{
id: "",
fileType: "gif",
wantP: thumbnail.DefinableProcessor{},
wantE: nil,
},
}
assert := tAssert.New(t)
for _, tt := range tests {
t.Run("", func(t *testing.T) {
p, e := thumbnail.ProcessorFor(tt.id, tt.fileType)
assert.Equal(p.ID(), tt.wantP.ID())
assert.Equal(e, tt.wantE)
})
}
}
@@ -1,101 +0,0 @@
package thumbnail
import (
"fmt"
"image"
"math"
"strconv"
"strings"
"github.com/pkg/errors"
)
const (
_resolutionSeparator = "x"
)
// ParseResolution returns an image.Rectangle representing the resolution given as a string
func ParseResolution(s string) (image.Rectangle, error) {
parts := strings.Split(s, _resolutionSeparator)
if len(parts) != 2 {
return image.Rectangle{}, fmt.Errorf("failed to parse resolution: %s. Expected format <width>x<height>", s)
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return image.Rectangle{}, fmt.Errorf("width: %s has an invalid value. Expected an integer", parts[0])
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return image.Rectangle{}, fmt.Errorf("height: %s has an invalid value. Expected an integer", parts[1])
}
return image.Rect(0, 0, width, height), nil
}
// Resolutions is a list of image.Rectangle representing resolutions.
type Resolutions []image.Rectangle
// ParseResolutions creates an instance of Resolutions from resolution strings.
func ParseResolutions(strs []string) (Resolutions, error) {
rs := make(Resolutions, 0, len(strs))
for _, s := range strs {
r, err := ParseResolution(s)
if err != nil {
return nil, errors.Wrap(err, "could not parse resolutions")
}
rs = append(rs, r)
}
return rs, nil
}
// ClosestMatch returns the resolution which is closest to the provided resolution.
// If there is no exact match the resolution will be the next higher one.
// If the given resolution is bigger than all available resolutions the biggest available one is used.
func (rs Resolutions) ClosestMatch(requested image.Rectangle, sourceSize image.Rectangle) image.Rectangle {
isLandscape := sourceSize.Dx() > sourceSize.Dy()
sourceLen := dimensionLength(sourceSize, isLandscape)
requestedLen := dimensionLength(requested, isLandscape)
isSourceSmaller := sourceLen < requestedLen
// We don't want to scale images up.
if isSourceSmaller {
return sourceSize
}
if len(rs) == 0 {
return requested
}
var match image.Rectangle
// Since we want to search for the smallest difference we start with the highest possible number
minDiff := math.MaxInt32
for _, current := range rs {
cLen := dimensionLength(current, isLandscape)
diff := requestedLen - cLen
if diff > 0 {
// current is smaller
continue
}
// Convert diff to positive value
// Multiplying by -1 is safe since we aren't getting positive numbers here
// because of the check above
absDiff := diff * -1
if absDiff < minDiff {
minDiff = absDiff
match = current
}
}
if (match == image.Rectangle{}) {
match = rs[len(rs)-1]
}
return match
}
func dimensionLength(rect image.Rectangle, isLandscape bool) int {
if isLandscape {
return rect.Dx()
}
return rect.Dy()
}
@@ -1,121 +0,0 @@
package thumbnail
import (
"image"
"testing"
)
func TestInitWithEmptyArray(t *testing.T) {
rs, err := ParseResolutions([]string{})
if err != nil {
t.Errorf("Init with an empty array should not fail. Error: %s.\n", err.Error())
}
if len(rs) != 0 {
t.Error("Init with an empty array should return an empty Resolutions instance.\n")
}
}
func TestInitWithNil(t *testing.T) {
rs, err := ParseResolutions(nil)
if err != nil {
t.Errorf("Init with nil parameter should not fail. Error: %s.\n", err.Error())
}
if len(rs) != 0 {
t.Error("Init with nil parameter should return an empty Resolutions instance.\n")
}
}
func TestInitWithInvalidValuesInArray(t *testing.T) {
_, err := ParseResolutions([]string{"invalid"})
if err == nil {
t.Error("Init with invalid parameter should fail.\n")
}
}
func TestInit(t *testing.T) {
rs, err := ParseResolutions([]string{"16x16"})
if err != nil {
t.Errorf("Init with valid parameter should not fail. Error: %s.\n", err.Error())
}
if len(rs) != 1 {
t.Errorf("resolutions has size %d, expected size %d.\n", len(rs), 1)
}
}
func TestInitWithMultipleResolutions(t *testing.T) {
rStrs := []string{"16x16", "32x32", "64x64", "128x128"}
rs, err := ParseResolutions(rStrs)
if err != nil {
t.Errorf("Init with valid parameter should not fail. Error: %s.\n", err.Error())
}
if len(rs) != len(rStrs) {
t.Errorf("resolutions has size %d, expected size %d.\n", len(rs), len(rStrs))
}
}
func TestClosestMatchWithEmptyResolutions(t *testing.T) {
rs, _ := ParseResolutions(nil)
want := image.Rect(0, 0, 24, 24)
imgSize := image.Rect(0, 0, 24, 24)
r := rs.ClosestMatch(want, imgSize)
if r.Dx() != want.Dx() || r.Dy() != want.Dy() {
t.Errorf("ClosestMatch from empty resolutions should return the given resolution")
}
}
func TestClosestMatch(t *testing.T) {
rs, _ := ParseResolutions([]string{"16x16", "24x24", "32x32", "64x64", "128x128"})
testData := [][]image.Rectangle{
{image.Rect(0, 0, 17, 17), image.Rect(0, 0, 1920, 1080), image.Rect(0, 0, 24, 24)},
{image.Rect(0, 0, 12, 17), image.Rect(0, 0, 1080, 1920), image.Rect(0, 0, 24, 24)},
{image.Rect(0, 0, 24, 24), image.Rect(0, 0, 1920, 1080), image.Rect(0, 0, 24, 24)},
{image.Rect(0, 0, 20, 20), image.Rect(0, 0, 1920, 1080), image.Rect(0, 0, 24, 24)},
{image.Rect(0, 0, 20, 80), image.Rect(0, 0, 1080, 1920), image.Rect(0, 0, 128, 128)},
{image.Rect(0, 0, 80, 20), image.Rect(0, 0, 1920, 1080), image.Rect(0, 0, 128, 128)},
{image.Rect(0, 0, 48, 48), image.Rect(0, 0, 1920, 1080), image.Rect(0, 0, 64, 64)},
{image.Rect(0, 0, 1024, 1024), image.Rect(0, 0, 1920, 1080), image.Rect(0, 0, 128, 128)},
{image.Rect(0, 0, 1920, 1080), image.Rect(0, 0, 256, 36), image.Rect(0, 0, 256, 36)},
}
for _, row := range testData {
given := row[0]
imgSize := row[1]
expected := row[2]
match := rs.ClosestMatch(given, imgSize)
if match != expected {
t.Errorf("Expected resolution %dx%d got %dx%d", expected.Dx(), expected.Dy(), match.Dx(), match.Dy())
}
}
}
func TestParseWithEmptyString(t *testing.T) {
if _, err := ParseResolution(""); err == nil {
t.Error("Parse with empty string should return an error.")
}
}
func TestParseWithInvalidWidth(t *testing.T) {
_, err := ParseResolution("invalidx42")
if err == nil {
t.Error("Parse with invalid width should return an error.")
}
}
func TestParseWithInvalidHeight(t *testing.T) {
_, err := ParseResolution("42xinvalid")
if err == nil {
t.Error("Parse with invalid height should return an error.")
}
}
func TestParseResolution(t *testing.T) {
rStr := "42x23"
r, _ := ParseResolution(rStr)
if r.Dx() != 42 || r.Dy() != 23 {
t.Errorf("Expected resolution %s got %s", rStr, r.String())
}
}
@@ -1,118 +0,0 @@
package storage
import (
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config"
)
const (
filesDir = "files"
)
// NewFileSystemStorage creates a new instance of FileSystem
func NewFileSystemStorage(cfg config.FileSystemStorage, logger log.Logger) FileSystem {
return FileSystem{
root: cfg.RootDirectory,
logger: logger,
}
}
// FileSystem represents a storage for the thumbnails using the local file system.
type FileSystem struct {
root string
logger log.Logger
}
// Stat returns if a file for the given key exists on the filesystem
func (s FileSystem) Stat(key string) bool {
img := filepath.Join(s.root, filesDir, key)
if _, err := os.Stat(img); err != nil {
return false
}
return true
}
// Get returns the file content for the given key
func (s FileSystem) Get(key string) ([]byte, error) {
img := filepath.Join(s.root, filesDir, key)
content, err := os.ReadFile(img)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
s.logger.Debug().Str("err", err.Error()).Str("key", key).Msg("could not load thumbnail from store")
}
return nil, err
}
return content, nil
}
// Put stores image data in the file system for the given key
func (s FileSystem) Put(key string, img []byte) error {
imgPath := filepath.Join(s.root, filesDir, key)
dir := filepath.Dir(imgPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return errors.Wrapf(err, "error while creating directory %s", dir)
}
if _, err := os.Stat(imgPath); os.IsNotExist(err) {
f, err := os.CreateTemp(dir, "tmpthumb")
if err != nil {
return errors.Wrapf(err, "could not create temporary file for \"%s\"", key)
}
defer f.Close()
// if there was a problem writing, remove the temporary file
if _, writeErr := f.Write(img); writeErr != nil {
if remErr := os.Remove(f.Name()); remErr != nil {
return errors.Wrapf(remErr, "could not cleanup temporary file for \"%s\"", key)
}
return errors.Wrapf(writeErr, "could not write to temporary file for \"%s\"", key)
}
// if there wasn't a problem, ensure the data is written into disk
if synErr := f.Sync(); synErr != nil {
return errors.Wrapf(synErr, "could not sync temporary file data into the disk for \"%s\"", key)
}
// rename the temporary file to the final file
if renErr := os.Rename(f.Name(), imgPath); renErr != nil {
// if we couldn't rename, remove the temporary file
if remErr := os.Remove(f.Name()); remErr != nil {
return errors.Wrapf(remErr, "rename failed and could not cleanup temporary file for \"%s\"", key)
}
return errors.Wrapf(renErr, "could not rename temporary file to \"%s\"", key)
}
}
return nil
}
// BuildKey generate the unique key for a thumbnail.
// The key is structure as follows:
//
// <first two letters of checksum>/<next two letters of checksum>/<rest of checksum>/<width>x<height>.<filetype>
//
// e.g. 97/9f/4c8db98f7b82e768ef478d3c8612/500x300.png
//
// The key also represents the path to the thumbnail in the filesystem under the configured root directory.
func (s FileSystem) BuildKey(r Request) string {
checksum := r.Checksum
filetype := r.Types[0]
parts := []string{strconv.Itoa(r.Resolution.Dx()), "x", strconv.Itoa(r.Resolution.Dy())}
if r.Characteristic != "" {
parts = append(parts, "-", r.Characteristic)
}
parts = append(parts, ".", filetype)
return filepath.Join(checksum[:2], checksum[2:4], checksum[4:], strings.Join(parts, ""))
}
@@ -1,64 +0,0 @@
package storage_test
import (
"image"
"testing"
tAssert "github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/storage"
)
func TestFileSystem_BuildKey(t *testing.T) {
tests := []struct {
r storage.Request
want string
}{
{
r: storage.Request{
Checksum: "120EA8A25E5D487BF68B5F7096440019",
Types: []string{"png", "jpg"},
Resolution: image.Rectangle{
Min: image.Point{
X: 1,
Y: 2,
},
Max: image.Point{
X: 3,
Y: 4,
},
},
Characteristic: "",
},
want: "12/0E/A8A25E5D487BF68B5F7096440019/2x2.png",
},
{
r: storage.Request{
Checksum: "120EA8A25E5D487BF68B5F7096440019",
Types: []string{"png", "jpg"},
Resolution: image.Rectangle{
Min: image.Point{
X: 1,
Y: 2,
},
Max: image.Point{
X: 3,
Y: 4,
},
},
Characteristic: "fill",
},
want: "12/0E/A8A25E5D487BF68B5F7096440019/2x2-fill.png",
},
}
s := storage.FileSystem{}
assert := tAssert.New(t)
for _, tt := range tests {
t.Run("", func(t *testing.T) {
assert.Equal(s.BuildKey(tt.r), tt.want)
})
}
}
@@ -1,32 +0,0 @@
package storage
import (
"image"
)
// Request combines different attributes needed for storage operations.
type Request struct {
// The checksum of the source file
// Will be used to determine if a thumbnail exists
Checksum string
// Types provided by the encoder.
// Contains the mimetypes of the thumbnail.
// In case of jpg/jpeg it will contain both.
Types []string
// The resolution of the thumbnail
Resolution image.Rectangle
// Characteristic defines the different image characteristics,
// for example, if it's scaled up to fit in the bounding box or not,
// is it a chroma version of the image, and so on...
// the main propose for this is to be able to differentiate between images which have
// the same resolution but different characteristics.
Characteristic string
}
// Storage defines the interface for a thumbnail store.
type Storage interface {
Stat(key string) bool
Get(key string) ([]byte, error)
Put(key string, img []byte) error
BuildKey(r Request) string
}
@@ -1,131 +0,0 @@
package thumbnail
import (
"bytes"
"image"
"mime"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/storage"
)
// Request bundles information needed to generate a thumbnail for a file
type Request struct {
Resolution image.Rectangle
Encoder Encoder
Generator Generator
Checksum string
}
// Manager is responsible for generating thumbnails
type Manager interface {
// Generate creates a thumbnail and stores it.
// The function returns a key with which the actual file can be retrieved.
Generate(r Request, img any) (string, error)
// CheckThumbnail checks if a thumbnail with the requested attributes exists.
// The function will return a status if the file exists and the key to the file.
CheckThumbnail(r Request) (string, bool)
// GetThumbnail will load the thumbnail from the storage and return its content.
GetThumbnail(key string) ([]byte, error)
}
// NewSimpleManager creates a new instance of SimpleManager
func NewSimpleManager(resolutions Resolutions, storage storage.Storage, logger log.Logger, maxInputWidth, maxInputHeight int) SimpleManager {
return SimpleManager{
storage: storage,
logger: logger,
resolutions: resolutions,
maxDimension: image.Point{X: maxInputWidth, Y: maxInputHeight},
}
}
// SimpleManager is a simple implementation of Manager
type SimpleManager struct {
storage storage.Storage
logger log.Logger
resolutions Resolutions
maxDimension image.Point
}
// Generate creates a thumbnail and stores it
func (s SimpleManager) Generate(r Request, img any) (string, error) {
var match image.Rectangle
inputDimensions, err := r.Generator.Dimensions(img)
if err != nil {
return "", err
}
match = s.resolutions.ClosestMatch(r.Resolution, inputDimensions)
// validate max input image dimensions - 6016x4000
if inputDimensions.Size().X > s.maxDimension.X || inputDimensions.Size().Y > s.maxDimension.Y {
return "", errors.ErrImageTooLarge
}
thumbnail, err := r.Generator.Generate(match, img)
if err != nil {
return "", err
}
buf := new(bytes.Buffer)
if err := r.Encoder.Encode(buf, thumbnail); err != nil {
return "", err
}
k := s.storage.BuildKey(mapToStorageRequest(r))
if err := s.storage.Put(k, buf.Bytes()); err != nil {
s.logger.Error().Err(err).Msg("could not store thumbnail")
return "", err
}
return k, nil
}
// CheckThumbnail checks if a thumbnail with the requested attributes exists.
func (s SimpleManager) CheckThumbnail(r Request) (string, bool) {
k := s.storage.BuildKey(mapToStorageRequest(r))
return k, s.storage.Stat(k)
}
// GetThumbnail will load the thumbnail from the storage and return its content.
func (s SimpleManager) GetThumbnail(key string) ([]byte, error) {
return s.storage.Get(key)
}
func mapToStorageRequest(r Request) storage.Request {
return storage.Request{
Checksum: r.Checksum,
Resolution: r.Resolution,
Types: r.Encoder.Types(),
Characteristic: r.Generator.ProcessorID(),
}
}
// IsMimeTypeSupported validate if the mime type is supported
func IsMimeTypeSupported(m string) bool {
mimeType, _, err := mime.ParseMediaType(m)
if err != nil {
return false
}
_, supported := SupportedMimeTypes[mimeType]
return supported
}
// PrepareRequest prepare the request based on image parameters
func PrepareRequest(width, height int, tType, checksum, pID string) (Request, error) {
generator, err := GeneratorFor(tType, pID)
if err != nil {
return Request{}, err
}
encoder, err := EncoderForType(tType)
if err != nil {
return Request{}, err
}
return Request{
Resolution: image.Rect(0, 0, width, height),
Generator: generator,
Encoder: encoder,
Checksum: checksum,
}, nil
}
@@ -1,198 +0,0 @@
package thumbnail
import (
"image"
"os"
"path"
"path/filepath"
"reflect"
"testing"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/preprocessor"
"github.com/stretchr/testify/assert"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail/storage"
)
type NoOpManager struct {
storage.Storage
}
func (m NoOpManager) BuildKey(_ storage.Request) string {
return ""
}
func (m NoOpManager) Set(_, _ string, _ []byte) error {
return nil
}
func BenchmarkGet(b *testing.B) {
sut := NewSimpleManager(
Resolutions{},
NoOpManager{},
log.NewLogger(),
6016,
4000,
)
res, _ := ParseResolution("32x32")
req := Request{
Resolution: res,
Checksum: "1872ade88f3013edeb33decd74a4f947",
}
cwd, _ := os.Getwd()
p := filepath.Join(cwd, "../../testdata/test.png")
f, _ := os.Open(p)
defer f.Close()
img, ext, _ := image.Decode(f)
req.Encoder, _ = EncoderForType(ext)
for i := 0; i < b.N; i++ {
_, _ = sut.Generate(req, img)
}
}
func TestPrepareRequest(t *testing.T) {
type args struct {
width int
height int
tType string
checksum string
}
tests := []struct {
name string
args args
want Request
wantErr bool
}{
{
name: "Test successful prepare the request for jpg",
args: args{
width: 32,
height: 32,
tType: "jpg",
checksum: "1872ade88f3013edeb33decd74a4f947",
},
want: Request{
Resolution: image.Rect(0, 0, 32, 32),
Encoder: JpegEncoder{},
Generator: SimpleGenerator{},
Checksum: "1872ade88f3013edeb33decd74a4f947",
},
},
{
name: "Test successful prepare the request for png",
args: args{
width: 32,
height: 32,
tType: "png",
checksum: "1872ade88f3013edeb33decd74a4f947",
},
want: Request{
Resolution: image.Rect(0, 0, 32, 32),
Encoder: PngEncoder{},
Generator: SimpleGenerator{},
Checksum: "1872ade88f3013edeb33decd74a4f947",
},
},
{
name: "Test successful prepare the request for gif",
args: args{
width: 32,
height: 32,
tType: "gif",
checksum: "1872ade88f3013edeb33decd74a4f947",
},
want: Request{
Resolution: image.Rect(0, 0, 32, 32),
Encoder: GifEncoder{},
Generator: GifGenerator{},
Checksum: "1872ade88f3013edeb33decd74a4f947",
},
},
{
name: "Test error when prepare the request for bmp",
args: args{
width: 32,
height: 32,
tType: "bmp",
checksum: "1872ade88f3013edeb33decd74a4f947",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := PrepareRequest(tt.args.width, tt.args.height, tt.args.tType, tt.args.checksum, "")
if (err != nil) != tt.wantErr {
t.Errorf("PrepareRequest() error = %v, wantErr %v", err, tt.wantErr)
return
}
// funcs are not reflactable, ignore
if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreFields(Request{}, "Generator")); diff != "" {
t.Errorf("PrepareRequest(): %v", diff)
}
if reflect.TypeOf(got.Generator) != reflect.TypeOf(tt.want.Generator) {
t.Errorf("PrepareRequest() = %v, want %v", reflect.TypeOf(got.Generator), reflect.TypeOf(tt.want.Generator))
}
})
}
}
func TestPreviewGenerationTooBigImage(t *testing.T) {
tests := []struct {
name string
fileName string
mimeType string
}{
{name: "png", mimeType: "image/png", fileName: "../../testdata/test.png"},
{name: "jpg", mimeType: "image/jpeg", fileName: "../../testdata/test.jpg"},
{name: "ggs", mimeType: "application/vnd.geogebra.slides", fileName: "../../testdata/test.ggs"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sut := NewSimpleManager(
Resolutions{},
NoOpManager{},
log.NewLogger(),
1024,
768,
)
res, _ := ParseResolution("32x32")
req := Request{
Resolution: res,
Checksum: "1872ade88f3013edeb33decd74a4f947",
}
cwd, _ := os.Getwd()
p := filepath.Join(cwd, tt.fileName)
f, _ := os.Open(p)
defer f.Close()
preproc := preprocessor.ForType(tt.mimeType, nil)
convert, err := preproc.Convert(f)
if err != nil {
return
}
ext := path.Ext(tt.fileName)
req.Encoder, _ = EncoderForType(ext)
req.Generator, err = GeneratorFor(ext, "fit")
if err != nil {
return
}
generate, err := sut.Generate(req, convert)
if err != nil {
return
}
assert.ErrorIs(t, err, errors.ErrImageTooLarge)
assert.Equal(t, "", generate)
})
}
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB