diff --git a/changelog/unreleased/max-input-image.md b/changelog/unreleased/max-input-image.md new file mode 100644 index 0000000000..595279d9c0 --- /dev/null +++ b/changelog/unreleased/max-input-image.md @@ -0,0 +1,5 @@ +Change: Define maximum input image dimensions when generating previews + +This is a general hardening change to limit processing time and resources of the thumbnailer. + +https://github.com/owncloud/ocis/pull/9035 diff --git a/services/thumbnails/pkg/command/server.go b/services/thumbnails/pkg/command/server.go index 580e70e885..d62095dd28 100644 --- a/services/thumbnails/pkg/command/server.go +++ b/services/thumbnails/pkg/command/server.go @@ -26,10 +26,10 @@ func Server(cfg *config.Config) *cli.Command { Name: "server", Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name), Category: "server", - Before: func(c *cli.Context) error { + Before: func(_ *cli.Context) error { return configlog.ReturnFatal(parser.ParseConfig(cfg)) }, - Action: func(c *cli.Context) error { + Action: func(_ *cli.Context) error { logger := logging.Configure(cfg.Service.Name, cfg.Log) traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name) @@ -49,12 +49,12 @@ func Server(cfg *config.Config) *cli.Command { } return context.WithCancel(cfg.Context) }() - metrics = metrics.New() + m = metrics.New() ) defer cancel() - metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1) + m.BuildInfo.WithLabelValues(version.GetString()).Set(1) service := grpc.NewService( grpc.Logger(logger), @@ -63,7 +63,7 @@ func Server(cfg *config.Config) *cli.Command { grpc.Name(cfg.Service.Name), grpc.Namespace(cfg.GRPC.Namespace), grpc.Address(cfg.GRPC.Addr), - grpc.Metrics(metrics), + grpc.Metrics(m), grpc.TraceProvider(traceProvider), ) @@ -95,7 +95,7 @@ func Server(cfg *config.Config) *cli.Command { http.Logger(logger), http.Context(ctx), http.Config(cfg), - http.Metrics(metrics), + http.Metrics(m), http.Namespace(cfg.HTTP.Namespace), http.TraceProvider(traceProvider), ) diff --git a/services/thumbnails/pkg/command/version.go b/services/thumbnails/pkg/command/version.go index b52381f04a..4d7377b378 100644 --- a/services/thumbnails/pkg/command/version.go +++ b/services/thumbnails/pkg/command/version.go @@ -18,7 +18,7 @@ func Version(cfg *config.Config) *cli.Command { Name: "version", Usage: "print the version of this binary and the running service instances", Category: "info", - Action: func(c *cli.Context) error { + Action: func(_ *cli.Context) error { fmt.Println("Version: " + version.GetString()) fmt.Printf("Compiled: %s\n", version.Compiled()) fmt.Println("") diff --git a/services/thumbnails/pkg/config/config.go b/services/thumbnails/pkg/config/config.go index 85e882a29e..59d2032cf8 100644 --- a/services/thumbnails/pkg/config/config.go +++ b/services/thumbnails/pkg/config/config.go @@ -44,4 +44,6 @@ type Thumbnail struct { FontMapFile string `yaml:"font_map_file" env:"THUMBNAILS_TXT_FONTMAP_FILE" desc:"The path to a font file for txt thumbnails." introductionVersion:"pre5.0"` TransferSecret string `yaml:"transfer_secret" env:"THUMBNAILS_TRANSFER_TOKEN" desc:"The secret to sign JWT to download the actual thumbnail file." introductionVersion:"pre5.0"` DataEndpoint string `yaml:"data_endpoint" env:"THUMBNAILS_DATA_ENDPOINT" desc:"The HTTP endpoint where the actual thumbnail file can be downloaded." introductionVersion:"pre5.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:"6.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:"6.0"` } diff --git a/services/thumbnails/pkg/config/defaults/defaultconfig.go b/services/thumbnails/pkg/config/defaults/defaultconfig.go index 84c1011ef6..a0465a7efc 100644 --- a/services/thumbnails/pkg/config/defaults/defaultconfig.go +++ b/services/thumbnails/pkg/config/defaults/defaultconfig.go @@ -48,6 +48,8 @@ func DefaultConfig() *config.Config { RevaGateway: shared.DefaultRevaConfig().Address, CS3AllowInsecure: false, DataEndpoint: "http://127.0.0.1:9186/thumbnails/data", + MaxInputWidth: 7680, + MaxInputHeight: 4320, }, } } diff --git a/services/thumbnails/pkg/config/parser/parse.go b/services/thumbnails/pkg/config/parser/parse.go index 6334261084..4ec11019dd 100644 --- a/services/thumbnails/pkg/config/parser/parse.go +++ b/services/thumbnails/pkg/config/parser/parse.go @@ -33,6 +33,7 @@ func ParseConfig(cfg *config.Config) error { return Validate(cfg) } -func Validate(cfg *config.Config) error { +// Validate can validate the configuration +func Validate(_ *config.Config) error { return nil } diff --git a/services/thumbnails/pkg/errors/error.go b/services/thumbnails/pkg/errors/error.go new file mode 100644 index 0000000000..e8bbe30889 --- /dev/null +++ b/services/thumbnails/pkg/errors/error.go @@ -0,0 +1,18 @@ +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") + // 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") + // 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") +) diff --git a/services/thumbnails/pkg/preprocessor/fontloader.go b/services/thumbnails/pkg/preprocessor/fontloader.go index 672e62593c..69f979e61b 100644 --- a/services/thumbnails/pkg/preprocessor/fontloader.go +++ b/services/thumbnails/pkg/preprocessor/fontloader.go @@ -25,28 +25,28 @@ type FontMap struct { DefaultFont string `json:"defaultFont"` } -// It contains the location of the loaded file (in FLoc) and the FontMap loaded +// FontMapData contains the location of the loaded file (in FLoc) and the FontMap loaded // from the file type FontMapData struct { FMap *FontMap FLoc string } -// It contains the location of the font used, and the loaded face (font.Face) +// LoadedFace contains the location of the font used, and the loaded face (font.Face) // ready to be used type LoadedFace struct { FontFile string Face font.Face } -// Represents a FontLoader. Use the "NewFontLoader" to get a instance +// FontLoader represents a FontLoader. Use the "NewFontLoader" to get a instance type FontLoader struct { faceCache sync.Cache fontMapData *FontMapData faceOpts *opentype.FaceOptions } -// Create a new FontLoader based on the fontMapFile. The FaceOptions will +// NewFontLoader creates a new FontLoader based on the fontMapFile. The FaceOptions will // be the same for all the font loaded by this instance. // Note that only the fonts described in the fontMapFile will be used. // @@ -92,7 +92,7 @@ func NewFontLoader(fontMapFile string, faceOpts *opentype.FaceOptions) (*FontLoa }, nil } -// Load and return the font face to be used for that script according to the +// LoadFaceForScript loads and returns the font face to be used for that script according to the // FontMap set when the FontLoader was created. If the script doesn't have // an associated font, a default font will be used. Note that the default font // might not be able to handle properly the script @@ -146,14 +146,17 @@ func (fl *FontLoader) LoadFaceForScript(script string) (*LoadedFace, error) { return loadedFace, nil } +// GetFaceOptSize returns face opt size func (fl *FontLoader) GetFaceOptSize() float64 { return fl.faceOpts.Size } +// GetFaceOptDPI returns face opt DPI func (fl *FontLoader) GetFaceOptDPI() float64 { return fl.faceOpts.DPI } +// GetScriptList returns script list func (fl *FontLoader) GetScriptList() []string { fontMap := fl.fontMapData.FMap.FontMap diff --git a/services/thumbnails/pkg/preprocessor/preprocessor.go b/services/thumbnails/pkg/preprocessor/preprocessor.go index d8addd3a70..4254c1a68b 100644 --- a/services/thumbnails/pkg/preprocessor/preprocessor.go +++ b/services/thumbnails/pkg/preprocessor/preprocessor.go @@ -19,6 +19,7 @@ import ( "golang.org/x/image/math/fixed" "github.com/dhowden/tag" + thumbnailerErrors "github.com/owncloud/ocis/v2/services/thumbnails/pkg/errors" ) // FileConverter is the interface for the file converter @@ -98,12 +99,12 @@ func (i AudioDecoder) Convert(r io.Reader) (interface{}, error) { picture := m.Picture() if picture == nil { - return nil, errors.New(`could not extract image from audio file`) + return nil, thumbnailerErrors.ErrNoImageFromAudioFile } converter := ForType(picture.MIMEType, nil) if converter == nil { - return nil, errors.New(`could not find converter for image extraced from audio file`) + return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromAudioFile } return converter.Convert(bytes.NewReader(picture.Data)) @@ -259,7 +260,7 @@ func ForType(mimeType string, opts map[string]interface{}) FileConverter { fontLoader, err := NewFontLoader(fontFileMap, fontFaceOpts) if err != nil { - // if couldn't create the FontLoader with the specified fontFileMap, + // if it couldn't create the FontLoader with the specified fontFileMap, // try to use the default font fontLoader, _ = NewFontLoader("", fontFaceOpts) } diff --git a/services/thumbnails/pkg/server/debug/server.go b/services/thumbnails/pkg/server/debug/server.go index 55ad0121d4..0f912967d8 100644 --- a/services/thumbnails/pkg/server/debug/server.go +++ b/services/thumbnails/pkg/server/debug/server.go @@ -27,8 +27,8 @@ func Server(opts ...Option) (*http.Server, error) { } // health implements the health check. -func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { +func health(_ *config.Config) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) @@ -43,8 +43,8 @@ func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) { } // ready implements the ready check. -func ready(cfg *config.Config) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { +func ready(_ *config.Config) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) diff --git a/services/thumbnails/pkg/server/http/option.go b/services/thumbnails/pkg/server/http/option.go index b77014b159..6bbf4627be 100644 --- a/services/thumbnails/pkg/server/http/option.go +++ b/services/thumbnails/pkg/server/http/option.go @@ -8,6 +8,7 @@ import ( "github.com/owncloud/ocis/v2/services/thumbnails/pkg/metrics" "github.com/urfave/cli/v2" "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" ) // Option defines a single option function. @@ -76,7 +77,7 @@ func TraceProvider(traceProvider trace.TracerProvider) Option { if traceProvider != nil { o.TraceProvider = traceProvider } else { - o.TraceProvider = trace.NewNoopTracerProvider() + o.TraceProvider = noop.NewTracerProvider() } } } diff --git a/services/thumbnails/pkg/service/grpc/v0/decorators/base.go b/services/thumbnails/pkg/service/grpc/v0/decorators/base.go index c43d393dec..5a1f6d3c0d 100644 --- a/services/thumbnails/pkg/service/grpc/v0/decorators/base.go +++ b/services/thumbnails/pkg/service/grpc/v0/decorators/base.go @@ -6,7 +6,7 @@ import ( thumbnailssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/thumbnails/v0" ) -// Interface acting as facade, holding all the interfaces that this +// 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 @@ -17,7 +17,7 @@ type DecoratedService interface { thumbnailssvc.ThumbnailServiceHandler } -// Base type to implement the decorators. It will provide a basic implementation +// 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: @@ -43,7 +43,7 @@ type Decorator struct { next DecoratedService } -// Base implementation for the GetThumbnail (for the thumbnailssvc). +// 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, diff --git a/services/thumbnails/pkg/service/grpc/v0/decorators/logging.go b/services/thumbnails/pkg/service/grpc/v0/decorators/logging.go index e8174d40ee..c998af1b2d 100644 --- a/services/thumbnails/pkg/service/grpc/v0/decorators/logging.go +++ b/services/thumbnails/pkg/service/grpc/v0/decorators/logging.go @@ -34,11 +34,11 @@ func (l logging) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbna Logger() if err != nil { - merror := merrors.FromError(err) - switch merror.Code { + fromError := merrors.FromError(err) + switch fromError.GetCode() { case http.StatusNotFound: logger.Debug(). - Str("error_detail", merror.Detail). + Str("error_detail", fromError.GetDetail()). Msg("no thumbnail found") default: logger.Warn(). diff --git a/services/thumbnails/pkg/service/grpc/v0/decorators/tracing.go b/services/thumbnails/pkg/service/grpc/v0/decorators/tracing.go index f34166e3e6..ba77846c0d 100644 --- a/services/thumbnails/pkg/service/grpc/v0/decorators/tracing.go +++ b/services/thumbnails/pkg/service/grpc/v0/decorators/tracing.go @@ -35,10 +35,10 @@ func (t tracing) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumbna defer span.End() span.SetAttributes( - attribute.KeyValue{Key: "filepath", Value: attribute.StringValue(req.Filepath)}, - attribute.KeyValue{Key: "thumbnail_type", Value: attribute.StringValue(req.ThumbnailType.String())}, - attribute.KeyValue{Key: "width", Value: attribute.IntValue(int(req.Width))}, - attribute.KeyValue{Key: "height", Value: attribute.IntValue(int(req.Height))}, + 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()))}, ) } diff --git a/services/thumbnails/pkg/service/grpc/v0/option.go b/services/thumbnails/pkg/service/grpc/v0/option.go index 00cf0eeab3..07c1556e41 100644 --- a/services/thumbnails/pkg/service/grpc/v0/option.go +++ b/services/thumbnails/pkg/service/grpc/v0/option.go @@ -50,13 +50,6 @@ func Config(val *config.Config) Option { } } -// Middleware provides a function to set the middleware option. -func Middleware(val ...func(http.Handler) http.Handler) Option { - return func(o *Options) { - o.Middleware = val - } -} - // ThumbnailStorage provides a function to set the thumbnail storage option. func ThumbnailStorage(val storage.Storage) Option { return func(o *Options) { diff --git a/services/thumbnails/pkg/service/grpc/v0/service.go b/services/thumbnails/pkg/service/grpc/v0/service.go index 156b46e0ef..5982b1ca40 100644 --- a/services/thumbnails/pkg/service/grpc/v0/service.go +++ b/services/thumbnails/pkg/service/grpc/v0/service.go @@ -43,6 +43,8 @@ func NewService(opts ...Option) decorators.DecoratedService { resolutions, options.ThumbnailStorage, logger, + options.Config.Thumbnail.MaxInputWidth, + options.Config.Thumbnail.MaxInputHeight, ), webdavSource: options.ImageSource, cs3Source: options.CS3Source, @@ -116,7 +118,7 @@ func (g Thumbnail) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumb func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest) (string, error) { src := req.GetCs3Source() - sRes, err := g.stat(src.Path, src.Authorization) + sRes, err := g.stat(src.GetPath(), src.GetAuthorization()) if err != nil { return "", err } @@ -125,7 +127,7 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetTh if tType == "" { tType = req.GetThumbnailType().String() } - tr, err := thumbnail.PrepareRequest(int(req.Width), int(req.Height), tType, sRes.GetInfo().GetChecksum().GetSum(), req.Processor) + tr, err := thumbnail.PrepareRequest(int(req.GetWidth()), int(req.GetHeight()), tType, sRes.GetInfo().GetChecksum().GetSum(), req.GetProcessor()) if err != nil { return "", merrors.BadRequest(g.serviceID, err.Error()) } @@ -134,12 +136,12 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetTh return key, nil } - ctx = imgsource.ContextSetAuthorization(ctx, src.Authorization) - r, err := g.cs3Source.Get(ctx, src.Path) + ctx = imgsource.ContextSetAuthorization(ctx, src.GetAuthorization()) + r, err := g.cs3Source.Get(ctx, src.GetPath()) if err != nil { return "", merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error()) } - defer r.Close() // nolint:errcheck + defer r.Close() ppOpts := map[string]interface{}{ "fontFileMap": g.preprocessorOpts.TxtFontFileMap, } @@ -158,13 +160,13 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetTh func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.GetThumbnailRequest) (string, error) { src := req.GetWebdavSource() - imgURL, err := url.Parse(src.Url) + imgURL, err := url.Parse(src.GetUrl()) if err != nil { return "", errors.Wrap(err, "source url is invalid") } var auth, statPath string - if src.IsPublicLink { + if src.GetIsPublicLink() { q := imgURL.Query() var rsp *gateway.AuthenticateResponse client, err := g.selector.Next() @@ -177,13 +179,13 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge exp := q.Get("expiration") rsp, err = client.Authenticate(ctx, &gateway.AuthenticateRequest{ Type: "publicshares", - ClientId: src.PublicLinkToken, + ClientId: src.GetPublicLinkToken(), ClientSecret: strings.Join([]string{"signature", sig, exp}, "|"), }) } else { rsp, err = client.Authenticate(ctx, &gateway.AuthenticateRequest{ Type: "publicshares", - ClientId: src.PublicLinkToken, + ClientId: src.GetPublicLinkToken(), // We pass an empty password because we expect non pre-signed public links // to not be password protected ClientSecret: "password|", @@ -193,11 +195,11 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge if err != nil { return "", merrors.InternalServerError(g.serviceID, "could not authenticate: %s", err.Error()) } - auth = rsp.Token - statPath = path.Join("/public", src.PublicLinkToken, req.Filepath) + auth = rsp.GetToken() + statPath = path.Join("/public", src.GetPublicLinkToken(), req.GetFilepath()) } else { - auth = src.RevaAuthorization - statPath = req.Filepath + auth = src.GetRevaAuthorization() + statPath = req.GetFilepath() } sRes, err := g.stat(statPath, auth) if err != nil { @@ -208,7 +210,7 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge if tType == "" { tType = req.GetThumbnailType().String() } - tr, err := thumbnail.PrepareRequest(int(req.Width), int(req.Height), tType, sRes.GetInfo().GetChecksum().GetSum(), req.Processor) + tr, err := thumbnail.PrepareRequest(int(req.GetWidth()), int(req.GetHeight()), tType, sRes.GetInfo().GetChecksum().GetSum(), req.GetProcessor()) if err != nil { return "", merrors.BadRequest(g.serviceID, err.Error()) } @@ -217,8 +219,8 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge return key, nil } - if src.WebdavAuthorization != "" { - ctx = imgsource.ContextSetAuthorization(ctx, src.WebdavAuthorization) + if src.GetWebdavAuthorization() != "" { + ctx = imgsource.ContextSetAuthorization(ctx, src.GetWebdavAuthorization()) } // add signature and expiration to webdav url @@ -232,7 +234,7 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge if err != nil { return "", merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error()) } - defer r.Close() // nolint:errcheck + defer r.Close() ppOpts := map[string]interface{}{ "fontFileMap": g.preprocessorOpts.TxtFontFileMap, } @@ -272,16 +274,16 @@ func (g Thumbnail) stat(path, auth string) (*provider.StatResponse, error) { return nil, merrors.InternalServerError(g.serviceID, "could not stat file: %s", err.Error()) } - if rsp.Status.Code != rpc.Code_CODE_OK { - switch rsp.Status.Code { + 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.Status.Message) + return nil, merrors.NotFound(g.serviceID, "could not stat file: %s", rsp.GetStatus().GetMessage()) default: - g.logger.Error().Str("status_message", rsp.Status.Message).Str("path", path).Msg("could not stat file") - return nil, merrors.InternalServerError(g.serviceID, "could not stat file: %s", rsp.Status.Message) + 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.Info.Type != provider.ResourceType_RESOURCE_TYPE_FILE { + 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" { @@ -292,11 +294,11 @@ func (g Thumbnail) stat(path, auth string) (*provider.StatResponse, error) { Status: http.StatusText(http.StatusTooEarly), } } - if rsp.Info.GetChecksum().GetSum() == "" { + 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.Info.MimeType) { + if !thumbnail.IsMimeTypeSupported(rsp.GetInfo().GetMimeType()) { return nil, merrors.NotFound(g.serviceID, "Unsupported file type") } return rsp, nil diff --git a/services/thumbnails/pkg/service/http/v0/option.go b/services/thumbnails/pkg/service/http/v0/option.go index aa630843ec..d7ba389e3e 100644 --- a/services/thumbnails/pkg/service/http/v0/option.go +++ b/services/thumbnails/pkg/service/http/v0/option.go @@ -59,14 +59,3 @@ func ThumbnailStorage(storage storage.Storage) Option { o.ThumbnailStorage = storage } } - -// TraceProvider provides a function to configure the trace provider -func TraceProvider(traceProvider trace.TracerProvider) Option { - return func(o *Options) { - if traceProvider != nil { - o.TraceProvider = traceProvider - } else { - o.TraceProvider = trace.NewNoopTracerProvider() - } - } -} diff --git a/services/thumbnails/pkg/service/http/v0/service.go b/services/thumbnails/pkg/service/http/v0/service.go index 8e36a3727d..7207507a7e 100644 --- a/services/thumbnails/pkg/service/http/v0/service.go +++ b/services/thumbnails/pkg/service/http/v0/service.go @@ -25,8 +25,8 @@ const ( // Service defines the service handlers. type Service interface { - ServeHTTP(http.ResponseWriter, *http.Request) - GetThumbnail(http.ResponseWriter, *http.Request) + ServeHTTP(w http.ResponseWriter, r *http.Request) + GetThumbnail(w http.ResponseWriter, r *http.Request) } // NewService returns a service implementation for Service. @@ -58,6 +58,8 @@ func NewService(opts ...Option) Service { resolutions, options.ThumbnailStorage, logger, + options.Config.Thumbnail.MaxInputWidth, + options.Config.Thumbnail.MaxInputHeight, ), } @@ -66,7 +68,7 @@ func NewService(opts ...Option) Service { r.Get("/data", svc.GetThumbnail) }) - _ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + _ = 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") return nil }) @@ -92,7 +94,7 @@ func (s Thumbnails) GetThumbnail(w http.ResponseWriter, r *http.Request) { logger := s.logger.SubloggerWithRequestID(r.Context()) key := r.Context().Value(keyContextKey).(string) - thumbnail, err := s.manager.GetThumbnail(key) + thumbnailBytes, err := s.manager.GetThumbnail(key) if err != nil { logger.Debug(). Err(err). @@ -103,8 +105,8 @@ func (s Thumbnails) GetThumbnail(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Length", strconv.Itoa(len(thumbnail))) - if _, err = w.Write(thumbnail); err != nil { + w.Header().Set("Content-Length", strconv.Itoa(len(thumbnailBytes))) + if _, err = w.Write(thumbnailBytes); err != nil { logger.Error(). Err(err). Str("key", key). @@ -112,6 +114,7 @@ func (s Thumbnails) GetThumbnail(w http.ResponseWriter, r *http.Request) { } } +// TransferTokenValidator validates a transfer token func (s Thumbnails) TransferTokenValidator(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logger := s.logger.SubloggerWithRequestID(r.Context()) diff --git a/services/thumbnails/pkg/service/jwt/jwt.go b/services/thumbnails/pkg/service/jwt/jwt.go index bbe46bb94d..86e1ff02cc 100644 --- a/services/thumbnails/pkg/service/jwt/jwt.go +++ b/services/thumbnails/pkg/service/jwt/jwt.go @@ -2,6 +2,7 @@ package jwt import "github.com/golang-jwt/jwt/v4" +// ThumbnailClaims defines the claims for thumb-nailing type ThumbnailClaims struct { jwt.RegisteredClaims Key string `json:"key"` diff --git a/services/thumbnails/pkg/thumbnail/encoding.go b/services/thumbnails/pkg/thumbnail/encoding.go index 65b5b17373..2cf22ba360 100644 --- a/services/thumbnails/pkg/thumbnail/encoding.go +++ b/services/thumbnails/pkg/thumbnail/encoding.go @@ -1,13 +1,14 @@ package thumbnail import ( - "errors" "image" "image/gif" "image/jpeg" "image/png" "io" "strings" + + "github.com/owncloud/ocis/v2/services/thumbnails/pkg/errors" ) const ( @@ -18,17 +19,10 @@ const ( typeGgs = "ggs" ) -var ( - // ErrInvalidType represents the error when a type can't be encoded. - ErrInvalidType = errors.New("can't encode this type") - // ErrNoEncoderForType represents the error when an encoder couldn't be found for a type. - ErrNoEncoderForType = errors.New("no encoder for this type found") -) - // Encoder encodes the thumbnail to a specific format. type Encoder interface { // Encode encodes the image to a format. - Encode(io.Writer, interface{}) error + Encode(w io.Writer, img interface{}) error // Types returns the formats suffixes. Types() []string // MimeType returns the mimetype used by the encoder. @@ -42,7 +36,7 @@ type PngEncoder struct{} func (e PngEncoder) Encode(w io.Writer, img interface{}) error { m, ok := img.(image.Image) if !ok { - return ErrInvalidType + return errors.ErrInvalidType } return png.Encode(w, m) } @@ -57,14 +51,14 @@ func (e PngEncoder) MimeType() string { return "image/png" } -// JpegEncoder encodes to jpg. +// JpegEncoder encodes to jpg type JpegEncoder struct{} // Encode encodes to jpg func (e JpegEncoder) Encode(w io.Writer, img interface{}) error { m, ok := img.(image.Image) if !ok { - return ErrInvalidType + return errors.ErrInvalidType } return jpeg.Encode(w, m, nil) } @@ -79,17 +73,19 @@ func (e JpegEncoder) MimeType() string { return "image/jpeg" } +// GifEncoder encodes to gif type GifEncoder struct{} // Encode encodes the image to a gif format func (e GifEncoder) Encode(w io.Writer, img interface{}) error { g, ok := img.(*gif.GIF) if !ok { - return ErrInvalidType + return errors.ErrInvalidType } return gif.EncodeAll(w, g) } +// Types returns the supported types of the GifEncoder func (e GifEncoder) Types() []string { return []string{typeGif} } @@ -110,7 +106,7 @@ func EncoderForType(fileType string) (Encoder, error) { case typeGif: return GifEncoder{}, nil default: - return nil, ErrNoEncoderForType + return nil, errors.ErrNoEncoderForType } } diff --git a/services/thumbnails/pkg/thumbnail/generator.go b/services/thumbnails/pkg/thumbnail/generator.go index 80796ad7ce..177aad17a4 100644 --- a/services/thumbnails/pkg/thumbnail/generator.go +++ b/services/thumbnails/pkg/thumbnail/generator.go @@ -8,11 +8,12 @@ import ( "strings" "github.com/kovidgoyal/imaging" + "github.com/owncloud/ocis/v2/services/thumbnails/pkg/errors" ) // Generator generates a web friendly file version. type Generator interface { - Generate(image.Rectangle, interface{}, Processor) (interface{}, error) + Generate(size image.Rectangle, img interface{}, processor Processor) (interface{}, error) } // SimpleGenerator is the default image generator and is used for all image types expect gif. @@ -22,7 +23,7 @@ type SimpleGenerator struct{} func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}, processor Processor) (interface{}, error) { m, ok := img.(image.Image) if !ok { - return nil, ErrInvalidType + return nil, errors.ErrInvalidType } return processor.Process(m, size.Dx(), size.Dy(), imaging.Lanczos), nil @@ -37,7 +38,7 @@ func (g GifGenerator) Generate(size image.Rectangle, img interface{}, processor m, ok := img.(*gif.GIF) if !ok { - return nil, ErrInvalidType + return nil, errors.ErrInvalidType } // Create a new RGBA image to hold the incremental frames. srcX, srcY := m.Config.Width, m.Config.Height @@ -80,6 +81,6 @@ func GeneratorForType(fileType string) (Generator, error) { case typeGif: return GifGenerator{}, nil default: - return nil, ErrNoEncoderForType + return nil, errors.ErrNoEncoderForType } } diff --git a/services/thumbnails/pkg/thumbnail/imgsource/cs3.go b/services/thumbnails/pkg/thumbnail/imgsource/cs3.go index d17648e5f3..082ed60b0c 100644 --- a/services/thumbnails/pkg/thumbnail/imgsource/cs3.go +++ b/services/thumbnails/pkg/thumbnail/imgsource/cs3.go @@ -15,13 +15,13 @@ import ( "github.com/cs3org/reva/v2/pkg/rhttp" "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/owncloud/ocis/v2/services/thumbnails/pkg/config" - "github.com/pkg/errors" + "github.com/owncloud/ocis/v2/services/thumbnails/pkg/errors" "google.golang.org/grpc/metadata" ) const ( - // "github.com/cs3org/reva/v2/internal/http/services/datagateway" is internal so we redeclare it here // TokenTransportHeader holds the header key for the reva transfer token + // "github.com/cs3org/reva/v2/internal/http/services/datagateway" is internal so we redeclare it here TokenTransportHeader = "X-Reva-Transfer" ) @@ -44,7 +44,7 @@ func NewCS3Source(cfg config.Thumbnail, gatewaySelector pool.Selectable[gateway. func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) { auth, ok := ContextGetAuthorization(ctx) if !ok { - return nil, errors.New("cs3source: authorization missing") + return nil, errors.ErrCS3AuthorizationMissing } ref, err := storagespace.ParseReference(path) if err != nil { @@ -66,18 +66,18 @@ func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) { return nil, err } - if rsp.Status.Code != rpc.Code_CODE_OK { - return nil, fmt.Errorf("could not load image: %s", rsp.Status.Message) + 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.Protocols { - if p.Protocol == "spaces" { - ep, tk = p.DownloadEndpoint, p.Token + for _, p := range rsp.GetProtocols() { + if p.GetProtocol() == "spaces" { + ep, tk = p.GetDownloadEndpoint(), p.GetToken() break } } - if (ep == "" || tk == "") && len(rsp.Protocols) > 0 { - ep, tk = rsp.Protocols[0].DownloadEndpoint, rsp.Protocols[0].Token + 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) @@ -93,7 +93,7 @@ func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) { } client := &http.Client{} - resp, err := client.Do(httpReq) // nolint:bodyclose + resp, err := client.Do(httpReq) if err != nil { return nil, err } diff --git a/services/thumbnails/pkg/thumbnail/imgsource/webdav.go b/services/thumbnails/pkg/thumbnail/imgsource/webdav.go index 6e02bc0465..9dda9dd8a1 100644 --- a/services/thumbnails/pkg/thumbnail/imgsource/webdav.go +++ b/services/thumbnails/pkg/thumbnail/imgsource/webdav.go @@ -44,7 +44,7 @@ func (s WebDav) Get(ctx context.Context, url string) (io.ReadCloser, error) { } client := &http.Client{} - resp, err := client.Do(req) // nolint:bodyclose + resp, err := client.Do(req) if err != nil { return nil, errors.Wrapf(err, `could not get the image "%s"`, url) } diff --git a/services/thumbnails/pkg/thumbnail/storage/filesystem.go b/services/thumbnails/pkg/thumbnail/storage/filesystem.go index dd18e88f57..0188fc8b43 100644 --- a/services/thumbnails/pkg/thumbnail/storage/filesystem.go +++ b/services/thumbnails/pkg/thumbnail/storage/filesystem.go @@ -31,6 +31,7 @@ type FileSystem struct { 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 { @@ -39,6 +40,7 @@ func (s FileSystem) Stat(key string) bool { 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) @@ -51,6 +53,7 @@ func (s FileSystem) Get(key string) ([]byte, error) { 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) diff --git a/services/thumbnails/pkg/thumbnail/storage/inmemory.go b/services/thumbnails/pkg/thumbnail/storage/inmemory.go deleted file mode 100644 index 1768318518..0000000000 --- a/services/thumbnails/pkg/thumbnail/storage/inmemory.go +++ /dev/null @@ -1,50 +0,0 @@ -package storage - -import ( - "strings" -) - -// NewInMemoryStorage creates a new InMemory instance. -func NewInMemoryStorage() InMemory { - return InMemory{ - store: make(map[string][]byte), - } -} - -// InMemory represents an in memory storage for thumbnails -// Can be used during development -type InMemory struct { - store map[string][]byte -} - -func (s InMemory) Stat(key string) bool { - _, exists := s.store[key] - return exists -} - -// Get loads the thumbnail from memory. -func (s InMemory) Get(key string) ([]byte, error) { - return s.store[key], nil -} - -// Set stores the thumbnail in memory. -func (s InMemory) Put(key string, thumbnail []byte) error { - s.store[key] = thumbnail - return nil -} - -// BuildKey generates a unique key to store and retrieve the thumbnail. -func (s InMemory) BuildKey(r Request) string { - parts := []string{ - r.Checksum, - r.Resolution.String(), - } - - if r.Characteristic != "" { - parts = append(parts, r.Characteristic) - } - - parts = append(parts, strings.Join(r.Types, ",")) - - return strings.Join(parts, "+") -} diff --git a/services/thumbnails/pkg/thumbnail/storage/inmemory_test.go b/services/thumbnails/pkg/thumbnail/storage/inmemory_test.go deleted file mode 100644 index 9f33a6ae49..0000000000 --- a/services/thumbnails/pkg/thumbnail/storage/inmemory_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package storage_test - -import ( - "image" - "testing" - - tAssert "github.com/stretchr/testify/assert" - - "github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail/storage" -) - -func TestInMemory_BuildKey(t *testing.T) { - tests := []struct { - r storage.Request - want string - }{ - { - r: storage.Request{ - Checksum: "cs", - Types: []string{"png", "jpg"}, - Resolution: image.Rectangle{ - Min: image.Point{ - X: 1, - Y: 2, - }, - Max: image.Point{ - X: 3, - Y: 4, - }, - }, - Characteristic: "", - }, - want: "cs+(1,2)-(3,4)+png,jpg", - }, - { - r: storage.Request{ - Checksum: "cs", - Types: []string{"png", "jpg"}, - Resolution: image.Rectangle{ - Min: image.Point{ - X: 1, - Y: 2, - }, - Max: image.Point{ - X: 3, - Y: 4, - }, - }, - Characteristic: "fill", - }, - want: "cs+(1,2)-(3,4)+fill+png,jpg", - }, - } - - s := storage.InMemory{} - assert := tAssert.New(t) - - for _, tt := range tests { - tt := tt - t.Run("", func(t *testing.T) { - assert.Equal(s.BuildKey(tt.r), tt.want) - }) - } - -} diff --git a/services/thumbnails/pkg/thumbnail/storage/storage.go b/services/thumbnails/pkg/thumbnail/storage/storage.go index 591b05bdc1..143ab69bc4 100644 --- a/services/thumbnails/pkg/thumbnail/storage/storage.go +++ b/services/thumbnails/pkg/thumbnail/storage/storage.go @@ -16,7 +16,7 @@ type Request struct { // The resolution of the thumbnail Resolution image.Rectangle // Characteristic defines the different image characteristics, - // for example, if its scaled up to fit in the bounding box or not, + // 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. @@ -25,8 +25,8 @@ type Request struct { // Storage defines the interface for a thumbnail store. type Storage interface { - Stat(string) bool - Get(string) ([]byte, error) - Put(string, []byte) error - BuildKey(Request) string + Stat(key string) bool + Get(key string) ([]byte, error) + Put(key string, img []byte) error + BuildKey(r Request) string } diff --git a/services/thumbnails/pkg/thumbnail/thumbnail.go b/services/thumbnails/pkg/thumbnail/thumbnail.go index 2871f1b8fc..48ffab36bd 100644 --- a/services/thumbnails/pkg/thumbnail/thumbnail.go +++ b/services/thumbnails/pkg/thumbnail/thumbnail.go @@ -7,11 +7,12 @@ import ( "mime" "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/thumbnails/pkg/errors" "github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail/storage" ) var ( - // SupportedMimeTypes contains a all mimetypes which are supported by the thumbnailer. + // SupportedMimeTypes contains an all mimetypes which are supported by the thumbnailer. SupportedMimeTypes = map[string]struct{}{ "image/png": {}, "image/jpg": {}, @@ -28,7 +29,7 @@ var ( } ) -// Request bundles information needed to generate a thumbnail for afile +// Request bundles information needed to generate a thumbnail for a file type Request struct { Resolution image.Rectangle Encoder Encoder @@ -41,37 +42,48 @@ type Request struct { type Manager interface { // Generate creates a thumbnail and stores it. // The function returns a key with which the actual file can be retrieved. - Generate(Request, interface{}) (string, error) + Generate(r Request, img interface{}) (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(Request) (string, bool) + 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) SimpleManager { +func NewSimpleManager(resolutions Resolutions, storage storage.Storage, logger log.Logger, maxInputWidth, maxInputHeight int) SimpleManager { return SimpleManager{ - storage: storage, - logger: logger, - resolutions: resolutions, + 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 + 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 interface{}) (string, error) { var match image.Rectangle + var inputDimensions image.Rectangle switch m := img.(type) { case *gif.GIF: match = s.resolutions.ClosestMatch(r.Resolution, m.Image[0].Bounds()) + inputDimensions = m.Image[0].Bounds() case image.Image: match = s.resolutions.ClosestMatch(r.Resolution, m.Bounds()) + inputDimensions = m.Bounds() + } + + // 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, r.Processor) @@ -92,11 +104,13 @@ func (s SimpleManager) Generate(r Request, img interface{}) (string, error) { 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) } diff --git a/services/thumbnails/pkg/thumbnail/thumbnail_test.go b/services/thumbnails/pkg/thumbnail/thumbnail_test.go index fc4c979642..2a046e15f5 100644 --- a/services/thumbnails/pkg/thumbnail/thumbnail_test.go +++ b/services/thumbnails/pkg/thumbnail/thumbnail_test.go @@ -1,8 +1,12 @@ package thumbnail import ( + "github.com/owncloud/ocis/v2/services/thumbnails/pkg/errors" + "github.com/owncloud/ocis/v2/services/thumbnails/pkg/preprocessor" + "github.com/stretchr/testify/assert" "image" "os" + "path" "path/filepath" "testing" @@ -17,11 +21,11 @@ type NoOpManager struct { storage.Storage } -func (m NoOpManager) BuildKey(r storage.Request) string { +func (m NoOpManager) BuildKey(_ storage.Request) string { return "" } -func (m NoOpManager) Set(username, key string, thumbnail []byte) error { +func (m NoOpManager) Set(_, _ string, _ []byte) error { return nil } @@ -31,6 +35,8 @@ func BenchmarkGet(b *testing.B) { Resolutions{}, NoOpManager{}, log.NewLogger(), + 6016, + 4000, ) res, _ := ParseResolution("32x32") @@ -126,10 +132,59 @@ func TestPrepareRequest(t *testing.T) { return } - // func's are not reflactable, ignore + // funcs are not reflactable, ignore if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreFields(Request{}, "Processor")); diff != "" { t.Errorf("PrepareRequest(): %v", diff) } }) } } + +func TestPreviewGenerationTooBigImage(t *testing.T) { + tests := []struct { + name string + fileName string + mimeType string + }{ + {name: "png", mimeType: "image/png", fileName: "../../testdata/oc.png"}, + {name: "gif", mimeType: "image/gif", fileName: "../../testdata/oc.gif"}, + {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) + generate, err := sut.Generate(req, convert) + if err != nil { + return + } + assert.ErrorIs(t, err, errors.ErrImageTooLarge) + assert.Equal(t, "", generate) + }) + } +} diff --git a/services/thumbnails/testdata/oc.gif b/services/thumbnails/testdata/oc.gif new file mode 100644 index 0000000000..e4f4e9e28b Binary files /dev/null and b/services/thumbnails/testdata/oc.gif differ diff --git a/services/thumbnails/testdata/test.ggs b/services/thumbnails/testdata/test.ggs new file mode 100644 index 0000000000..078ba5c4da Binary files /dev/null and b/services/thumbnails/testdata/test.ggs differ