Compare commits

..
Author SHA1 Message Date
Jörn Friedrich Dreyer 8d5912a5c4 refactor(thumbnails): remove the pull-based pipeline
Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
2026-09-08 16:10:29 +02:00
Jörn Friedrich Dreyer b6f2adfffb refactor(webdav): drive the thumbnail workflow via the push-based generator
Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
2026-09-08 16:10:29 +02:00
Jörn Friedrich Dreyer 9832ec25e5 feat(thumbnails): add imagor-compatible push-based thumbnail endpoint
Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
2026-09-08 16:10:23 +02:00
opencloudeu c2a34e8c45 [tx] updated from transifex 2026-09-07 23:17:03 +00:00
122 changed files with 7271 additions and 4669 deletions

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;
}
@@ -0,0 +1,157 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Roy-Magne Mo, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-09-07 23:16+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Roy-Magne Mo, 2026\n"
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: no\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorWithVersions, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:137 pkg/unifiedrole/roles.go:143
#: pkg/unifiedrole/roles.go:149 pkg/unifiedrole/roles.go:155
#: pkg/unifiedrole/roles.go:167 pkg/unifiedrole/roles.go:173
#: pkg/unifiedrole/roles.go:179
msgid "Can edit"
msgstr "Kan redigere"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:161
msgid "Can edit without versions"
msgstr "Kan redigere uten versjoner"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:191
msgid "Can manage"
msgstr "Kan håndtere"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:185
msgid "Can upload"
msgstr "Kan laste opp"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole ViewerWithVersions, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:107 pkg/unifiedrole/roles.go:113
#: pkg/unifiedrole/roles.go:119 pkg/unifiedrole/roles.go:125
#: pkg/unifiedrole/roles.go:131
msgid "Can view"
msgstr "Kan se"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:197
msgid "Can view (secure)"
msgstr "Kan vise (sikker)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:203
msgid "Cannot access"
msgstr "Kan ikke få tilgang"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:200
msgid "Deny all access."
msgstr "Avvis all tilgang."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Her kan du legge til en beskrivelse for dette rommet."
#. UnifiedRole ViewerWithVersions, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:110 pkg/unifiedrole/roles.go:128
msgid "View and download including the history."
msgstr "Vis og last ned, inkludert historikken."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:104 pkg/unifiedrole/roles.go:122
msgid "View and download."
msgstr "Vis og last ned."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:194
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr "Vis kun dokumenter, bilder og PDF-er. Vannmerker vil bli brukt."
#. UnifiedRole FileEditorWithVErsions, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:176
msgid "View, download and edit including the history."
msgstr "Vis, last ned og rediger, inkludert historikken."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "View, download and edit."
msgstr "Vis, last ned og rediger."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:116
msgid "View, download and show all invited people."
msgstr "Vis, last ned og vis alle inviterte personer."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:182
msgid "View, download and upload."
msgstr "Vis, last ned og last opp."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "View, download, edit and show all invited people."
msgstr "Vis, last ned, rediger og vis alle inviterte personer."
#. UnifiedRole Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:140
msgid "View, download, upload, edit, add and delete including the history."
msgstr ""
"Vis, last ned, last opp, rediger, legg til og slett, inkludert historikken."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134 pkg/unifiedrole/roles.go:158
msgid "View, download, upload, edit, add and delete."
msgstr "Vis, last ned, last opp, rediger, legg til og slett."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:188
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Vis, last ned, last opp, rediger, legg til, slett og administrer medlemmer."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:146
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Se, last ned, last opp, rediger, legg til, slett og vis alle inviterte "
"personer."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Vis, last ned, last opp, rediger, legg til, slett inkludert historikken."
+44 -24
View File
@@ -222,9 +222,50 @@ func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
}
}
files, ok := g.listDriveItemChildren(w, r, space.GetRoot())
if !ok {
listRequest := &storageprovider.ListContainerRequest{
Ref: &storageprovider.Reference{ResourceId: space.GetRoot()},
}
if driveItemPropertySelected(r, _selectShareTypes) {
listRequest.FieldMask = shareTypesFieldMask
}
lRes, err := gatewayClient.ListContainer(ctx, listRequest)
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error making ListContainer grpc call")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case lRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, lRes.GetStatus().GetMessage())
return
}
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED {
// TODO check if we should return 404 to not disclose existing items
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, lRes.GetStatus().GetMessage())
return
}
g.logger.Error().Err(err).Msg("error sending list container grpc request")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
files, err := formatDriveItems(g.logger, g.publicBaseURL, lRes.GetInfos())
if err != nil {
g.logger.Error().Err(err).Msg("error encoding response as json")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
g.setDriveItemsThumbnails(r, files, lRes.GetInfos())
if driveItemPropertySelected(r, _selectAllowedValues) {
for i, info := range lRes.GetInfos() {
files[i].LibreGraphPermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(info.GetPermissionSet())
}
}
if driveItemPropertySelected(r, _selectShareTypes) {
g.addShareTypes(ctx, files, lRes.GetInfos())
}
render.Status(r, http.StatusOK)
@@ -292,7 +333,7 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
}
if driveItemPropertySelected(r, _selectAllowedValues) {
setDriveItemAllowedValues(driveItem, res.GetInfo())
driveItem.LibreGraphPermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(res.GetInfo().GetPermissionSet())
}
// only containers have children
@@ -395,8 +436,6 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri
return nil, false
}
setDriveItemsAllowedValues(r, files, res.GetInfos())
if driveItemPropertySelected(r, _selectShareTypes) {
g.addShareTypes(r.Context(), files, res.GetInfos())
}
@@ -406,25 +445,6 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri
return files, true
}
// setDriveItemAllowedValues fills @libre.graph.permissions.actions.allowedValues
// from the item's permission set.
func setDriveItemAllowedValues(item *libregraph.DriveItem, info *storageprovider.ResourceInfo) {
item.LibreGraphPermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(info.GetPermissionSet())
}
// setDriveItemsAllowedValues does the same across a listing, when the property
// was selected.
func setDriveItemsAllowedValues(r *http.Request, items []libregraph.DriveItem, infos []*storageprovider.ResourceInfo) {
if !driveItemPropertySelected(r, _selectAllowedValues) {
return
}
for i := range items {
if i < len(infos) {
setDriveItemAllowedValues(&items[i], infos[i])
}
}
}
func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.ResourceId, baseURL *url.URL) (*libregraph.RemoteItem, error) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
@@ -142,7 +142,7 @@ var _ = Describe("Driveitems", func() {
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListContainer permission denied as not found", func() {
It("handles ListContainer permission denied", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
@@ -154,7 +154,7 @@ var _ = Describe("Driveitems", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("handles ListContainer error", func() {
@@ -515,35 +515,6 @@ var _ = Describe("Driveitems", func() {
Expect(res.Value[0].PendingOperations).To(BeNil())
})
It("returns the allowed actions when selected", func() {
r = r.WithContext(r.Context())
q := r.URL.Query()
q.Add("$select", "@libre.graph.permissions.actions.allowedValues")
r.URL.RawQuery = q.Encode()
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
PermissionSet: &provider.ResourcePermissions{
GetPath: true,
InitiateFileDownload: true,
},
},
},
}, nil)
res := assertItemsList(1)
Expect(res.Value[0].GetLibreGraphPermissionsActionsAllowedValues()).To(ConsistOf(
unifiedrole.DriveItemPathRead,
unifiedrole.DriveItemContentRead,
))
})
It("omits share types unless they are selected", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
+1 -1
View File
@@ -8,7 +8,7 @@ import (
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
)
const (
@@ -0,0 +1,212 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Roy-Magne Mo, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-09-07 23:16+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Roy-Magne Mo, 2026\n"
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: no\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:78
msgid "Click here to check it: {ShareLink}"
msgstr "Klikk her for å sjekke det: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:63
msgid "Click here to view it: {ShareLink}"
msgstr "Klikk her for å se den: {ShareLink}"
#: pkg/email/templates.go:139
msgid "Hello {RecipientName},"
msgstr "Hei {RecipientName},"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Hei {ShareGrantee}"
#. ShareRemoved email template, resolves via {{ .Greeting }}
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32 pkg/email/templates.go:45
msgid "Hello {ShareGrantee},"
msgstr "Hei {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:59 pkg/email/templates.go:72
#: pkg/email/templates.go:87
msgid "Hello {SpaceGrantee},"
msgstr "Hei {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:131
msgid "Hi {DisplayName},"
msgstr "Hei {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:100 pkg/email/templates.go:117
msgid "Hi,"
msgstr "Hei,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:85
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Medlemskap i '{SpaceName}' utløpt {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:129
msgid "Report"
msgstr "Rapport"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:98 pkg/email/templates.go:115
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} ønsker å samarbeide med deg"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:43
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Deling til '{ShareFolder}' utløpt {ExpiredAt}"
#: pkg/email/templates.go:141
msgid "You can view the mention here: {ResourceLink}"
msgstr "Du kan se omtalen her: {ResourceLink}"
#: pkg/email/templates.go:138
msgid "You were mentioned in '{ResourceName}'"
msgstr "Du ble nevnt i «{ResourceName}»"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"Medlemskapet ditt i rom {SpaceName} utløp {ExpiredAt}\n"
"\n"
"Selv om dette medlemskapet har utløpt, kan du fortsatt ha tilgang gjennom andre delinger og/eller rommedlemskap"
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:47
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"Deltakelsen din i {ShareFolder} utløp {ExpiredAt}\n"
"\n"
"Selv om denne deltakelsen er trukket tilbake, kan du fortsatt ha tilgang gjennom andre deltakelser og/eller plassmedlemskap."
#: pkg/email/templates.go:140
msgid "{AuthorName} mentioned you in \"{ResourceName}\"."
msgstr "{AuthorName} nevnte deg i «{ResourceName}»."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:119
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) ønsker å begynne å dele samarbeidsressurser med deg.\n"
"Vennligst besøk føderasjonsinnstillingene dine og bruk følgende detaljer:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:102
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) ønsker å begynne å dele samarbeidsressurser med deg.\n"
"For å akseptere invitasjonen, besøk følgende URL:\n"
"{ShareLink}\n"
"\n"
"Alternativt kan du besøke dine føderasjonsinnstillinger og bruke følgende detaljer:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} har delt «{ShareFolder}» med deg."
#. ShareRemoved email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"{ShareSharer} has unshared '{ShareFolder}' with you.\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"{ShareSharer} har fjernet deling '{ShareFolder}' til deg.\n"
"\n"
"Selv om denne delingen er trukket tilbake, kan du fortsatt ha tilgang gjennom andre delinger og/eller medlemskap i rommet."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} delte '{ShareFolder}' med deg"
#. ShareRemoved email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "{ShareSharer} unshared '{ShareFolder}' with you"
msgstr "{ShareSharer} deler ikke '{ShareFolder}' med deg"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} har invitert deg til å bli med i «{SpaceName}»."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:74
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} har fjernet deg fra «{SpaceName}».\n"
"\n"
"Du kan fortsatt ha tilgang gjennom andre grupper eller direkte medlemskap."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} inviterte deg til å bli med i {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:70
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} fjernet deg fra {SpaceName}"
@@ -11,7 +11,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-08-17 23:18+0000\n"
"POT-Creation-Date: 2026-09-07 23:16+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jiri Grönroos <jiri.gronroos@iki.fi>, 2026\n"
"Language-Team: Finnish (https://app.transifex.com/opencloud-eu/teams/204053/fi/)\n"
-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
+31 -78
View File
@@ -1,99 +1,53 @@
# Thumbnails
The thumbnails service provides methods to generate thumbnails for various files and resolutions based on requests. It retrieves the sources at the location where the user files are stored and saves the thumbnails where system files are stored. Those locations have defaults but can be manually defined via environment variables.
The thumbnails service is a stateless image resizer. It exposes an imagor-compatible push endpoint that accepts an original image as a multipart upload and returns the resized thumbnail encoded in the requested format.
## File Locations Overview
> **Warning:** This service performs no authentication or authorization. By default it binds to `127.0.0.1:9186` so only local processes (the webdav service) can reach it. If you change `THUMBNAILS_HTTP_ADDR` to a non-loopback address, put it behind your reverse proxy's auth layer or an internal network boundary — anyone who can reach the endpoint can upload and retrieve arbitrary images through it.
The relevant environment variables defining file locations are:
## Push Endpoint
- (1) `OC_BASE_DATA_PATH`
- (2) `STORAGE_USERS_DECOMPOSED_ROOT`
- (3) `THUMBNAILS_FILESYSTEMSTORAGE_ROOT`
The webdav service (which owns the complete thumbnail workflow) POSTs the source file to this endpoint and receives the processed image back.
(1) ... Having a default set by the OpenCloud code, but if defined, used as base path for other services.
(2) ... Source files, defaults to (1) plus path component, but can be freely defined if required.
(3) ... Target files, defaults to (1) plus path component, but can be freely defined if required.
| Route | Description |
|---|---|
| `POST /unsafe/{width}x{height}` (optionally `/filters:format({format})`) | Fill the exact width x height (center crop, may upscale) — the default |
| `POST /unsafe/fit-in/{width}x{height}` (optionally `/filters:format({format})`) | Scale to fit within width x height, preserving aspect ratio and never upscaling (letterboxed) |
| `POST /unsafe/stretch/{width}x{height}` (optionally `/filters:format({format})`) | Resize to the exact width x height without preserving aspect ratio (distorts) |
For details and defaults for these environment variables see the OpenCloud admin documentation.
The filters segment is captured whole and parsed; the `format` and `no_upscale` filters are meaningful to this executor, other filters are ignored. The `format` filter is optional: when absent the input's own format is preserved (detected from the image header), matching imagor. Inputs we cannot re-encode (e.g. webp, tiff, bmp) fall back to JPEG, mirroring imagor's default for unsavable sources.
## Thumbnail Location
Like real imagor, the default fill resize **upscales** small sources to fill the box exactly (a 100x100 source requested at `320x320` returns a 320x320 image). Adding the `no_upscale()` filter caps the result at the source size instead (the same request returns 100x100). The `fit-in` route never upscales regardless of filters.
It may be beneficial to define the location of the thumbnails to be other than the default (with system files). This is due the fact that storing thumbnails can consume a lot of space over time which not necessarily needs to reside on the same partition or mount or expensive drives.
The webdav service selects the route based on the requested processor. The full mapping is:
## Thumbnail Source File Types
| Request | Operation |
|---|---|
| `processor=resize` | stretch (distort to the exact box) |
| `processor=fill` or `processor=thumbnail` | fill (center-crop to the exact box, may upscale) |
| `processor=fit` / `processor=fit-in` | fit-in (preserve aspect, fit in box, never upscale) |
| no processor, gif source | stretch (resize for gifs) |
| no processor, other sources | fill (default = thumbnail = fill) |
Thumbnails can be generated from the following source file types:
By default (no processor, non-gif) the fill form is used, which center-crops to the exact box and upscales small sources — this matches the legacy `thumbnail` processor behavior (e.g. a 200x100 image requested at 100x100 returns a square 100x100 image). Requesting `processor=fit` switches to the `fit-in` form, which preserves aspect ratio and never upscales, letterboxing a non-square source into the box (the same image returns 100x50).
- png
- jpg
- gif
- tiff
- bmp
- txt
### Legacy `a` parameter
The thumbnail service retrieves source files using the information provided by the backend. The Linux backend identifies source files usually based on the extension.
The webdav preview endpoint also accepts a legacy `a` flag: `a=1` (or absent) means "preserve aspect" (fit-in), `a=0` means "fill". An explicit `processor` always wins over `a`. When an explicit processor overrides a contradictory `a`, the thumbnail response includes the header `X-OpenCloud-Thumbnail-Aspect-Ignored` so developers can tell their client to send a consistent request.
If a file type was not properly assigned or the type identification failed, thumbnail generation will fail and an error will be logged.
The request body is a `multipart/form-data` upload with a single file field named `image`. Supported output formats are `jpg`, `png`, and `gif`.
## Thumbnail Target File Types
## Configuration
Thumbnails can either be generated as `png`, `jpg` or `gif` files. These types are hardcoded and no other types can be requested. A requestor, like another service or a client, can request one of the available types to be generated. If more than one type is required, each type must be requested individually.
| Environment variable | Description |
|---|---|
| `THUMBNAILS_HTTP_ADDR` | Bind address of the HTTP service (default `127.0.0.1:9186`) |
| `THUMBNAILS_LOG_LEVEL` | Log level (`panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace`) |
| `THUMBNAILS_MAX_CONCURRENT_REQUESTS` | Maximum number of thumbnail requests decoded and resized in parallel. Default is 0 (unlimited). Requests arriving while the limit is reached get HTTP 429 with a `Retry-After` header |
## Thumbnail Query String Parameters
## Using libvips for Image Processing
Clients can request thumbnail previews for files by adding `?preview=1` to the file URL. Requests for files with no thumbnail available respond with HTTP status `404`.
The following query parameters are supported:
| Parameter | Required | Default Value | Description |
|-----------|----------|------------------------------------------------------|---------------------------------------------------------------------------------|
| preview | YES | 1 | generates preview |
| x | YES | first x-value configured in `THUMBNAILS_RESOLUTIONS` | horizontal target size |
| y | YES | first y-value configured in `THUMBNAILS_RESOLUTIONS` | vertical target size |
| scalingup | NO | 0 | prevents up-scaling of small images |
| a | NO | 1 | aspect ratio |
| c | NO | Caching string | Clients should send the etag, so they get a fresh thumbnail after a file change |
| processor | NO | `resize` for gifs and `thumbnail` for all others | preferred thumbnail processor |
## Thumbnail Resolution
Various resolutions can be defined via `THUMBNAILS_RESOLUTIONS`. A requestor can request any arbitrary resolution and the thumbnail service will use the one closest to the requested resolution. If more than one resolution is required, each resolution must be requested individually.
Example:
Requested: 18x12\
Available: 30x20, 15x10, 9x6\
Returned: 15x10
## Thumbnail Processors
Normally, an image might get cropped when creating a preview, depending on the aspect ratio of the original image. This can have negative
impacts on previews as only a part of the image will be shown. When using an _optional_ processor in the request, cropping can be avoided by defining on how the preview image generation will be done. The following processors are available:
* `resize` resizes the image to the specified width and height and returns the transformed image. If one of width or height is 0, the image aspect ratio is preserved.
* `fit` scales down the image to fit the specified maximum width and height and returns the transformed image.
* `fill`: creates an image with the specified dimensions and fills it with the scaled source image. To achieve the correct aspect ratio without stretching, the source image will be cropped.
* `thumbnail` scales the image up or down, crops it to the specified width and height and returns the transformed image.
To apply one of those, a query parameter has to be added to the request, like `?processor=fit`. If no query parameter or processor is added, the default behaviour applies which is `resize` for gifs and `thumbnail` for all others.
## Deleting Thumbnails
As of now, there is no automated thumbnail deletion. This is especially true when a source file gets deleted or moved. This situation will be solved at a later stage. For the time being, if you run short on physical thumbnails space, you have to manually delete the thumbnail store to free space. Thumbnails will then be recreated on request.
## Memory Considerations
Since source files need to be loaded into memory when generating thumbnails, large source files could potentially crash this service if there is insufficient memory available. For bigger instances when using container orchestration deployment methods, this service can be dedicated to its own server(s) with more memory.
To have more control over memory (and CPU) consumption the maximum number of concurrent requests can be limited by setting the environment variable `THUMBNAILS_MAX_CONCURRENT_REQUESTS`. The default value is 0 which does not apply any restrictions to the number of concurrent requests. As soon as the number of concurrent requests is reached any further request will be responded with `429/Too Many Requests` and the client can retry at a later point in time.
## Thumbnails and SecureView
If a resource is shared using SecureView, the share reciever will get a 403 (forbidden) response when requesting a thumbnail. The requesting client needs to decide what to show and usually a placeholder thumbnail is used.
## Using libvips for Thumbnail Generation
To improve performance and to support a wider range of images formats, the thumbnails service is able to utilize the [libvips library](https://www.libvips.org/) for thumbnail generation. Support for libvips needs to be
enabled at buildtime and has a couple of implications:
To improve performance and to support a wider range of image formats, the thumbnails service is able to utilize the [libvips library](https://www.libvips.org/) for image processing. Support for libvips needs to be enabled at buildtime and has a couple of implications:
* With libvips support enabled, it is not possible to create a statically linked OpenCloud binary.
* Therefore, the libvips shared libraries need to be available at runtime in the same release that was used to build the OpenCloud binary.
@@ -120,4 +74,3 @@ go build -tags enable_vips -o opencloud -o bin/opencloud ./cmd/opencloud
When building a docker image using the Dockerfile in the top-level directory of OpenCloud, libvips support is enabled and the libvips shared libraries are included
in the resulting docker image.
-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
+14 -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,25 @@ 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"`
// MaxConcurrentRequests limits the number of thumbnail requests that are
// decoded and resized in parallel. 0 (the default) means unlimited, matching
// main's THUMBNAILS_MAX_CONCURRENT_REQUESTS. Requests arriving while the limit
// is reached get HTTP 429 Too Many Requests.
MaxConcurrentRequests int `yaml:"max_concurrent_requests" env:"THUMBNAILS_MAX_CONCURRENT_REQUESTS" desc:"Maximum number of concurrent thumbnail generation requests. Default is 0 which is unlimited." 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,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
}
@@ -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
}
}
@@ -0,0 +1,37 @@
package svc
import (
"image"
"image/color"
"image/draw"
)
// palettedAtOrigin re-pallettes img using Floyd-Steinberg dithering onto a new
// palette whose bounds are anchored at the origin. The source's own (possibly
// offset) bounds are honored so the pixel content lands in the right place, but
// the result always starts at (0,0). This is required for gif frames: after a
// resize the frame rectangle inherits the source frame's offset, and a frame
// whose rect extends past the gif logical screen makes gif.EncodeAll fail with
// "image block is out of bounds".
func palettedAtOrigin(img image.Image, p color.Palette) *image.Paletted {
b := img.Bounds()
pm := image.NewPaletted(image.Rect(0, 0, b.Dx(), b.Dy()), p)
draw.FloydSteinberg.Draw(pm, pm.Bounds(), shiftedImage(img, b.Min), image.Point{})
return pm
}
// shiftedImage wraps img so its bounds are shifted by off, letting a draw target
// anchored at the origin receive an image whose content is defined relative to a
// different origin.
type shiftedImageWrapper struct {
img image.Image
off image.Point
}
func (s shiftedImageWrapper) Bounds() image.Rectangle { return s.img.Bounds().Add(s.off) }
func (s shiftedImageWrapper) At(x, y int) color.Color { return s.img.At(x-s.off.X, y-s.off.Y) }
func (s shiftedImageWrapper) ColorModel() color.Model { return s.img.ColorModel() }
func shiftedImage(img image.Image, off image.Point) image.Image {
return shiftedImageWrapper{img: img, off: off}
}
@@ -0,0 +1,339 @@
package svc
import (
"bytes"
"fmt"
"image"
"image/gif"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
)
// gifMagic is the leading signature of a GIF file (GIF87a or GIF89a). It guards
// against gif.DecodeAll panicking on non-GIF input.
var gifMagic = []byte("GIF8")
// Operations understood by the push endpoint. They map to imagor's resize/crop
// modes so a real imagor deployment can replace this service.
const (
OpFill = "fill"
OpFitIn = "fit-in"
OpStretch = "stretch"
)
// errInvalid mirrors imagor's ErrInvalid: a syntactically invalid request.
var errInvalid = fmt.Errorf("invalid")
// pushHandler handles POST requests for the imagor-like push-based thumbnail endpoint.
// Path patterns (operation and format are both optional; absent operation is the default
// center-crop fill, absent format keeps the input's own format):
// - POST /unsafe(/fit-in|/stretch)/{W}x{H}(/filters:{filters})
//
// The filters segment is captured whole and parsed by outputFormatFromFilters; only the
// format filter is meaningful to this executor, other filters are ignored. When no format
// filter is present the input's own format is preserved (detected via imaging), matching
// imagor. It returns only imagor's defined status codes: 400 for invalid requests or a
// file exceeding the max size, 422 for an image exceeding the max resolution.
func (s Thumbnails) pushHandler(w http.ResponseWriter, r *http.Request) {
ok, retryAfter := s.limiter.acquire()
if !ok {
writeTooManyRequests(w, retryAfter)
return
}
defer s.limiter.release()
width, err := parseDim(r, "width")
if err != nil {
writeInvalid(w, "invalid width")
return
}
height, err := parseDim(r, "height")
if err != nil {
writeInvalid(w, "invalid height")
return
}
operation := chi.URLParam(r, "operation")
if operation == "" {
operation = OpFill
}
switch operation {
case OpFill, OpFitIn, OpStretch:
default:
writeInvalid(w, fmt.Sprintf("unsupported operation: %s", operation))
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeInvalid(w, "failed to parse multipart form")
return
}
file, _, err := r.FormFile("image")
if err != nil {
writeInvalid(w, "missing image field in form data")
return
}
defer file.Close()
imgData, err := io.ReadAll(file)
if err != nil {
writeInvalid(w, "failed to read image")
return
}
filtersSegment := chi.URLParam(r, "filters")
ext, hasFormat, err := outputFormatFromFilters(filtersSegment)
if err != nil {
writeInvalid(w, err.Error())
return
}
noUpscale := filterPresent(filtersSegment, "no_upscale")
if !hasFormat {
// No format filter: preserve the input's own format (imagor default),
// detected via imaging. Only runs on this path, so the common webdav
// request (which always sends a format) pays no decode cost.
ext = inputFormatViaImaging(imgData)
}
// imagor ErrMaxResolutionExceeded: the declared input exceeds the configured
// maximum width or height. Checked against the image header BEFORE any
// decoding so a dimension bomb (a tiny file declaring huge dimensions) is
// rejected without allocating the pixel buffer. webdav translates the
// resulting 422 into its legacy 403 response.
if s.maxWidth > 0 || s.maxHeight > 0 {
cfg, _, err := image.DecodeConfig(bytes.NewReader(imgData))
if err == nil {
if (s.maxWidth > 0 && cfg.Width > s.maxWidth) || (s.maxHeight > 0 && cfg.Height > s.maxHeight) {
writeMaxResolution(w)
return
}
}
}
// The box and operation are chosen by webdav (the sizing brain); this service
// is a dumb, imagor-like executor that just fits within the given box.
processed, err := processImage(bytes.NewReader(imgData), width, height, operation, noUpscale)
if err != nil {
writeInvalid(w, "failed to process image")
return
}
var (
buf bytes.Buffer
contentType string
encErr error
)
switch ext {
case "jpg":
contentType = "image/jpeg"
encErr = encodeJPEG(&buf, processed)
case "png":
contentType = "image/png"
encErr = encodePNG(&buf, processed)
default:
contentType = "image/gif"
g, ok := processed.(*gif.GIF)
if !ok {
writeInvalid(w, "processed image is not a gif")
return
}
encErr = gif.EncodeAll(&buf, g)
}
if encErr != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "failed to encode image")
return
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
buf.WriteTo(w)
}
func parseDim(r *http.Request, name string) (int, error) {
v := chi.URLParam(r, name)
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid %s: %s", name, v)
}
return n, nil
}
// writeInvalid responds with imagor's ErrInvalid status (400 Bad Request).
func writeInvalid(w http.ResponseWriter, msg string) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, msg)
}
// writeMaxResolution responds with imagor's ErrMaxResolutionExceeded status
// (422 Unprocessable Entity).
func writeMaxResolution(w http.ResponseWriter) {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintln(w, "maximum resolution exceeded")
}
// concurrencyLimiter bounds the number of requests processed in parallel. It is
// nil when no limit is configured (THUMBNAILS_MAX_CONCURRENT_REQUESTS = 0).
type concurrencyLimiter struct {
tokens chan struct{}
}
func newConcurrencyLimiter(limit int) *concurrencyLimiter {
if limit <= 0 {
return nil
}
tokens := make(chan struct{}, limit)
for i := 0; i < limit; i++ {
tokens <- struct{}{}
}
return &concurrencyLimiter{tokens: tokens}
}
// acquire tries to take a slot without blocking. It reports whether the request
// may proceed and, when not, how many seconds to wait before retrying.
func (l *concurrencyLimiter) acquire() (bool, int) {
if l == nil {
return true, 0
}
select {
case <-l.tokens:
return true, 0
default:
return false, 1
}
}
func (l *concurrencyLimiter) release() {
if l == nil {
return
}
l.tokens <- struct{}{}
}
// writeTooManyRequests responds with 429 and a short Retry-After header.
func writeTooManyRequests(w http.ResponseWriter, retryAfter int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintln(w, "too many concurrent requests")
}
// mapFormatToExt maps imagor format names to the internal extension used by encoders.
func mapFormatToExt(format string) string {
switch strings.ToLower(format) {
case "jpeg":
return "jpg"
default:
return strings.ToLower(format)
}
}
// outputFormatFromFilters parses an imagor filters segment (e.g. "format(jpeg)") and
// reports the requested output extension. Only the format filter is meaningful to this
// executor; other filters are ignored because the operation already governs
// sizing/upscaling. It returns hasFormat=false when no format filter is present, in
// which case the caller falls back to inputFormatViaImaging. An explicit but
// unsupported format is an error.
func outputFormatFromFilters(segment string) (ext string, hasFormat bool, err error) {
for _, token := range strings.Split(segment, ":") {
name, args, ok := splitFilter(token)
if !ok || name != "format" {
continue
}
mapped := mapFormatToExt(args)
switch mapped {
case "jpg", "png", "gif":
return mapped, true, nil
default:
return "", false, fmt.Errorf("unsupported output format: %s", args)
}
}
return "", false, nil
}
// inputFormatViaImaging detects the uploaded image's format with the stdlib
// decoder registry (image.DecodeConfig reads only the header) and maps it to our
// output extension. jpeg/png/gif are kept; anything else (webp, tiff, bmp, or an
// undecodable body) falls back to jpeg, mirroring imagor's unsavable-source
// default.
func inputFormatViaImaging(imgData []byte) string {
_, format, err := image.DecodeConfig(bytes.NewReader(imgData))
if err != nil {
return "jpg"
}
switch format {
case "png":
return "png"
case "gif":
return "gif"
default: // jpeg, webp, tiff, bmp, ...
return "jpg"
}
}
// splitFilter splits a "name(args)" token into name and args. It returns ok=false for
// tokens that are not in parenthesized form.
func splitFilter(token string) (name, args string, ok bool) {
i := strings.IndexByte(token, '(')
if i < 0 || !strings.HasSuffix(token, ")") {
return "", "", false
}
return token[:i], token[i+1 : len(token)-1], true
}
// filterPresent reports whether a named filter occurs in an imagor filters
// segment (e.g. "no_upscale():format(jpeg)").
func filterPresent(segment, name string) bool {
for _, token := range strings.Split(segment, ":") {
if n, _, ok := splitFilter(token); ok && n == name {
return true
}
}
return false
}
// isGifReader reports whether the reader holds GIF data, based on the GIF magic
// bytes. It peeks at the first 4 bytes and rewinds the stream so it can be read
// again. This guard is required because gif.DecodeAll panics (nil dereference)
// when handed a non-GIF image instead of returning an error.
func isGifReader(r io.Reader) bool {
readSeeker, ok := r.(io.ReadSeeker)
if !ok {
return false
}
saved, err := readSeeker.Seek(0, io.SeekCurrent)
if err != nil {
return false
}
buf := make([]byte, 4)
if _, err := io.ReadFull(readSeeker, buf); err != nil {
return false
}
_, _ = readSeeker.Seek(saved, io.SeekStart)
return bytes.HasPrefix(buf, gifMagic)
}
// Backend-specific function set in init() by push_imaging.go or push_vips.go.
// For gif input it returns a *gif.GIF with every frame resized (preserving
// animation); for other inputs it returns a single image.Image. The operation
// selects the resize/crop mode (fill, fit-in, stretch). noUpscale mirrors
// imagor's no_upscale() filter: when set, fill and fit-in must not enlarge the
// source (stretch is exact and ignores it).
var processImage func(io.Reader, int, int, string, bool) (any, error)
// encodeJPEG and encodePNG write the processed image to w in the requested
// format. Set by init() in push_imaging.go / push_vips.go. The vips backend
// encodes via libvips (ExportJpeg/ExportPng) so its output matches the legacy
// pipeline (progressive JPEG, quality 80); the imaging backend uses the stdlib
// encoders. GIF encoding stays in the common handler because both backends
// return a *gif.GIF for animated input.
var (
encodeJPEG func(w io.Writer, processed any) error
encodePNG func(w io.Writer, processed any) error
)
@@ -0,0 +1,155 @@
//go:build !enable_vips
package svc
import (
"fmt"
"image"
"image/draw"
"image/gif"
"image/jpeg"
"image/png"
"io"
"github.com/kovidgoyal/imaging"
)
func init() {
processImage = processImageImaging
encodeJPEG = encodeJPEGImaging
encodePNG = encodePNGImaging
}
// processImageImaging resizes the input using the imaging backend. The operation
// selects the resize/crop mode: fill (center-crop to the box, upscaling by default
// like real imagor's default resize), fit-in (fit within the box without cropping,
// upscaling small sources by default like real imagor), or stretch (resize to the
// exact box). noUpscale caps the
// default fill at the source size, mirroring imagor's no_upscale() filter.
func processImageImaging(r io.Reader, width, height int, operation string, noUpscale bool) (any, error) {
if isGifReader(r) {
g, err := gif.DecodeAll(r)
if err == nil && len(g.Image) > 0 {
return resizeGIF(g, width, height, operation, noUpscale), nil
}
}
img, err := imaging.Decode(r, imaging.AutoOrientation(true))
if err != nil {
return nil, err
}
srcBounds := img.Bounds()
switch operation {
case OpStretch:
return imaging.Resize(img, width, height, imaging.Lanczos), nil
case OpFitIn:
// Like real imagor, fit-in fits within the box preserving aspect and
// upscales small sources by default; no_upscale() caps it at the source
// size. imaging.Fit never enlarges, so a small source is scaled up to the
// fitted dimensions explicitly.
if noUpscale && srcBounds.Dx() <= width && srcBounds.Dy() <= height {
return img, nil
}
if srcBounds.Dx() <= width && srcBounds.Dy() <= height {
srcAR := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
boxAR := float64(width) / float64(height)
if srcAR > boxAR {
return imaging.Resize(img, width, int(float64(width)/srcAR), imaging.Lanczos), nil
}
return imaging.Resize(img, int(float64(height)*srcAR), height, imaging.Lanczos), nil
}
return imaging.Fit(img, width, height, imaging.Lanczos), nil
default: // OpFill
if noUpscale && srcBounds.Dx() <= width && srcBounds.Dy() <= height {
// imagor no_upscale(): never enlarge the source.
return img, nil
}
return imaging.Thumbnail(img, width, height, imaging.Lanczos), nil
}
}
// resizeGIF resizes every frame of an animated gif while preserving the
// animation. It composites each frame onto a running canvas honoring the gif
// disposal method, resizes with the requested processor, and re-pallettes the
// result using Floyd-Steinberg dithering. Code adapted from
// https://github.com/willnorris/gifresize. noUpscale caps the default fill at
// the source size, mirroring imagor's no_upscale() filter.
func resizeGIF(m *gif.GIF, width, height int, operation string, noUpscale bool) *gif.GIF {
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 {
frameBounds := frame.Bounds()
prev := tmp
draw.Draw(tmp, frameBounds, frame, frameBounds.Min, draw.Over)
var processed image.Image
switch operation {
case OpStretch:
processed = imaging.Resize(tmp, width, height, imaging.Lanczos)
case OpFitIn:
if noUpscale && srcX <= width && srcY <= height {
processed = tmp
} else if srcX <= width && srcY <= height {
srcAR := float64(srcX) / float64(srcY)
boxAR := float64(width) / float64(height)
if srcAR > boxAR {
processed = imaging.Resize(tmp, width, int(float64(width)/srcAR), imaging.Lanczos)
} else {
processed = imaging.Resize(tmp, int(float64(height)*srcAR), height, imaging.Lanczos)
}
} else {
processed = imaging.Fit(tmp, width, height, imaging.Lanczos)
}
default: // OpFill
if noUpscale && srcX <= width && srcY <= height {
processed = tmp
} else {
processed = imaging.Fill(tmp, width, height, imaging.Center, imaging.Lanczos)
}
}
m.Image[i] = palettedAtOrigin(processed, frame.Palette)
switch m.Disposal[i] {
case gif.DisposalBackground:
tmp = image.NewRGBA(b)
case gif.DisposalPrevious:
tmp = prev
}
}
// stretch resizes every frame to exactly width x height, so the logical
// screen must always match the box even when that is larger than the source
// (webdav sends no_upscale(), which would otherwise skip the update and leave
// frames extending past the screen, making gif.EncodeAll fail with "image
// block is out of bounds"). fill/fit-in keep the source size when noUpscale
// caps them at the source, so they only grow the screen when actually resized.
if operation == OpStretch || !noUpscale || srcX > width || srcY > height {
m.Config.Width = width
m.Config.Height = height
}
return m
}
// encodeJPEGImaging encodes the processed image as JPEG using the stdlib encoder.
func encodeJPEGImaging(w io.Writer, processed any) error {
img, ok := processed.(image.Image)
if !ok {
return fmt.Errorf("cannot encode %T as jpeg", processed)
}
return jpeg.Encode(w, img, nil)
}
// encodePNGImaging encodes the processed image as PNG using the stdlib encoder.
func encodePNGImaging(w io.Writer, processed any) error {
img, ok := processed.(image.Image)
if !ok {
return fmt.Errorf("cannot encode %T as png", processed)
}
return png.Encode(w, img)
}
@@ -0,0 +1,996 @@
package svc
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"golang.org/x/image/bmp"
)
func createTestImage(width, height int) []byte {
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}
func createMultipartBody(imgBytes []byte) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("image", "test.png")
if err != nil {
panic(err)
}
if _, err := part.Write(imgBytes); err != nil {
panic(err)
}
if err := writer.Close(); err != nil {
panic(err)
}
return body, writer.FormDataContentType()
}
func newTestMux() *chi.Mux {
return newTestMuxWithLimits(0, 0)
}
// newTestMuxWithLimits builds the route table with explicit max width/height so
// tests can exercise the ErrMaxResolutionExceeded (422) path. A limit of 0
// disables the corresponding bound.
func newTestMuxWithLimits(maxWidth, maxHeight int) *chi.Mux {
mux := chi.NewRouter()
h := Thumbnails{maxWidth: maxWidth, maxHeight: maxHeight}.pushHandler
mux.Post("/unsafe/{operation}/{width}x{height}/filters:{filters}/", h)
mux.Post("/unsafe/{operation}/{width}x{height}/filters:{filters}", h)
mux.Post("/unsafe/{width}x{height}/filters:{filters}/", h)
mux.Post("/unsafe/{width}x{height}/filters:{filters}", h)
return mux
}
// createTestGIF returns an animated gif with the given number of frames, each
// frame a solid color so that animation preservation can be verified.
func createTestGIF(width, height, frames int) []byte {
pal := color.Palette{color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255}}
g := &gif.GIF{}
for i := 0; i < frames; i++ {
frame := image.NewPaletted(image.Rect(0, 0, width, height), pal)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
frame.SetColorIndex(x, y, uint8(i%len(pal)))
}
}
g.Image = append(g.Image, frame)
g.Delay = append(g.Delay, 10)
}
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, g); err != nil {
panic(err)
}
return buf.Bytes()
}
func TestPushEndpoint_FitIn_JPEG(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:format(jpeg)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("expected Content-Type image/jpeg, got %s", ct)
}
if rec.Body.Len() == 0 {
t.Error("expected non-empty response body")
}
}
func TestPushEndpoint_FitIn_PNG(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("expected Content-Type image/png, got %s", ct)
}
if rec.Body.Len() == 0 {
t.Error("expected non-empty response body")
}
}
func TestPushEndpoint_FitIn_GIF(t *testing.T) {
mux := newTestMux()
imgBytes := createTestGIF(200, 200, 3)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:format(gif)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/gif" {
t.Errorf("expected Content-Type image/gif, got %s", ct)
}
if rec.Body.Len() == 0 {
t.Error("expected non-empty response body")
}
out, err := gif.DecodeAll(rec.Body)
if err != nil {
t.Fatalf("failed to decode response gif: %v", err)
}
if len(out.Image) != 3 {
t.Errorf("expected 3 frames preserved, got %d", len(out.Image))
}
}
func TestPushEndpoint_FitIn_GIFPreservesAnimation(t *testing.T) {
mux := newTestMux()
imgBytes := createTestGIF(200, 100, 4)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/50x50/filters:format(gif)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
out, err := gif.DecodeAll(rec.Body)
if err != nil {
t.Fatalf("failed to decode response gif: %v", err)
}
if len(out.Image) != 4 {
t.Errorf("expected 4 frames preserved, got %d", len(out.Image))
}
// fit-in of a 200x100 source into a 50x50 box downscales (no upscaling needed),
// so every frame must stay within the box.
for i, frame := range out.Image {
b := frame.Bounds()
if b.Dx() > 50 || b.Dy() > 50 {
t.Errorf("frame %d exceeds requested dimensions: %dx%d", i, b.Dx(), b.Dy())
}
}
}
func TestPushEndpoint_Fill_GIF(t *testing.T) {
mux := newTestMux()
imgBytes := createTestGIF(200, 100, 3)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/64x64/filters:format(gif)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
out, err := gif.DecodeAll(rec.Body)
if err != nil {
t.Fatalf("failed to decode response gif: %v", err)
}
if len(out.Image) != 3 {
t.Errorf("expected 3 frames preserved, got %d", len(out.Image))
}
for i, frame := range out.Image {
b := frame.Bounds()
if b.Dx() != 64 || b.Dy() != 64 {
t.Errorf("fill should produce exact dimensions, frame %d is %dx%d", i, b.Dx(), b.Dy())
}
}
}
// TestPushEndpoint_Stretch_GIFUpscaleNoUpscaleFilter pins the fix for stretching a
// gif to a box larger than the source while the no_upscale() filter is set (which
// is exactly what webdav sends). The resized frames are larger than the original
// logical screen, so without updating m.Config the frame rects extend past the
// screen and gif.EncodeAll fails with "image block is out of bounds" (surfaced as
// HTTP 500). The endpoint must return 200 with every frame exactly the requested
// size.
func TestPushEndpoint_Stretch_GIFUpscaleNoUpscaleFilter(t *testing.T) {
mux := newTestMux()
imgBytes := createTestGIF(100, 80, 3)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/stretch/200x160/filters:no_upscale():format(gif)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
out, err := gif.DecodeAll(rec.Body)
if err != nil {
t.Fatalf("failed to decode response gif: %v", err)
}
if len(out.Image) != 3 {
t.Fatalf("expected 3 frames preserved, got %d", len(out.Image))
}
for i, frame := range out.Image {
b := frame.Bounds()
if b.Dx() != 200 || b.Dy() != 160 {
t.Errorf("stretch should produce exactly 200x160, frame %d is %dx%d", i, b.Dx(), b.Dy())
}
}
}
func TestPushEndpoint_FitIn_AspectRatio(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(400, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() > 100 || bounds.Dy() > 100 {
t.Errorf("fit-in should not exceed requested dimensions, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
// TestPushEndpoint_FitIn_UpscalesByDefault documents that, like real imagor,
// fit-in upscales a small source to fit the box when no_upscale() is absent. A
// 50x50 source requested at 200x200 must come back as exactly 200x200.
func TestPushEndpoint_FitIn_UpscalesByDefault(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(50, 50)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/200x200/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != 200 || bounds.Dy() != 200 {
t.Errorf("fit-in should upscale 50x50 to fit a 200x200 box as 200x200, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
// TestPushEndpoint_FitIn_NoUpscaleFilter pins imagor's no_upscale() filter on
// fit-in: with it, a small source must not be enlarged. A 50x50 source requested
// at 200x200 comes back as 50x50 instead of 200x200.
func TestPushEndpoint_FitIn_NoUpscaleFilter(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(50, 50)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/200x200/filters:no_upscale():format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() > 50 || bounds.Dy() > 50 {
t.Errorf("fit-in with no_upscale should not enlarge the image, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
// TestPushEndpoint_Default_SquareFill pins the default processor's outcome:
// center-crop to fill the requested box exactly (the legacy "thumbnail"
// processor behavior). A 200x100 source requested at 100x100 must come back as
// exactly 100x100 — center-cropped, NOT letterboxed to 100x50. This is the route
// the webdav service uses by default (no fit-in segment).
func TestPushEndpoint_Default_SquareFill(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 100)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/100x100/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != 100 || bounds.Dy() != 100 {
t.Errorf("default should center-crop 200x100 into a 100x100 box as 100x100, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
// TestPushEndpoint_Default_Upscales documents that the default (fill) processor
// upscales small sources to fill the box exactly, matching the legacy
// "thumbnail" processor. A 50x50 source requested at 200x200 must come back as
// exactly 200x200.
func TestPushEndpoint_Default_Upscales(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(50, 50)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/200x200/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != 200 || bounds.Dy() != 200 {
t.Errorf("default should upscale 50x50 to fill a 200x200 box as 200x200, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
// TestPushEndpoint_Default_NoUpscaleFilter pins imagor's no_upscale() filter:
// with it, the default resize must not enlarge a small source. A 50x50 source
// requested at 200x200 comes back as 50x50 instead of 200x200.
func TestPushEndpoint_Default_NoUpscaleFilter(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(50, 50)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/200x200/filters:no_upscale():format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != 50 || bounds.Dy() != 50 {
t.Errorf("no_upscale should keep the source at 50x50, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
// TestPushEndpoint_FitIn_LetterboxOutcome pins the opt-in "fit" processor's
// outcome: aspect-preserving fit within the box. A 200x100 source requested at
// 100x100 must come back as exactly 100x50 — centered/letterboxed in the box,
// NOT center-cropped to 100x100.
func TestPushEndpoint_FitIn_LetterboxOutcome(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 100)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != 100 || bounds.Dy() != 50 {
t.Errorf("fit-in should letterbox 200x100 into a 100x100 box as 100x50, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
func TestPushEndpoint_Fill_JPEG(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/100x100/filters:format(jpeg)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("expected Content-Type image/jpeg, got %s", ct)
}
if rec.Body.Len() == 0 {
t.Error("expected non-empty response body")
}
}
func TestPushEndpoint_Fill_ExactDimensions(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(400, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/100x100/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != 100 || bounds.Dy() != 100 {
t.Errorf("fill should produce exact dimensions, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
func TestPushEndpoint_BadDimensions(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(100, 100)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/abcxdef/filters:format(jpeg)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rec.Code)
}
}
func TestPushEndpoint_ZeroDimensions(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(100, 100)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/0x0/filters:format(jpeg)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for zero dimensions, got %d", rec.Code)
}
}
func TestPushEndpoint_UnsupportedFormat(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(100, 100)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/50x50/filters:format(webp)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for unsupported format, got %d", rec.Code)
}
}
func TestPushEndpoint_InvalidImageData(t *testing.T) {
mux := newTestMux()
body, contentType := createMultipartBody([]byte("not a valid image"))
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/50x50/filters:format(jpeg)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid image data, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestPushEndpoint_MissingImageField(t *testing.T) {
mux := newTestMux()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("other_field", "some value")
if err := writer.Close(); err != nil {
panic(err)
}
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/50x50/filters:format(jpeg)/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for missing image field, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestPushEndpoint_NotMultipart(t *testing.T) {
mux := newTestMux()
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/50x50/filters:format(jpeg)/", bytes.NewReader([]byte("plain text")))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for non-multipart request, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestPushEndpoint_NoTrailingSlash(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:format(png)", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200 without trailing slash, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("expected Content-Type image/png, got %s", ct)
}
}
// TestPushEndpoint_Stretch pins the stretch operation: resize to the exact box
// without preserving aspect ratio. A 200x100 source requested at 64x32 must come
// back as exactly 64x32 (distorted), not letterboxed or cropped.
func TestPushEndpoint_Stretch(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 100)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/stretch/64x32/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("failed to decode response image: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != 64 || bounds.Dy() != 32 {
t.Errorf("stretch should produce exactly 64x32, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
// TestPushEndpoint_MaxResolutionExceeded pins the ErrMaxResolutionExceeded path:
// a decoded input exceeding the configured max width/height returns 422.
func TestPushEndpoint_MaxResolutionExceeded(t *testing.T) {
mux := newTestMuxWithLimits(100, 100)
imgBytes := createTestImage(500, 500)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/64x64/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Errorf("expected status 422 for oversized input, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestPushEndpoint_MaxResolutionExceeded_OneAxis pins the per-axis check: a source
// that exceeds only the max width (but is well under the max height) must still be
// rejected with 422. This catches an inverted "both axes" condition.
func TestPushEndpoint_MaxResolutionExceeded_OneAxis(t *testing.T) {
mux := newTestMuxWithLimits(100, 100)
imgBytes := createTestImage(500, 10)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/64x64/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Errorf("expected status 422 for width-oversized input, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestPushEndpoint_WithinMaxResolution confirms an input within the configured
// limits still succeeds (the 422 check must not over-trigger).
func TestPushEndpoint_WithinMaxResolution(t *testing.T) {
mux := newTestMuxWithLimits(100, 100)
imgBytes := createTestImage(50, 50)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/32x32/filters:format(png)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200 for in-bounds input, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestConcurrencyLimiter pins the THUMBNAILS_MAX_CONCURRENT_REQUESTS behavior:
// up to N requests may proceed in parallel, the (N+1)th is rejected non-blocking
// with a 429. A limit of 0 disables the limiter entirely.
func TestConcurrencyLimiter(t *testing.T) {
t.Run("limit 2 rejects the third concurrent request", func(t *testing.T) {
l := newConcurrencyLimiter(2)
if ok, _ := l.acquire(); !ok {
t.Fatal("first acquire should succeed")
}
if ok, _ := l.acquire(); !ok {
t.Fatal("second acquire should succeed")
}
ok, retryAfter := l.acquire()
if ok {
t.Fatal("third acquire should fail while the limit is reached")
}
if retryAfter <= 0 {
t.Errorf("expected a positive Retry-After hint, got %d", retryAfter)
}
l.release()
if ok, _ := l.acquire(); !ok {
t.Fatal("acquire after release should succeed")
}
})
t.Run("zero limit is unlimited", func(t *testing.T) {
var l *concurrencyLimiter
for i := 0; i < 100; i++ {
if ok, _ := l.acquire(); !ok {
t.Fatalf("acquire %d should succeed without a limiter", i)
}
}
l.release()
})
t.Run("429 is returned when the handler is saturated", func(t *testing.T) {
s := Thumbnails{limiter: newConcurrencyLimiter(1)}
if ok, _ := s.limiter.acquire(); !ok {
t.Fatal("could not saturate the limiter")
}
defer s.limiter.release()
req := httptest.NewRequest(http.MethodPost, "/unsafe/64x64/filters:format(png)/", nil)
rec := httptest.NewRecorder()
s.pushHandler(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 from a saturated limiter, got %d", rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("expected a Retry-After header on 429")
}
})
}
func createTestJPEG(width, height int) []byte {
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
panic(err)
}
return buf.Bytes()
}
func createTestBMP(width, height int) []byte {
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := bmp.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}
// TestPushEndpoint_ToleratesExtraFilters proves the generic filters parser ignores
// filters other than format (a real imagor client may send no_upscale or others).
func TestPushEndpoint_ToleratesExtraFilters(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:no_upscale():format(jpeg)/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("expected Content-Type image/jpeg, got %s", ct)
}
}
// TestPushEndpoint_NoFormat_FallbackGIF pins the 1c pass-through fallback: a filters
// segment with no format filter (here just no_upscale) keeps a gif input as gif.
func TestPushEndpoint_NoFormat_FallbackGIF(t *testing.T) {
mux := newTestMux()
imgBytes := createTestGIF(200, 200, 2)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:no_upscale()/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/gif" {
t.Errorf("expected Content-Type image/gif, got %s", ct)
}
}
// TestPushEndpoint_NoFormat_FallbackPNG pins the 1c pass-through fallback: a filters
// segment with no format filter keeps a png input as png.
func TestPushEndpoint_NoFormat_FallbackPNG(t *testing.T) {
mux := newTestMux()
imgBytes := createTestImage(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:no_upscale()/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("expected Content-Type image/png, got %s", ct)
}
}
// TestPushEndpoint_NoFormat_FallbackJPEG pins the 1c pass-through fallback: a filters
// segment with no format filter keeps a jpeg input as jpeg.
func TestPushEndpoint_NoFormat_FallbackJPEG(t *testing.T) {
mux := newTestMux()
imgBytes := createTestJPEG(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:no_upscale()/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("expected Content-Type image/jpeg, got %s", ct)
}
}
// TestPushEndpoint_NoFormat_UnsupportedInputFallsBackToJPEG pins imagor's unsavable
// source behavior: an input we cannot re-encode (here a BMP) with no format filter is
// returned as JPEG, matching imagor's default for formats outside its savable set.
func TestPushEndpoint_NoFormat_UnsupportedInputFallsBackToJPEG(t *testing.T) {
mux := newTestMux()
imgBytes := createTestBMP(200, 200)
body, contentType := createMultipartBody(imgBytes)
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/100x100/filters:no_upscale()/", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("expected Content-Type image/jpeg, got %s", ct)
}
}
func TestInputFormatViaImaging(t *testing.T) {
tests := []struct {
name string
data []byte
want string
}{
{"jpeg", createTestJPEG(16, 16), "jpg"},
{"png", createTestImage(16, 16), "png"},
{"gif", createTestGIF(16, 16, 2), "gif"},
{"bmp falls back to jpeg", createTestBMP(16, 16), "jpg"},
{"undecodable falls back to jpeg", []byte("not an image at all"), "jpg"},
{"empty falls back to jpeg", []byte{}, "jpg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := inputFormatViaImaging(tt.data); got != tt.want {
t.Errorf("inputFormatViaImaging() = %q, want %q", got, tt.want)
}
})
}
}
func TestSplitFilter(t *testing.T) {
tests := []struct {
name string
token string
wantName string
wantArgs string
wantOK bool
}{
{"format", "format(jpeg)", "format", "jpeg", true},
{"no args name", "no_upscale()", "no_upscale", "", true},
{"no parens", "plain", "", "", false},
{"open only", "format(jpeg", "", "", false},
{"close only", "formatjpeg)", "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, args, ok := splitFilter(tt.token)
if ok != tt.wantOK || name != tt.wantName || args != tt.wantArgs {
t.Errorf("splitFilter(%q) = (%q,%q,%v), want (%q,%q,%v)",
tt.token, name, args, ok, tt.wantName, tt.wantArgs, tt.wantOK)
}
})
}
}
func TestOutputFormatFromFilters(t *testing.T) {
tests := []struct {
name string
segment string
wantExt string
wantHas bool
wantErr bool
}{
{"format jpeg", "format(jpeg)", "jpg", true, false},
{"extra filters ignored", "no_upscale():format(png)", "png", true, false},
{"unsupported format", "format(webp)", "", false, true},
{"no format filter", "no_upscale()", "", false, false},
{"empty segment", "", "", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, has, err := outputFormatFromFilters(tt.segment)
if (err != nil) != tt.wantErr {
t.Fatalf("outputFormatFromFilters() error = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.wantExt || has != tt.wantHas {
t.Errorf("outputFormatFromFilters() = (%q,%v), want (%q,%v)", got, has, tt.wantExt, tt.wantHas)
}
})
}
}
@@ -0,0 +1,179 @@
//go:build enable_vips
package svc
import (
"fmt"
"image"
"image/draw"
"image/gif"
"io"
"github.com/davidbyttow/govips/v2/vips"
"github.com/kovidgoyal/imaging"
)
func init() {
processImage = processImageVips
encodeJPEG = encodeJPEGVips
encodePNG = encodePNGVips
}
// processImageVips resizes the input using libvips. The operation selects the
// resize/crop mode: fill (center-crop to the box, upscaling by default like real
// imagor's default resize), fit-in (fit within the box without cropping, upscaling
// small sources by default like real imagor), or stretch (resize to the exact box).
// noUpscale caps the upscaling at the source size, mirroring imagor's no_upscale()
// filter.
func processImageVips(r io.Reader, width, height int, operation string, noUpscale bool) (any, error) {
if isGifReader(r) {
g, err := gif.DecodeAll(r)
if err == nil && len(g.Image) > 0 {
return resizeGIFVips(g, width, height, operation, noUpscale), nil
}
}
imgData, err := io.ReadAll(r)
if err != nil {
return nil, err
}
m, err := vips.NewImageFromBuffer(imgData)
if err != nil {
return nil, err
}
// Note: no defer m.Close() here. The ImageRef is returned to the caller and
// encoded by encodeJPEGVips/encodePNGVips, which own its lifetime and close it
// after exporting. Closing it here would free the underlying C image before the
// export runs (use-after-free).
switch operation {
case OpStretch:
// Resize to the exact box without preserving aspect ratio.
hScale := float64(width) / float64(m.Width())
vScale := float64(height) / float64(m.Height())
if err := m.ResizeWithVScale(hScale, vScale, vips.KernelLanczos3); err != nil {
return nil, err
}
case OpFitIn:
// Like real imagor, fit-in upscales small sources to fit the box by
// default (SizeBoth); no_upscale() caps it at the source size (SizeDown).
size := vips.SizeBoth
if noUpscale {
size = vips.SizeDown
}
if err := m.ThumbnailWithSize(width, height, vips.InterestingNone, size); err != nil {
return nil, err
}
default: // OpFill
// Center-crop to fill the box exactly. SizeBoth matches real imagor's
// default resize (which upscales small sources); no_upscale() caps it
// at the source size via SizeDown.
size := vips.SizeBoth
if noUpscale {
size = vips.SizeDown
}
if err := m.ThumbnailWithSize(width, height, vips.InterestingAttention, size); err != nil {
return nil, err
}
}
if err := m.RemoveMetadata(); err != nil {
return nil, err
}
// Keep the image in libvips form so the encode hooks can export it via
// ExportJpeg/ExportPng (matching the legacy pipeline's progressive JPEG and
// quality-80 output) instead of a lossy PNG round-trip + stdlib re-encode.
return m, nil
}
// resizeGIFVips resizes every frame of an animated gif while preserving the
// animation, compositing each frame onto a running canvas honoring the gif
// disposal method and re-palletting with Floyd-Steinberg dithering. noUpscale
// caps the default fill at the source size, mirroring imagor's no_upscale()
// filter.
func resizeGIFVips(m *gif.GIF, width, height int, operation string, noUpscale bool) *gif.GIF {
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 {
frameBounds := frame.Bounds()
prev := tmp
draw.Draw(tmp, frameBounds, frame, frameBounds.Min, draw.Over)
var processed image.Image
switch operation {
case OpStretch:
processed = imaging.Resize(tmp, width, height, imaging.Lanczos)
case OpFitIn:
if noUpscale && srcX <= width && srcY <= height {
processed = tmp
} else {
processed = imaging.Fit(tmp, width, height, imaging.Lanczos)
}
default: // OpFill
if noUpscale && srcX <= width && srcY <= height {
processed = tmp
} else {
processed = imaging.Fill(tmp, width, height, imaging.Center, imaging.Lanczos)
}
}
m.Image[i] = palettedAtOrigin(processed, frame.Palette)
switch m.Disposal[i] {
case gif.DisposalBackground:
tmp = image.NewRGBA(b)
case gif.DisposalPrevious:
tmp = prev
}
}
// stretch resizes every frame to exactly width x height, so the logical
// screen must always match the box even when that is larger than the source
// (webdav sends no_upscale(), which would otherwise skip the update and leave
// frames extending past the screen, making gif.EncodeAll fail with "image
// block is out of bounds"). fill/fit-in keep the source size when noUpscale
// caps them at the source, so they only grow the screen when actually resized.
if operation == OpStretch || !noUpscale || srcX > width || srcY > height {
m.Config.Width = width
m.Config.Height = height
}
return m
}
// encodeJPEGVips exports the processed libvips image as JPEG using libvips's
// default export parameters (progressive/interlaced, quality 80), matching the
// legacy pipeline's output byte-for-byte where the resize operation is unchanged.
func encodeJPEGVips(w io.Writer, processed any) error {
m, ok := processed.(*vips.ImageRef)
if !ok {
return fmt.Errorf("cannot encode %T as jpeg", processed)
}
defer m.Close()
buf, _, err := m.ExportJpeg(vips.NewJpegExportParams())
if err != nil {
return err
}
_, err = w.Write(buf)
return err
}
// encodePNGVips exports the processed libvips image as PNG via libvips.
func encodePNGVips(w io.Writer, processed any) error {
m, ok := processed.(*vips.ImageRef)
if !ok {
return fmt.Errorf("cannot encode %T as png", processed)
}
defer m.Close()
buf, _, err := m.ExportPng(vips.NewPngExportParams())
if err != nil {
return err
}
_, err = w.Write(buf)
return err
}
@@ -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,28 +31,25 @@ 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")
}
limits := options.Config.Thumbnail
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,
),
logger: options.Logger,
mux: m,
maxWidth: limits.MaxInputWidth,
maxHeight: limits.MaxInputHeight,
limiter: newConcurrencyLimiter(limits.MaxConcurrentRequests),
}
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Use(svc.TransferTokenValidator)
r.Get("/data", svc.GetThumbnail)
})
// Push-based thumbnail generation endpoint (imagor-compatible). The optional
// operation segment selects the resize/crop mode; its absence is the default
// center-crop fill. The filters segment is captured whole and parsed by the
// handler so any set/order of imagor filters is accepted.
handler := svc.pushHandler
m.Post("/unsafe/{operation}/{width}x{height}/filters:{filters}/", handler)
m.Post("/unsafe/{operation}/{width}x{height}/filters:{filters}", handler)
m.Post("/unsafe/{width}x{height}/filters:{filters}/", handler)
m.Post("/unsafe/{width}x{height}/filters:{filters}", handler)
_ = chi.Walk(m, func(method string, route string, _ http.Handler, middlewares ...func(http.Handler) http.Handler) error {
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
@@ -78,73 +61,14 @@ 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
logger log.Logger
mux *chi.Mux
maxWidth int
maxHeight int
limiter *concurrencyLimiter
}
// ServeHTTP implements the Service interface.
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

@@ -0,0 +1,129 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Roy-Magne Mo, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-09-07 23:16+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Roy-Magne Mo, 2026\n"
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: no\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Tilgang til rom {space} mistet"
#: pkg/service/templates.go:59
msgid "Access to {resource} expired"
msgstr "Tilgang til {resource} utløpt"
#: pkg/service/templates.go:64
msgid ""
"Attention! The instance will be shut down and deprovisioned on {date}. "
"Download all your data before that date as no access past that date is "
"possible."
msgstr ""
"Merk! Instansen vil bli slått av og avinstallert {date}. Last ned all data "
"før denne datoen, da ingen tilgang er mulig etter denne datoen."
#: pkg/service/templates.go:63
msgid "Instance will be shut down and deprovisioned"
msgstr "Instansen vil bli slått av og fjernet"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Medlemskapet utløpt"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Retningslinjer håndhevet"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Fjernet fra rom"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Ressurs delt"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Ressursen deles ikke"
#: pkg/service/templates.go:58
msgid "Share expired"
msgstr "Deling utløpt"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Plass slettet"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Rom deaktivert"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Delte rom"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
"Filen {resource} ble slettet fordi den bryter med denne skyens "
"restriksjoner. Dette kan skyldes en ulovlig filtype, potensielt skadelig "
"eller forbudt innhold, eller filstørrelsesgrenser."
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus funnet"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr "Virus funnet i {resource}. Opplasting ikke mulig. Virus: {virus}"
#: pkg/service/templates.go:53
msgid "You have been mentioned"
msgstr "Du har blitt nevnt"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} la deg til i rom {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} slettet rom {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} deaktivert rom {space}"
#: pkg/service/templates.go:54
msgid "{user} mentioned you in {resource}"
msgstr "{user} nevnte deg i {resource}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} fjernet deg fra rom {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} delt {resource} med deg"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} delte ikke {resource} med deg"
+46 -5
View File
@@ -8,16 +8,57 @@ Currently, the webdav service handles request for two functionalities, which are
### Thumbnails
The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints.
The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints.
See the [thumbnail](https://github.com/opencloud-eu/opencloud/tree/main/services/thumbnails) service for more information about thumbnails.
Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAIL_CACHE_BACKEND` and `WEBDAV_THUMBNAIL_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache.
#### Thumbnail Query String Parameters
Clients can request thumbnail previews for files by adding `?preview=1` to the file URL. Requests for files with no thumbnail available respond with HTTP status `404`.
The following query parameters are supported:
| Parameter | Required | Default Value | Description |
|-----------|----------|------------------------------------------------------|---------------------------------------------------------------------------------|
| preview | YES | 1 | generates preview |
| x | YES | first x-value configured in `WEBDAV_THUMBNAIL_RESOLUTIONS` | horizontal target size |
| y | YES | first y-value configured in `WEBDAV_THUMBNAIL_RESOLUTIONS` | vertical target size |
| scalingup | NO | 0 | accepted for compatibility but ignored; not forwarded to the generator |
| a | NO | 1 | aspect ratio (legacy; only honored when no explicit `processor` is given) |
| c | NO | Caching string | Clients should send the etag, so they get a fresh thumbnail after a file change |
| processor | NO | `resize` for gifs and `thumbnail` for all others | preferred thumbnail processor |
#### Thumbnail Resolution
Various resolutions can be defined via `WEBDAV_THUMBNAIL_RESOLUTIONS`. A requestor can request any arbitrary resolution and the webdav service will use the one closest to the requested resolution. If more than one resolution is required, each resolution must be requested individually.
Example:
Requested: 18x12\
Available: 30x20, 15x10, 9x6\
Returned: 15x10
#### Thumbnail Processors
Normally, an image might get cropped when creating a preview, depending on the aspect ratio of the original image. This can have negative impacts on previews as only a part of the image will be shown. When using an _optional_ processor in the request, cropping can be avoided by defining on how the preview image generation will be done. The following processors are available:
* `resize` resizes the image to the specified width and height and returns the transformed image (distorts to the exact box).
* `fit-in` scales the image down to fit within the specified maximum width and height, preserving aspect ratio and never upscaling (letterboxed).
* `fill`: creates an image with the specified dimensions and fills it with the scaled source image. To achieve the correct aspect ratio without stretching, the source image will be cropped.
The following are **legacy aliases** kept for compatibility. They map onto the processors above but **ignore the `a` and `scalingup` query parameters**; callers that need aspect or upscaling control should use the imagor names directly (`fill`, `fit-in`, `resize`):
* `thumbnail` is a legacy alias for `fill` (center-crop to the exact box, may upscale).
* `fit` is a legacy alias for `fit-in`.
To apply one of those, a query parameter has to be added to the request, like `?processor=fit-in`. If no processor is given, the default behaviour applies which is `resize` for gifs and `thumbnail` (i.e. `fill`) for all others. When an explicit processor overrides a contradictory legacy `a`, the thumbnail response includes the header `X-OpenCloud-Thumbnail-Aspect-Ignored` so developers can fix their client to send a consistent request.
### Search
The webdav service provides access to the search functionality. It offers multiple `REPORT` endpoints for getting search results.
The webdav service provides access to the search functionality. It offers multiple `REPORT` endpoints for getting search results.
See the [search](https://github.com/opencloud-eu/opencloud/tree/main/services/search) service for more details about search functionality.
See the [search](https://github.com/opencloud-eu/opencloud/tree/main/services/search) service for more details about search functionality.
## Scalability
The webdav service does not persist any data and does not cache any information. Therefore multiple instances of this service can be spawned in a bigger deployment like when using container orchestration with Kubernetes, without any extra configuration.
The webdav service persists generated thumbnails to its thumbnail cache (file backend by default). When running multiple instances behind a load balancer, point `WEBDAV_THUMBNAIL_CACHE_BACKEND` at a shared `s3` bucket so all instances read and write the same cache; otherwise each instance keeps its own on-disk cache.
+19 -5
View File
@@ -21,9 +21,23 @@ type Config struct {
HTTP HTTP `yaml:"http"`
DisablePreviews bool `yaml:"disablePreviews" env:"OC_DISABLE_PREVIEWS;WEBDAV_DISABLE_PREVIEWS" desc:"Set this option to 'true' to disable rendering of thumbnails triggered via webdav access. Note that when disabled, all access to preview related webdav paths will return a 404." introductionVersion:"1.0.0"`
OpenCloudPublicURL string `yaml:"opencloud_public_url" env:"OC_URL;OC_PUBLIC_URL" desc:"URL, where OpenCloud is reachable for users." introductionVersion:"1.0.0"`
WebdavNamespace string `yaml:"webdav_namespace" env:"WEBDAV_WEBDAV_NAMESPACE" desc:"CS3 path layout to use when forwarding /webdav requests" 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"`
Context context.Context `yaml:"-"`
OpenCloudPublicURL string `yaml:"opencloud_public_url" env:"OC_URL;OC_PUBLIC_URL" desc:"URL, where OpenCloud is reachable for users." introductionVersion:"1.0.0"`
WebdavNamespace string `yaml:"webdav_namespace" env:"WEBDAV_WEBDAV_NAMESPACE" desc:"CS3 path layout to use when forwarding /webdav requests" 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"`
ThumbnailGeneratorURL string `yaml:"thumbnail_generator_url" env:"WEBDAV_THUMBNAIL_GENERATOR_URL" desc:"Base URL of the thumbnail generator service, e.g. http://thumbnails:9130" introductionVersion:"1.0.0"`
ThumbnailGeneratorTimeout string `yaml:"thumbnail_generator_timeout" env:"WEBDAV_THUMBNAIL_GENERATOR_TIMEOUT" desc:"HTTP timeout for requests to the thumbnail generator, e.g. 30s" introductionVersion:"1.0.0"`
MaxInputFileSize string `yaml:"max_input_file_size" env:"WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE" desc:"Maximum file size of an input image for thumbnail generation. Usable common abbreviations: [KB, KiB, MB, MiB, GB, GiB], example: 50MB" introductionVersion:"1.0.0"`
ThumbnailCacheBackend string `yaml:"thumbnail_cache_backend" env:"WEBDAV_THUMBNAIL_CACHE_BACKEND" desc:"Cache backend for imagor thumbnails: 'none', 'memory', 'file', or 's3'. Default: file." introductionVersion:"1.0.0"`
ThumbnailCacheDir string `yaml:"thumbnail_cache_dir" env:"WEBDAV_THUMBNAIL_CACHE_DIR" desc:"Directory for file-based thumbnail cache (Default: $OC_BASE_DATA_PATH/thumbnails/files). Only used when ThumbnailCacheBackend is 'file'." introductionVersion:"1.0.0"`
ThumbnailResolutions []string `yaml:"thumbnail_resolutions" env:"THUMBNAILS_RESOLUTIONS;WEBDAV_THUMBNAIL_RESOLUTIONS" desc:"Supported target resolutions in the format WidthxHeight like 32x32. The requested size is snapped onto one of these (orientation-aware) before being sent to the generator." introductionVersion:"1.0.0"`
ThumbnailCacheS3Bucket string `yaml:"thumbnail_cache_s3_bucket" env:"WEBDAV_THUMBNAIL_CACHE_S3_BUCKET" desc:"S3 bucket name for thumbnail cache when ThumbnailCacheBackend is 's3'." introductionVersion:"1.0.0"`
ThumbnailCacheS3Region string `yaml:"thumbnail_cache_s3_region" env:"WEBDAV_THUMBNAIL_CACHE_S3_REGION" desc:"S3 region for thumbnail cache when ThumbnailCacheBackend is 's3'." introductionVersion:"1.0.0"`
ThumbnailCacheS3Endpoint string `yaml:"thumbnail_cache_s3_endpoint" env:"WEBDAV_THUMBNAIL_CACHE_S3_ENDPOINT" desc:"S3 endpoint URL (for MinIO or compatible services) when ThumbnailCacheBackend is 's3'." introductionVersion:"1.0.0"`
ThumbnailCacheS3AccessKey string `yaml:"thumbnail_cache_s3_access_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_ACCESS_KEY" desc:"S3 access key for thumbnail cache authentication." introductionVersion:"1.0.0"`
ThumbnailCacheS3SecretKey string `yaml:"thumbnail_cache_s3_secret_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_SECRET_KEY" desc:"S3 secret key for thumbnail cache authentication." introductionVersion:"1.0.0"`
FontMapFile string `yaml:"font_map_file" env:"WEBDAV_THUMBNAILS_TXT_FONTMAP_FILE;THUMBNAILS_TXT_FONTMAP_FILE" desc:"The path to a font map file for txt thumbnails." introductionVersion:"1.0.0"`
Context context.Context `yaml:"-"`
}
@@ -1,8 +1,10 @@
package defaults
import (
"path"
"strings"
cored "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/webdav/pkg/config"
@@ -42,6 +44,17 @@ func DefaultConfig() *config.Config {
OpenCloudPublicURL: "https://localhost:9200",
WebdavNamespace: "/users/{{.Id.OpaqueId}}",
RevaGateway: shared.DefaultRevaConfig().Address,
ThumbnailGeneratorURL: "http://127.0.0.1:9186",
ThumbnailGeneratorTimeout: "30s",
MaxInputFileSize: "50MB",
ThumbnailCacheBackend: "file",
ThumbnailCacheDir: path.Join(cored.BaseDataPath(), "thumbnails", "files"),
ThumbnailResolutions: []string{
"16x16", "32x32", "64x64", "128x128", "320x320", "1024x1024", // square
"280x500", "560x1000", "1080x1920", "2160x3840", "4320x7680", // portrait
"500x280", "1000x560", "1920x1080", "3840x2160", "7680x4320", // landscape
},
}
}
@@ -66,5 +79,4 @@ func Sanitize(cfg *config.Config) {
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
}
+68 -27
View File
@@ -4,8 +4,10 @@ import (
"fmt"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
@@ -22,10 +24,17 @@ const (
DefaultHeight = 32
)
// ThumbnailRequest combines all parameters provided when requesting a thumbnail
// ThumbnailRequest combines all parameters provided when requesting a thumbnail.
type ThumbnailRequest struct {
// The file path of the source file
Filepath string
// Ref is the CS3 reference to the source file. It carries both the space root
// (ResourceId) and the relative path, mirroring reva's ocdav split of
// space-ID-based requests from path-only requests:
// - Space-ID requests (dav/spaces/{id}/...): ResourceId is the parsed space
// ID and Path is the URL-relative path. The workflow uses it directly.
// - Path-only requests (dav/files/{user}/..., /webdav/..., public links):
// ResourceId is nil and Path is an absolute CS3 path. The workflow resolves
// the space root via a lookup (which also works for public links).
Ref *providerv1beta1.Reference
// The file name of the source file including the extension
Filename string
// The file extension
@@ -34,33 +43,54 @@ type ThumbnailRequest struct {
Width int32
// The requested height of the thumbnail
Height int32
// In case of a public share the public link token.
PublicLinkToken string
// Indicates which image processor to use
Processor string
// The Identifier from the requested URL
// Aspect reports whether the client wants the aspect ratio preserved (the
// legacy ownCloud "a" flag: a=1/absent -> preserve, a=0 -> fill the box).
Aspect bool
// Identifier is the username from /dav/files/{user}/... when present. The
// workflow resolves it via GetUserByClaim so the file is looked up in that
// user's home; empty for space-ID, /webdav/ and public-link requests.
Identifier string
}
func addMissingStorageID(id string) string {
rid := &providerv1beta1.ResourceId{}
rid.StorageId, rid.SpaceId, rid.OpaqueId, _ = storagespace.SplitID(id)
if rid.StorageId == "" && rid.SpaceId == utils.ShareStorageSpaceID {
rid.StorageId = utils.ShareStorageProviderID
}
return storagespace.FormatResourceID(rid)
}
// ParseThumbnailRequest extracts all required parameters from a http request.
func ParseThumbnailRequest(r *http.Request) (*ThumbnailRequest, error) {
ctx := r.Context()
fp := ctx.Value(constants.ContextKeyPath).(string)
id := ""
v := ctx.Value(constants.ContextKeyID)
if v != nil {
id = addMissingStorageID(v.(string))
var (
ref *providerv1beta1.Reference
identifier string
)
if v := ctx.Value(constants.ContextKeyID); v != nil {
id := v.(string)
if strings.Contains(id, "$") {
// Space-ID based request (dav/spaces/{id}/...): the URL carries the
// space root, so build the reference directly from the parsed ID.
rid, err := storagespace.ParseID(addMissingStorageID(id))
if err != nil {
return nil, fmt.Errorf("parse space id %q: %w", id, err)
}
ref = &providerv1beta1.Reference{
ResourceId: &rid,
Path: utils.MakeRelativePath(fp),
}
} else {
// The identifier is a username (dav/files/{username}/...); the workflow
// resolves it to that user's home path via GetUserByClaim.
ref = &providerv1beta1.Reference{Path: fp}
identifier = id
}
}
if token := chi.URLParam(r, "token"); token != "" {
ref = &providerv1beta1.Reference{Path: path.Join("/public", token, strings.TrimLeft(fp, "/"))}
} else if ref == nil {
// Path-only request (/webdav/...): the absolute CS3 path is carried in
// ContextKeyPath; the workflow resolves the space root.
ref = &providerv1beta1.Reference{Path: fp}
}
q := r.URL.Query()
@@ -70,17 +100,28 @@ func ParseThumbnailRequest(r *http.Request) (*ThumbnailRequest, error) {
}
return &ThumbnailRequest{
Filepath: fp,
Filename: filepath.Base(fp),
Extension: filepath.Ext(fp),
Width: int32(width),
Height: int32(height),
Processor: q.Get("processor"),
PublicLinkToken: chi.URLParam(r, "token"),
Identifier: id,
Ref: ref,
Filename: filepath.Base(fp),
Extension: filepath.Ext(fp),
Width: int32(width),
Height: int32(height),
Processor: q.Get("processor"),
Aspect: q.Get("a") != "0",
Identifier: identifier,
}, nil
}
// addMissingStorageID fills in the shares storage provider ID when a share mount
// space ID is missing it, so the parsed reference routes to the right provider.
func addMissingStorageID(id string) string {
rid := &providerv1beta1.ResourceId{}
rid.StorageId, rid.SpaceId, rid.OpaqueId, _ = storagespace.SplitID(id)
if rid.StorageId == "" && rid.SpaceId == utils.ShareStorageSpaceID {
rid.StorageId = utils.ShareStorageProviderID
}
return storagespace.FormatResourceID(rid)
}
func parseDimensions(q url.Values) (int64, int64, error) {
width, err := parseDimension(q.Get("x"), "width", DefaultWidth)
if err != nil {
@@ -0,0 +1,56 @@
package requests
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/constants"
)
func newThumbnailRequest(t *testing.T, rawQuery string) *http.Request {
t.Helper()
req := httptest.NewRequest("GET", "/files/test.png?"+rawQuery, nil)
ctx := context.WithValue(req.Context(), constants.ContextKeyPath, "/files/test.png")
return req.WithContext(ctx)
}
func TestParseThumbnailRequestAspect(t *testing.T) {
tests := []struct {
name string
query string
wantA bool
}{
{"a absent defaults to preserve aspect", "x=1024&y=1024", true},
{"a=1 preserves aspect", "x=1024&y=1024&a=1", true},
{"a=0 fills the box", "x=1024&y=1024&a=0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := newThumbnailRequest(t, tt.query)
tr, err := ParseThumbnailRequest(r)
if err != nil {
t.Fatalf("ParseThumbnailRequest() error = %v", err)
}
if tr.Aspect != tt.wantA {
t.Errorf("Aspect = %v, want %v", tr.Aspect, tt.wantA)
}
})
}
}
func TestParseThumbnailRequestProcessor(t *testing.T) {
r := newThumbnailRequest(t, "x=320&y=320&processor=fit")
tr, err := ParseThumbnailRequest(r)
if err != nil {
t.Fatalf("ParseThumbnailRequest() error = %v", err)
}
if tr.Processor != "fit" {
t.Errorf("Processor = %q, want %q", tr.Processor, "fit")
}
if tr.Width != 320 || tr.Height != 320 {
t.Errorf("dimensions = %dx%d, want 320x320", tr.Width, tr.Height)
}
}
@@ -0,0 +1,70 @@
package generator
import (
"mime"
"strings"
)
// ExtensionInfo holds the output format and content type for a given file extension.
type ExtensionInfo struct {
OutputFormat string // generator output format (gif, png, jpeg)
ContentType string // HTTP Content-Type header value
}
var extMap = map[string]ExtensionInfo{
"gif": {OutputFormat: "gif", ContentType: "image/gif"},
"png": {OutputFormat: "png", ContentType: "image/png"},
"jpeg": {OutputFormat: "jpeg", ContentType: "image/jpeg"},
"jpg": {OutputFormat: "jpeg", ContentType: "image/jpeg"},
}
var defaultExtInfo = ExtensionInfo{OutputFormat: "jpeg", ContentType: "image/jpeg"}
// GetExtensionInfo returns the extension info for a given file extension.
func GetExtensionInfo(ext string) ExtensionInfo {
if info, ok := extMap[strings.ToLower(strings.TrimLeft(ext, "."))]; ok {
return info
}
return defaultExtInfo
}
// OutputFormat returns the generator output format for a given file extension.
func OutputFormat(ext string) string {
return GetExtensionInfo(ext).OutputFormat
}
// ContentType returns the HTTP Content-Type for a given file extension.
func ContentType(ext string) string {
return GetExtensionInfo(ext).ContentType
}
// MimeToExt maps a resource mime type to the file extension used for the on-disk
// thumbnail cache key. Unknown image types fall back to jpg, matching main's
// behavior (only png/gif are treated specially).
func MimeToExt(mimeType string) string {
switch mimeType {
case "image/png":
return "png"
case "image/gif":
return "gif"
default:
return "jpg"
}
}
// ExtForMime mirrors main's GetExtForMime: returns the output extension for a
// source mime, or "" when the source has no dedicated image type (txt, audio,
// bmp, tiff) so the caller falls back to the requested extension.
func ExtForMime(mimeType string) string {
m, _, _ := mime.ParseMediaType(mimeType)
switch m {
case "image/jpeg", "image/webp":
return "jpg"
case "image/png":
return "png"
case "image/gif":
return "gif"
default:
return ""
}
}
@@ -0,0 +1,167 @@
package generator
import (
"testing"
)
func TestParseMaxInputFileSize(t *testing.T) {
tests := []struct {
name string
input string
want uint64
wantErr bool
}{
{"empty", "", 0, false},
{"plain bytes", "1234", 1234, false},
{"KB", "50KB", 50 * 1024, false},
{"kB case insensitive", "50kB", 50 * 1024, false},
{"KiB", "50KiB", 50 * 1024, false},
{"MB", "5MB", 5 * 1024 * 1024, false},
{"MiB", "5MiB", 5 * 1024 * 1024, false},
{"GB", "2GB", 2 * 1024 * 1024 * 1024, false},
{"GiB", "2GiB", 2 * 1024 * 1024 * 1024, false},
{"whitespace around value", " 5MB ", 5 * 1024 * 1024, false},
{"space before suffix", "5 MB", 5 * 1024 * 1024, false},
{"invalid input", "abc", 0, true},
{"negative number", "-5MB", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseMaxInputFileSize(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseMaxInputFileSize() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ParseMaxInputFileSize() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetExtensionInfo(t *testing.T) {
tests := []struct {
ext string
format string
ct string
}{
{"gif", "gif", "image/gif"},
{".gif", "gif", "image/gif"},
{"png", "png", "image/png"},
{"jpeg", "jpeg", "image/jpeg"},
{"jpg", "jpeg", "image/jpeg"},
{"webp", "jpeg", "image/jpeg"},
{"", "jpeg", "image/jpeg"},
}
for _, tt := range tests {
t.Run(tt.ext, func(t *testing.T) {
info := GetExtensionInfo(tt.ext)
if info.OutputFormat != tt.format {
t.Errorf("OutputFormat = %q, want %q", info.OutputFormat, tt.format)
}
if info.ContentType != tt.ct {
t.Errorf("ContentType = %q, want %q", info.ContentType, tt.ct)
}
})
}
}
func TestOutputFormat(t *testing.T) {
if got := OutputFormat("png"); got != "png" {
t.Errorf("OutputFormat(\"png\") = %q, want \"png\"", got)
}
if got := OutputFormat("jpg"); got != "jpeg" {
t.Errorf("OutputFormat(\"jpg\") = %q, want \"jpeg\"", got)
}
}
func TestContentType(t *testing.T) {
if got := ContentType("gif"); got != "image/gif" {
t.Errorf("ContentType(\"gif\") = %q, want \"image/gif\"", got)
}
if got := ContentType("unknown"); got != "image/jpeg" {
t.Errorf("ContentType(\"unknown\") = %q, want \"image/jpeg\"", got)
}
}
func TestMimeToExt(t *testing.T) {
tests := []struct {
mime string
want string
}{
{"image/jpeg", "jpg"},
{"image/png", "png"},
{"image/gif", "gif"},
{"application/octet-stream", "jpg"},
}
for _, tt := range tests {
t.Run(tt.mime, func(t *testing.T) {
if got := MimeToExt(tt.mime); got != tt.want {
t.Errorf("MimeToExt(%q) = %q, want %q", tt.mime, got, tt.want)
}
})
}
}
func TestBuildURL(t *testing.T) {
tests := []struct {
name string
base string
width int32
height int32
operation string
ext string
noUpscale bool
want string
}{
{
name: "fill operation center-crops to the box",
base: "http://generator",
width: 128,
height: 128,
operation: OpFill,
ext: "jpeg",
want: "http://generator/unsafe/128x128/filters:format(jpeg)/",
},
{
name: "fill with no_upscale filter caps the resize at source size",
base: "http://generator",
width: 128,
height: 128,
operation: OpFill,
ext: "jpeg",
noUpscale: true,
want: "http://generator/unsafe/128x128/filters:no_upscale():format(jpeg)/",
},
{
name: "fit-in preserves aspect ratio within the box without upscaling",
base: "http://generator",
width: 1024,
height: 1024,
operation: OpFitIn,
ext: "jpeg",
want: "http://generator/unsafe/fit-in/1024x1024/filters:format(jpeg)/",
},
{
name: "stretch resizes to the exact box",
base: "http://generator/",
width: 64,
height: 32,
operation: OpStretch,
ext: "png",
want: "http://generator/unsafe/stretch/64x32/filters:format(png)/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BuildURL(tt.base, tt.width, tt.height, tt.operation, tt.ext, tt.noUpscale)
if got != tt.want {
t.Errorf("BuildURL() = %q, want %q", got, tt.want)
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
package generator
import (
"net/http"
)
// WriteThumbnailResponse writes the thumbnail bytes to the HTTP response with correct Content-Type.
func WriteThumbnailResponse(w http.ResponseWriter, data []byte, ext string) {
ct := ContentType(ext)
w.Header().Set("Content-Type", ct)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+46
View File
@@ -0,0 +1,46 @@
package generator
import (
"fmt"
"strconv"
"strings"
)
type sizeUnit struct {
suffix string
len int
mul uint64
}
var sizeUnits = []sizeUnit{
{"GIB", 3, 1 << 30}, {"GB", 2, 1 << 30},
{"MIB", 3, 1 << 20}, {"MB", 2, 1 << 20},
{"KIB", 3, 1 << 10}, {"KB", 2, 1 << 10},
}
// ParseMaxInputFileSize parses a size string like "50MB" into bytes.
// Empty string returns 0 (no limit).
func ParseMaxInputFileSize(s string) (uint64, error) {
if s == "" {
return 0, nil
}
s = strings.TrimSpace(s)
var unitMultiplier uint64 = 1
upper := strings.ToUpper(s)
for _, u := range sizeUnits {
if strings.HasSuffix(upper, u.suffix) {
unitMultiplier = u.mul
s = s[:len(s)-u.len]
break
}
}
size, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid file size: %w", err)
}
return size * unitMultiplier, nil
}
+57
View File
@@ -0,0 +1,57 @@
package generator
import (
"fmt"
"strings"
)
// Operations are the imagor resize/crop operations the generator understands.
const (
// OpFill center-crops to fill the box exactly (imagor default resize).
OpFill = "fill"
// OpFitIn fits within the box without cropping and never upscales (imagor
// fit-in), preserving the source aspect ratio.
OpFitIn = "fit-in"
// OpStretch resizes to the exact box without preserving aspect ratio.
OpStretch = "stretch"
)
// operationSegment maps an operation to its imagor URL path segment. The fill
// operation is the bare WxH resize (imagor's default), so it contributes no
// leading segment; stretch and fit-in use their imagor names. Note that real
// imagor has no /fill/ route — "fill" is only our internal name for the default
// center-crop resize.
func operationSegment(op string) string {
switch op {
case OpFitIn:
return "fit-in"
case OpStretch:
return "stretch"
default: // OpFill and anything unrecognized fall back to the default resize
return ""
}
}
// BuildURL constructs the thumbnail generator processing URL for a matched box
// and operation. The operation segment selects the resize/crop mode (which also
// governs upscaling); the image is re-encoded to the requested output format via
// an imagor filters segment. Like real imagor, the default (fill) resize upscales
// small sources to fill the box exactly; pass noUpscale=true to emit the
// no_upscale() filter so the generator caps the result at the source size.
func BuildURL(baseURL string, width, height int32, operation, outputExt string, noUpscale bool) string {
base := strings.TrimRight(baseURL, "/")
box := fmt.Sprintf("%dx%d", width, height)
filters := "format(" + outputExt + ")"
if noUpscale {
filters = "no_upscale():format(" + outputExt + ")"
}
filterSegment := "filters:" + filters
segment := operationSegment(operation)
if segment == "" {
return fmt.Sprintf("%s/unsafe/%s/%s/", base, box, filterSegment)
}
return fmt.Sprintf("%s/unsafe/%s/%s/%s/", base, segment, box, filterSegment)
}
@@ -0,0 +1,9 @@
package preprocessor
import "errors"
var (
ErrNoImageFromAudioFile = errors.New("preprocessor: could not extract image from audio file")
ErrNoConverterForExtractedImageFromGgsFile = errors.New("preprocessor: could not find converter for image extracted from ggs file")
ErrNoConverterForExtractedImageFromAudioFile = errors.New("preprocessor: could not find converter for image extracted from audio file")
)
@@ -20,8 +20,6 @@ import (
"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
@@ -44,7 +42,13 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) {
// GgsDecoder is a converter for the geogebra slides file
type GgsDecoder struct{ thumbnailpath string }
// Convert reads the ggs file and returns the thumbnail image
// Convert reads the ggs file and returns the thumbnail image.
//
// TODO: this parses user-provided bytes in-process (archive/zip streams the
// embedded thumbnail out of the .ggs container, which is then decoded and
// re-encoded here). Tika is the intended out-of-process extractor for embedded
// images; reading/re-encoding user-supplied embedded images in webdav is a
// separate attack vector not addressed by this architecture change.
func (g GgsDecoder) Convert(r io.Reader) (any, error) {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
@@ -63,7 +67,7 @@ func (g GgsDecoder) Convert(r io.Reader) (any, error) {
}
converter := ForType("image/png", nil)
if converter == nil {
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromGgsFile
return nil, ErrNoConverterForExtractedImageFromGgsFile
}
img, err := converter.Convert(thumbnail)
if err != nil {
@@ -78,7 +82,13 @@ func (g GgsDecoder) Convert(r io.Reader) (any, error) {
// 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
// Convert reads the audio file and extracts the thumbnail image from the id3 tag.
//
// TODO: this parses user-provided bytes in-process (dhowden/tag reads the audio
// metadata and pulls the embedded APIC picture, which is then decoded and
// re-encoded here). Tika is the intended out-of-process extractor for embedded
// images; reading/re-encoding user-supplied embedded images in webdav is a
// separate attack vector not addressed by this architecture change.
func (i AudioDecoder) Convert(r io.Reader) (any, error) {
b, err := io.ReadAll(r)
if err != nil {
@@ -91,12 +101,12 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
picture := m.Picture()
if picture == nil {
return nil, thumbnailerErrors.ErrNoImageFromAudioFile
return nil, ErrNoImageFromAudioFile
}
converter := ForType(picture.MIMEType, nil)
if converter == nil {
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromAudioFile
return nil, ErrNoConverterForExtractedImageFromAudioFile
}
return converter.Convert(bytes.NewReader(picture.Data))
@@ -202,7 +212,13 @@ type GGPStruct struct {
// GgpDecoder is a converter for the geogebra pinboard file
type GgpDecoder struct{}
// Convert reads the ggp file and returns the first thumbnail image
// Convert reads the ggp file and returns the first thumbnail image.
//
// TODO: this parses user-provided bytes in-process (JSON/base64 extraction of
// the embedded image, which is then decoded here). Tika is the intended
// out-of-process extractor for embedded images; reading/re-encoding user-supplied
// embedded images in webdav is a separate attack vector not addressed by this
// architecture change.
func (j GgpDecoder) Convert(r io.Reader) (any, error) {
ggp := &GGPStruct{}
err := json.NewDecoder(r).Decode(ggp)
@@ -0,0 +1,29 @@
//go:build enable_vips
package preprocessor
import (
"io"
vips "github.com/davidbyttow/govips/v2/vips"
"github.com/kovidgoyal/imaging"
"github.com/pkg/errors"
)
func init() {
vips.LoggingSettings(nil, vips.LogLevelError)
}
// ImageDecoder is a converter for the image file. It decodes with imaging so the
// downstream encode step always receives an image.Image (the vips-backed
// encoder re-wraps it into libvips itself). Returning a *vips.ImageRef here
// would leak a C image past the workflow's ownership boundary.
type ImageDecoder struct{}
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
}
@@ -0,0 +1,182 @@
package svc
import (
"os"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/generator"
)
var _ = Describe("ParseMaxInputFileSize", func() {
It("should return 0 for empty string", func() {
size, err := generator.ParseMaxInputFileSize("")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(0)))
})
It("should parse plain bytes", func() {
size, err := generator.ParseMaxInputFileSize("1234")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(1234)))
})
It("should parse KB suffix", func() {
size, err := generator.ParseMaxInputFileSize("50KB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(50 * 1024)))
})
It("should parse kB suffix (case insensitive)", func() {
size, err := generator.ParseMaxInputFileSize("50kB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(50 * 1024)))
})
It("should parse KiB suffix", func() {
size, err := generator.ParseMaxInputFileSize("50KiB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(50 * 1024)))
})
It("should parse MB suffix", func() {
size, err := generator.ParseMaxInputFileSize("5MB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(5 * 1024 * 1024)))
})
It("should parse MiB suffix", func() {
size, err := generator.ParseMaxInputFileSize("5MiB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(5 * 1024 * 1024)))
})
It("should parse GB suffix", func() {
size, err := generator.ParseMaxInputFileSize("2GB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(2 * 1024 * 1024 * 1024)))
})
It("should parse GiB suffix", func() {
size, err := generator.ParseMaxInputFileSize("2GiB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(2 * 1024 * 1024 * 1024)))
})
It("should handle leading/trailing whitespace", func() {
size, err := generator.ParseMaxInputFileSize(" 5MB ")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(5 * 1024 * 1024)))
})
It("should handle whitespace around suffix", func() {
size, err := generator.ParseMaxInputFileSize("5 MB")
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(5 * 1024 * 1024)))
})
It("should return error for invalid input", func() {
_, err := generator.ParseMaxInputFileSize("abc")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid file size"))
})
It("should return error for negative number", func() {
_, err := generator.ParseMaxInputFileSize("-5MB")
Expect(err).To(HaveOccurred())
})
})
// testEnvVarFallback simulates go-micro's ";" separator pattern for env vars.
func testEnvVarFallback(names string) string {
for _, n := range strings.Split(names, ";") {
if v := os.Getenv(strings.TrimSpace(n)); v != "" {
return v
}
}
return ""
}
var _ = Describe("envVarFallback", func() {
BeforeEach(func() {
os.Unsetenv("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
os.Unsetenv("THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
})
AfterEach(func() {
os.Unsetenv("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
os.Unsetenv("THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
})
It("should return empty string when neither env var is set", func() {
val := testEnvVarFallback("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
Expect(val).To(Equal(""))
})
It("should return primary env var value when only primary is set", func() {
os.Setenv("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE", "50MB")
val := testEnvVarFallback("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
Expect(val).To(Equal("50MB"))
})
It("should return fallback env var value when only fallback is set", func() {
os.Setenv("THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE", "100MB")
val := testEnvVarFallback("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
Expect(val).To(Equal("100MB"))
})
It("should prefer primary env var over fallback when both are set", func() {
os.Setenv("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE", "50MB")
os.Setenv("THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE", "100MB")
val := testEnvVarFallback("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
Expect(val).To(Equal("50MB"))
})
It("should parse fallback value correctly through ParseMaxInputFileSize", func() {
os.Setenv("THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE", "2GB")
val := testEnvVarFallback("WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE")
size, err := generator.ParseMaxInputFileSize(val)
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(uint64(2 * 1024 * 1024 * 1024)))
})
})
var _ = Describe("ExtensionInfo", func() {
It("should map gif extension correctly", func() {
info := generator.GetExtensionInfo("gif")
Expect(info.OutputFormat).To(Equal("gif"))
Expect(info.ContentType).To(Equal("image/gif"))
})
It("should map png extension correctly", func() {
info := generator.GetExtensionInfo("png")
Expect(info.OutputFormat).To(Equal("png"))
Expect(info.ContentType).To(Equal("image/png"))
})
It("should map jpeg extension correctly", func() {
info := generator.GetExtensionInfo("jpeg")
Expect(info.OutputFormat).To(Equal("jpeg"))
Expect(info.ContentType).To(Equal("image/jpeg"))
})
It("should map jpg to jpeg output format", func() {
info := generator.GetExtensionInfo("jpg")
Expect(info.OutputFormat).To(Equal("jpeg"))
Expect(info.ContentType).To(Equal("image/jpeg"))
})
It("should return default info for unknown extension", func() {
info := generator.GetExtensionInfo("webp")
Expect(info.OutputFormat).To(Equal("jpeg"))
Expect(info.ContentType).To(Equal("image/jpeg"))
})
It("should return default info for empty extension", func() {
info := generator.GetExtensionInfo("")
Expect(info.OutputFormat).To(Equal("jpeg"))
Expect(info.ContentType).To(Equal("image/jpeg"))
})
})
+1 -1
View File
@@ -25,8 +25,8 @@ import (
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/constants"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/net"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/prop"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/propfind"
+213 -277
View File
@@ -3,15 +3,15 @@ package svc
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
"time"
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
@@ -20,20 +20,19 @@ import (
"github.com/go-chi/render"
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/storage/utils/templates"
"github.com/riandyrn/otelchi"
merrors "go-micro.dev/v4/errors"
grpcmetadata "google.golang.org/grpc/metadata"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/registry"
"github.com/opencloud-eu/opencloud/pkg/tracing"
thumbnailsmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/thumbnails/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
thumbnailssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/thumbnails/v0"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/config"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/constants"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/dav/requests"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/generator"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail/cache"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail/workflow"
)
var (
@@ -85,61 +84,98 @@ func NewService(opts ...Option) (Service, error) {
return nil, err
}
svc := Webdav{
config: conf,
log: options.Logger,
mux: m,
searchClient: searchsvc.NewSearchProviderService("eu.opencloud.api.search", conf.GrpcClient),
thumbnailsClient: thumbnailssvc.NewThumbnailService("eu.opencloud.api.thumbnails", conf.GrpcClient),
gatewaySelector: gatewaySelector,
genTimeout, _ := time.ParseDuration(conf.ThumbnailGeneratorTimeout)
if genTimeout == 0 {
genTimeout = 30 * time.Second
}
if svc.config.DisablePreviews {
svc.thumbnailsClient = nil
resolutions, err := thumbnail.ParseResolutions(conf.ThumbnailResolutions)
if err != nil {
return nil, fmt.Errorf("parse thumbnail resolutions: %w", err)
}
maxInputSize, err := generator.ParseMaxInputFileSize(conf.MaxInputFileSize)
if err != nil {
return nil, fmt.Errorf("parse max input file size: %w", err)
}
s3cfg := cache.BuildS3CacheConfig(
conf.ThumbnailCacheS3Bucket,
conf.ThumbnailCacheS3Region,
conf.ThumbnailCacheS3Endpoint,
conf.ThumbnailCacheS3AccessKey,
conf.ThumbnailCacheS3SecretKey,
)
c := cache.NewThumbnailCache(conf.ThumbnailCacheBackend, conf.ThumbnailCacheDir, s3cfg)
httpClient := &http.Client{Timeout: genTimeout}
wf, err := workflow.NewWorkflow(
workflow.WithGeneratorURL(conf.ThumbnailGeneratorURL),
workflow.WithCache(c),
workflow.WithHTTPClient(httpClient),
workflow.WithMaxInputSize(maxInputSize),
workflow.WithResolutions(resolutions),
workflow.WithWebdavNamespace(conf.WebdavNamespace),
workflow.WithFontMapFile(conf.FontMapFile),
workflow.WithLogger(options.Logger),
workflow.WithStater(workflow.NewGatewayStater(gatewaySelector)),
workflow.WithFileDownloader(workflow.NewGatewayFileDownloader(gatewaySelector, httpClient)),
workflow.WithSpaceLookup(workflow.NewGatewaySpaceLookup(gatewaySelector)),
workflow.WithUserResolver(workflow.NewGatewayUserResolver(gatewaySelector)),
)
if err != nil {
return nil, fmt.Errorf("create thumbnail workflow: %w", err)
}
svc := Webdav{
config: conf,
log: options.Logger,
mux: m,
searchClient: searchsvc.NewSearchProviderService("eu.opencloud.api.search", conf.GrpcClient),
workflow: wf,
gatewaySelector: gatewaySelector,
}
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(svc.DavUserContext())
if !svc.config.DisablePreviews {
r.Group(func(r chi.Router) {
r.Use(svc.DavUserContext())
r.Get("/remote.php/dav/spaces/{id}", svc.SpacesThumbnail)
r.Get("/remote.php/dav/spaces/{id}/*", svc.SpacesThumbnail)
r.Get("/dav/spaces/{id}", svc.SpacesThumbnail)
r.Get("/dav/spaces/{id}/*", svc.SpacesThumbnail)
r.MethodFunc("REPORT", "/remote.php/dav/spaces*", svc.Search)
r.MethodFunc("REPORT", "/dav/spaces*", svc.Search)
r.Get("/remote.php/dav/spaces/{id}", svc.SpacesThumbnail)
r.Get("/remote.php/dav/spaces/{id}/*", svc.SpacesThumbnail)
r.Get("/dav/spaces/{id}", svc.SpacesThumbnail)
r.Get("/dav/spaces/{id}/*", svc.SpacesThumbnail)
r.MethodFunc("REPORT", "/remote.php/dav/spaces*", svc.Search)
r.MethodFunc("REPORT", "/dav/spaces*", svc.Search)
r.Get("/remote.php/dav/files/{id}", svc.Thumbnail)
r.Get("/remote.php/dav/files/{id}/*", svc.Thumbnail)
r.Get("/dav/files/{id}", svc.Thumbnail)
r.Get("/dav/files/{id}/*", svc.Thumbnail)
r.Get("/remote.php/dav/files/{id}", svc.Thumbnail)
r.Get("/remote.php/dav/files/{id}/*", svc.Thumbnail)
r.Get("/dav/files/{id}", svc.Thumbnail)
r.Get("/dav/files/{id}/*", svc.Thumbnail)
r.MethodFunc("REPORT", "/remote.php/dav/files*", svc.Search)
r.MethodFunc("REPORT", "/dav/files*", svc.Search)
})
r.MethodFunc("REPORT", "/remote.php/dav/files*", svc.Search)
r.MethodFunc("REPORT", "/dav/files*", svc.Search)
})
r.Group(func(r chi.Router) {
r.Use(svc.DavPublicContext())
r.Group(func(r chi.Router) {
r.Use(svc.DavPublicContext())
r.Head("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnailHead)
r.Head("/dav/public-files/{token}/*", svc.PublicThumbnailHead)
r.Head("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnailHead)
r.Head("/dav/public-files/{token}/*", svc.PublicThumbnailHead)
r.Get("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnail)
r.Get("/dav/public-files/{token}/*", svc.PublicThumbnail)
})
r.Get("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnail)
r.Get("/dav/public-files/{token}/*", svc.PublicThumbnail)
})
r.Group(func(r chi.Router) {
r.Use(svc.WebDAVContext())
r.Get("/remote.php/webdav/*", svc.Thumbnail)
r.Get("/webdav/*", svc.Thumbnail)
r.MethodFunc("REPORT", "/remote.php/webdav*", svc.Search)
r.MethodFunc("REPORT", "/webdav*", svc.Search)
})
}
r.Group(func(r chi.Router) {
r.Use(svc.WebDAVContext())
r.Get("/remote.php/webdav/*", svc.Thumbnail)
r.Get("/webdav/*", svc.Thumbnail)
r.MethodFunc("REPORT", "/remote.php/webdav*", svc.Search)
r.MethodFunc("REPORT", "/webdav*", svc.Search)
})
})
_ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
@@ -152,12 +188,12 @@ func NewService(opts ...Option) (Service, error) {
// Webdav implements the business logic for Service.
type Webdav struct {
config *config.Config
log log.Logger
mux *chi.Mux
searchClient searchsvc.SearchProviderService
thumbnailsClient thumbnailssvc.ThumbnailService
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
config *config.Config
log log.Logger
mux *chi.Mux
searchClient searchsvc.SearchProviderService
workflow *workflow.ThumbnailWorkflow
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
}
// ServeHTTP implements the Service interface.
@@ -236,52 +272,18 @@ func (g Webdav) SpacesThumbnail(w http.ResponseWriter, r *http.Request) {
renderError(w, r, errBadRequest(err.Error()))
return
}
t := r.Header.Get(revactx.TokenHeader)
fullPath := filepath.Join(tr.Identifier, tr.Filepath)
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_Cs3Source{
Cs3Source: &thumbnailsmsg.CS3Source{
Path: fullPath,
Authorization: t,
},
},
})
auth := r.Header.Get(revactx.TokenHeader)
data, ext, aIgnored, err := g.workflow.Execute(r.Context(), tr, auth, logger)
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusTooEarly:
// StatusTooEarly if file is processing
renderError(w, r, errTooEarly(e.Detail))
return
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusForbidden:
renderError(w, r, errPermissionDenied(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
logger.Debug().Err(err).Msg("could not get thumbnail")
g.handleWorkflowError(w, r, err, tr, logger)
return
}
g.sendThumbnailResponse(rsp, w, r)
setAspectIgnoredHeader(w, aIgnored)
generator.WriteThumbnailResponse(w, data, ext)
}
func whoami(gatewayClient gatewayv1beta1.GatewayAPIClient, ctx context.Context, token string) (*userv1beta1.User, error) {
// look up user from token via WhoAmI
userRes, err := gatewayClient.WhoAmI(ctx, &gatewayv1beta1.WhoAmIRequest{
Token: token,
})
@@ -304,83 +306,14 @@ func (g Webdav) Thumbnail(w http.ResponseWriter, r *http.Request) {
return
}
t := r.Header.Get(revactx.TokenHeader)
gatewayClient, err := g.gatewaySelector.Next()
auth := r.Header.Get(revactx.TokenHeader)
data, ext, aIgnored, err := g.workflow.Execute(r.Context(), tr, auth, logger)
if err != nil {
logger.Error().Err(err).Msg("could not get reva gatewayClient")
renderError(w, r, errInternalError("could not get reva gatewayClient"))
g.handleWorkflowError(w, r, err, tr, logger)
return
}
var user *userv1beta1.User
if tr.Identifier == "" {
user, err = whoami(gatewayClient, r.Context(), t)
if err != nil {
logger.Error().Err(err).Msg("could not get user")
renderError(w, r, errInternalError("could not get user"))
return
}
} else {
// look up user from URL via GetUserByClaim
ctx := grpcmetadata.AppendToOutgoingContext(r.Context(), revactx.TokenHeader, t)
userRes, err := gatewayClient.GetUserByClaim(ctx, &userv1beta1.GetUserByClaimRequest{
Claim: "username",
Value: tr.Identifier,
})
if err != nil {
logger.Error().Err(err).Msg("could not get user: transport error")
renderError(w, r, errInternalError("could not get user"))
return
}
if userRes.Status.Code != rpcv1beta1.Code_CODE_OK {
logger.Debug().Str("grpcmessage", userRes.GetStatus().GetMessage()).Msg("could not get user")
renderError(w, r, errInternalError("could not get user"))
return
}
user = userRes.GetUser()
}
fullPath := filepath.Join(templates.WithUser(user, g.config.WebdavNamespace), tr.Filepath)
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_Cs3Source{
Cs3Source: &thumbnailsmsg.CS3Source{
Path: fullPath,
Authorization: t,
},
},
})
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusTooEarly:
// StatusTooEarly if file is processing
renderError(w, r, errTooEarly(e.Detail))
return
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusForbidden:
renderError(w, r, errPermissionDenied(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
g.log.Error().Err(err).Msg("could not get thumbnail")
return
}
g.sendThumbnailResponse(rsp, w, r)
setAspectIgnoredHeader(w, aIgnored)
generator.WriteThumbnailResponse(w, data, ext)
}
func (g Webdav) PublicThumbnail(w http.ResponseWriter, r *http.Request) {
@@ -392,40 +325,19 @@ func (g Webdav) PublicThumbnail(w http.ResponseWriter, r *http.Request) {
return
}
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_WebdavSource{
WebdavSource: &thumbnailsmsg.WebdavSource{
Url: g.config.OpenCloudPublicURL + r.URL.RequestURI(),
IsPublicLink: true,
PublicLinkToken: tr.PublicLinkToken,
},
},
})
auth, err := workflow.ResolvePublicLinkAuth(r.Context(), r, chi.URLParam(r, "token"), g.gatewaySelector)
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
g.log.Error().Err(err).Msg("could not get thumbnail")
g.handlePublicLinkAuthError(w, r, err, tr.Filename, logger)
return
}
g.sendThumbnailResponse(rsp, w, r)
data, ext, aIgnored, err := g.workflow.ExecutePublic(r.Context(), tr, auth, logger)
if err != nil {
g.handleWorkflowError(w, r, err, tr, logger)
return
}
setAspectIgnoredHeader(w, aIgnored)
generator.WriteThumbnailResponse(w, data, ext)
}
func (g Webdav) PublicThumbnailHead(w http.ResponseWriter, r *http.Request) {
@@ -437,91 +349,119 @@ func (g Webdav) PublicThumbnailHead(w http.ResponseWriter, r *http.Request) {
return
}
_, err = g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_WebdavSource{
WebdavSource: &thumbnailsmsg.WebdavSource{
Url: g.config.OpenCloudPublicURL + r.URL.RequestURI(),
IsPublicLink: true,
PublicLinkToken: tr.PublicLinkToken,
},
},
})
auth, err := workflow.ResolvePublicLinkAuth(r.Context(), r, chi.URLParam(r, "token"), g.gatewaySelector)
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
logger.Debug().Err(err).Msg("could not get thumbnail")
g.handlePublicLinkAuthError(w, r, err, tr.Filename, logger)
return
}
if err := g.workflow.Head(r.Context(), tr, auth, logger); err != nil {
g.handleHeadError(w, r, err, tr, logger)
return
}
w.WriteHeader(http.StatusOK)
}
func (g Webdav) sendThumbnailResponse(rsp *thumbnailssvc.GetThumbnailResponse, w http.ResponseWriter, r *http.Request) {
logger := g.log.SubloggerWithRequestID(r.Context())
client := &http.Client{
// Timeout: time.Second * 5,
}
// aspectIgnoredHeader is set on thumbnail responses when an explicit processor
// overrode the legacy "a" flag with a different behavior, so developers can tell
// their client to send a consistent request (see the thumbnail docs).
const aspectIgnoredHeader = "X-OpenCloud-Thumbnail-Aspect-Ignored"
dlReq, err := http.NewRequest(http.MethodGet, rsp.DataEndpoint, http.NoBody)
if err != nil {
renderError(w, r, errInternalError(err.Error()))
logger.Error().Err(err).Msg("could not create download thumbnail request")
func setAspectIgnoredHeader(w http.ResponseWriter, ignored bool) {
if !ignored {
return
}
dlReq.Header.Set("Transfer-Token", rsp.TransferToken)
dlRsp, err := client.Do(dlReq)
if err != nil {
renderError(w, r, errInternalError(err.Error()))
logger.Error().Err(err).Msg("could not download thumbnail: transport error")
return
}
defer dlRsp.Body.Close()
if dlRsp.StatusCode != http.StatusOK {
logger.Debug().
Str("transfer_token", rsp.GetTransferToken()).
Str("data_endpoint", rsp.GetDataEndpoint()).
Str("response_status", dlRsp.Status).
Msg("could not download thumbnail")
renderError(w, r, newErrResponse(dlRsp.StatusCode, "could not download thumbnail"))
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", dlRsp.Header.Get("Content-Type"))
_, err = io.Copy(w, dlRsp.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to write thumbnail to response writer")
}
w.Header().Set(aspectIgnoredHeader, "1; an explicit processor overrode the legacy 'a' flag, see the thumbnail docs")
}
func extensionToThumbnailType(ext string) thumbnailsmsg.ThumbnailType {
switch strings.ToUpper(ext) {
case "GIF":
return thumbnailsmsg.ThumbnailType_GIF
case "PNG":
return thumbnailsmsg.ThumbnailType_PNG
default:
return thumbnailsmsg.ThumbnailType_JPG
func (g Webdav) handleWorkflowError(w http.ResponseWriter, r *http.Request, err error, tr *requests.ThumbnailRequest, logger log.Logger) {
if errors.Is(err, workflow.ErrFileProcessing) {
addRetryAfterHeader(w)
renderError(w, r, errTooEarly("file is being processed"))
return
}
if errors.Is(err, workflow.ErrImageTooLarge) {
logger.Debug().Err(err).Msg("thumbnail input image too large")
renderError(w, r, errPermissionDenied(workflow.ErrImageTooLarge.Error()))
return
}
if errors.Is(err, workflow.ErrPermissionDenied) {
logger.Debug().Err(err).Msg("user lacks download permission for thumbnail")
renderError(w, r, errPermissionDenied(workflow.ErrPermissionDenied.Error()))
return
}
if errors.Is(err, workflow.ErrNotAFile) {
logger.Debug().Err(err).Msg("thumbnail requested for a non-file resource")
renderError(w, r, errBadRequest("Unsupported file type"))
return
}
if errors.Is(err, workflow.ErrNotFound) {
logger.Debug().Err(err).Msg("thumbnail source could not be located")
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
}
if errors.Is(err, workflow.ErrUnsupportedFileType) {
logger.Debug().Err(err).Msg("thumbnail requested for unsupported file type")
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
}
// Anything else (generator down, download failure, timeout, ...) is a server
// error: clients must not cache it as "no preview" the way they do 404s.
logger.Error().Err(err).Msg("thumbnail workflow failed")
renderError(w, r, errInternalError("could not generate thumbnail"))
}
func (g Webdav) handleHeadError(w http.ResponseWriter, r *http.Request, err error, tr *requests.ThumbnailRequest, logger log.Logger) {
if errors.Is(err, workflow.ErrFileProcessing) {
addRetryAfterHeader(w)
renderError(w, r, errTooEarly("file is being processed"))
return
}
if errors.Is(err, workflow.ErrImageTooLarge) {
logger.Debug().Err(err).Msg("thumbnail input image too large")
renderError(w, r, errPermissionDenied(workflow.ErrImageTooLarge.Error()))
return
}
if errors.Is(err, workflow.ErrPermissionDenied) {
logger.Debug().Err(err).Msg("user lacks download permission for thumbnail")
renderError(w, r, errPermissionDenied(workflow.ErrPermissionDenied.Error()))
return
}
if errors.Is(err, workflow.ErrNotAFile) {
logger.Debug().Err(err).Msg("thumbnail head check requested for a non-file resource")
renderError(w, r, errBadRequest("Unsupported file type"))
return
}
if errors.Is(err, workflow.ErrNotFound) {
logger.Debug().Err(err).Msg("thumbnail source could not be located")
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
}
if errors.Is(err, workflow.ErrUnsupportedFileType) {
logger.Debug().Err(err).Msg("thumbnail head check requested for unsupported file type")
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
}
logger.Error().Err(err).Msg("thumbnail head check failed")
renderError(w, r, errInternalError("could not check thumbnail"))
}
func (g Webdav) handlePublicLinkAuthError(w http.ResponseWriter, r *http.Request, err error, filename string, logger log.Logger) {
if errors.Is(err, workflow.ErrPublicLinkPasswordRequired) {
// A password-protected public link accessed without (or with the wrong)
// password must not reveal that the resource exists, so it is hidden
// behind a 404 rather than a 403.
logger.Debug().Err(err).Msg("public link requires a password")
renderError(w, r, errNotFound(notFoundMsg(filename)))
return
}
if errors.Is(err, workflow.ErrPublicLinkExpired) {
logger.Debug().Err(err).Msg("public link has expired")
renderError(w, r, newErrResponse(http.StatusGone, "public link has expired"))
return
}
logger.Error().Err(err).Msg("could not authenticate public link")
renderError(w, r, errInternalError("could not authenticate public link"))
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
@@ -568,8 +508,9 @@ func errTooEarly(msg string) *errResponse {
return newErrResponse(http.StatusTooEarly, msg)
}
func errTooManyRequests(msg string) *errResponse {
return newErrResponse(http.StatusTooManyRequests, msg)
func addRetryAfterHeader(w http.ResponseWriter) {
after := rand.IntN(14) + 1
w.Header().Set("Retry-After", strconv.Itoa(after))
}
func renderError(w http.ResponseWriter, r *http.Request, err *errResponse) {
@@ -580,8 +521,3 @@ func renderError(w http.ResponseWriter, r *http.Request, err *errResponse) {
func notFoundMsg(name string) string {
return "File with name " + name + " could not be located"
}
func addRetryAfterHeader(w http.ResponseWriter) {
after := rand.IntN(14) + 1
w.Header().Set("Retry-After", strconv.Itoa(after))
}
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more