diff --git a/services/frontend/pkg/config/config.go b/services/frontend/pkg/config/config.go index e2bfb6a67b..6ff194bde0 100644 --- a/services/frontend/pkg/config/config.go +++ b/services/frontend/pkg/config/config.go @@ -158,6 +158,8 @@ type OCDav struct { // PublicURL used to redirect /s/{token} URLs to PublicURL string `yaml:"public_url" env:"OC_URL;OCDAV_PUBLIC_URL;FRONTENT_OCDAV_PUBLIC_URL" desc:"URL where OpenCloud is reachable for users." introductionVersion:"1.0.0"` + ThumbnailSourceTypes []string `yaml:"thumbnail_source_types" env:"OC_THUMBNAIL_SOURCE_TYPES;FRONTEND_THUMBNAIL_SOURCE_TYPES" desc:"Additional mime types oc:has-preview is answered with 1 for, the same list the webdav service attempts previews for. The types the build ships are included; this only adds to them." introductionVersion:"%%NEXT%%"` + // Insecure certificates allowed when making requests to the gateway Insecure bool `yaml:"insecure" env:"OC_INSECURE;OCDAV_INSECURE;FRONTENT_OCDAV_INSECURE" desc:"Allow insecure connections to the GATEWAY service." introductionVersion:"1.0.0"` EnableHTTPTPC bool `yaml:"enable_http_tpc" env:"OCDAV_ENABLE_HTTP_TPC;FRONTENT_OCDAV_ENABLE_HTTP_TPC" desc:"Enable HTTP / WebDAV Third-Party-Copy support." introductionVersion:"6.0.0"` diff --git a/services/frontend/pkg/revaconfig/config.go b/services/frontend/pkg/revaconfig/config.go index afa7018f8b..611a269326 100644 --- a/services/frontend/pkg/revaconfig/config.go +++ b/services/frontend/pkg/revaconfig/config.go @@ -15,6 +15,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/version" "github.com/opencloud-eu/opencloud/services/frontend/pkg/config" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) // FrontendConfigFromStruct will adapt an OpenCloud config struct into a reva mapstructure to start a reva service. @@ -368,6 +369,9 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string "insecure": cfg.OCDav.Insecure, "enable_http_tpc": cfg.OCDav.EnableHTTPTPC, "public_url": cfg.OCDav.PublicURL, + // what oc:has-preview is answered from, the same set the + // webdav service attempts previews for + "preview_mime_types": thumbnail.NewSourceTypes(cfg.OCDav.ThumbnailSourceTypes).List(), // still not supported //"favorite_storage_driver": unused, //"favorite_storage_drivers": unused, diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go index a1d0b16629..2c53f7320e 100644 --- a/services/graph/pkg/config/config.go +++ b/services/graph/pkg/config/config.go @@ -26,13 +26,15 @@ type Config struct { TokenManager *TokenManager `yaml:"token_manager"` GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` - Application Application `yaml:"application"` - Spaces Spaces `yaml:"spaces"` - Identity Identity `yaml:"identity"` - IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"` - Events Events `yaml:"events"` - UnifiedRoles UnifiedRoles `yaml:"unified_roles"` - MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"` + Application Application `yaml:"application"` + Spaces Spaces `yaml:"spaces"` + Identity Identity `yaml:"identity"` + IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"` + Events Events `yaml:"events"` + UnifiedRoles UnifiedRoles `yaml:"unified_roles"` + ThumbnailSourceTypes []string `yaml:"thumbnail_source_types" env:"OC_THUMBNAIL_SOURCE_TYPES;GRAPH_THUMBNAIL_SOURCE_TYPES" desc:"Additional mime types a preview is announced for, the same list the webdav service attempts previews for. The types the build ships are included; this only adds to them." introductionVersion:"%%NEXT%%"` + + MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"` Keycloak Keycloak `yaml:"keycloak"` ServiceAccount ServiceAccount `yaml:"service_account"` diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 167f8b1b62..88ba754796 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -351,7 +351,7 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { } if driveItemRelationExpanded(r, _expandThumbnails) { - setDriveItemThumbnails(driveItem, res.GetInfo(), g.config.Commons.OpenCloudURL) + setDriveItemThumbnails(driveItem, res.GetInfo(), g.config.Commons.OpenCloudURL, g.sourceTypes) } render.Status(r, http.StatusOK) diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 913d2ab087..097715a472 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -27,6 +27,7 @@ import ( "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" "github.com/opencloud-eu/opencloud/services/graph/pkg/identity" "github.com/opencloud-eu/opencloud/services/graph/pkg/metrics" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) // Permissions is the interface used to access the permissions service @@ -56,6 +57,7 @@ type RoleService interface { // Graph defines implements the business logic for Service. type Graph struct { BaseGraphService + sourceTypes thumbnail.SourceTypes mux *chi.Mux identityBackend identity.Backend identityEducationBackend identity.EducationBackend diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 347fe8ce17..03c764c5e2 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -22,6 +22,7 @@ import ( settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0" graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" "github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) const ( @@ -183,6 +184,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx svc := Graph{ BaseGraphService: baseGraphService, + sourceTypes: thumbnail.NewSourceTypes(options.Config.ThumbnailSourceTypes), mux: m, specialDriveItemsCache: spacePropertiesCache, eventsPublisher: options.EventsPublisher, diff --git a/services/graph/pkg/service/v0/sharedbyme.go b/services/graph/pkg/service/v0/sharedbyme.go index cc61df8b75..011bf94d80 100644 --- a/services/graph/pkg/service/v0/sharedbyme.go +++ b/services/graph/pkg/service/v0/sharedbyme.go @@ -42,7 +42,7 @@ func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) { expandThumbnails := strings.Contains(expand, "thumbnails") if expandThumbnails { for k, item := range driveItems { - setShareThumbnails(&item, item.GetId(), g.config.Commons.OpenCloudURL) + setShareThumbnails(&item, item.GetId(), g.config.Commons.OpenCloudURL, g.sourceTypes) driveItems[k] = item } } diff --git a/services/graph/pkg/service/v0/sharedwithme.go b/services/graph/pkg/service/v0/sharedwithme.go index 87aa96a887..6f16195db1 100644 --- a/services/graph/pkg/service/v0/sharedwithme.go +++ b/services/graph/pkg/service/v0/sharedwithme.go @@ -71,7 +71,7 @@ func (g Graph) listSharedWithMe(ctx context.Context, expandThumbnails bool) ([]l if expandThumbnails { for k, item := range driveItems { - setShareThumbnails(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL) + setShareThumbnails(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL, g.sourceTypes) driveItems[k] = item } } diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 0fc998d6b0..d5d877e740 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -23,20 +23,20 @@ func (g Graph) setDriveItemsThumbnails(r *http.Request, items []libregraph.Drive } for i := range items { if i < len(infos) { - setDriveItemThumbnails(&items[i], infos[i], g.config.Commons.OpenCloudURL) + setDriveItemThumbnails(&items[i], infos[i], g.config.Commons.OpenCloudURL, g.sourceTypes) } } } -func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceInfo, baseURL string) { - if set := previewThumbnailSet(res, baseURL); set != nil { +func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceInfo, baseURL string, sourceTypes thumbnail.SourceTypes) { + if set := previewThumbnailSet(res, baseURL, sourceTypes); set != nil { item.SetThumbnails([]libregraph.ThumbnailSet{*set}) } } // previewThumbnailSet returns nil when the thumbnailer cannot render the resource. -func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph.ThumbnailSet { - if !thumbnail.IsMimeTypeSupported(res.GetMimeType()) { +func previewThumbnailSet(res *provider.ResourceInfo, baseURL string, sourceTypes thumbnail.SourceTypes) *libregraph.ThumbnailSet { + if !sourceTypes.Supports(res.GetMimeType()) { return nil } return thumbnailSetFor(baseURL, storagespace.FormatResourceID(res.GetId())) @@ -59,9 +59,9 @@ func previewThumbnail(base string, box int32) *libregraph.Thumbnail { // setShareThumbnails works off the driveItem, the share listings have no resource // info. The id comes separately, a received share carries it on its remote item. -func setShareThumbnails(item *libregraph.DriveItem, itemID, baseURL string) { +func setShareThumbnails(item *libregraph.DriveItem, itemID, baseURL string, sourceTypes thumbnail.SourceTypes) { mimeType := item.GetFile().MimeType - if itemID == "" || mimeType == nil || !thumbnail.IsMimeTypeSupported(*mimeType) { + if itemID == "" || mimeType == nil || !sourceTypes.Supports(*mimeType) { return } item.SetThumbnails([]libregraph.ThumbnailSet{*thumbnailSetFor(baseURL, itemID)}) diff --git a/services/webdav/pkg/config/config.go b/services/webdav/pkg/config/config.go index 3cc9f8d242..f151b305be 100644 --- a/services/webdav/pkg/config/config.go +++ b/services/webdav/pkg/config/config.go @@ -39,6 +39,8 @@ type Config struct { 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:"%%NEXT%%"` ThumbnailCacheS3AccessKey string `yaml:"thumbnail_cache_s3_access_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_ACCESS_KEY" desc:"S3 access key for thumbnail cache authentication." introductionVersion:"%%NEXT%%"` ThumbnailCacheS3SecretKey string `yaml:"thumbnail_cache_s3_secret_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_SECRET_KEY" desc:"S3 secret key for thumbnail cache authentication." introductionVersion:"%%NEXT%%"` + ThumbnailSourceTypes []string `yaml:"thumbnail_source_types" env:"OC_THUMBNAIL_SOURCE_TYPES;WEBDAV_THUMBNAIL_SOURCE_TYPES" desc:"Additional mime types a preview is announced and attempted for, e.g. image/vnd.microsoft.icon for a generator that reads icons. The types the build ships are included; this only adds to them. A type the running generator cannot read answers 404." introductionVersion:"%%NEXT%%"` + ThumbnailGeneratorFormats []string `yaml:"thumbnail_generator_formats" env:"OC_THUMBNAIL_GENERATOR_FORMATS;WEBDAV_THUMBNAIL_GENERATOR_FORMATS" desc:"The formats the thumbnail generator writes, as mime:format entries, e.g. image/webp:webp. The format is the name the generator's own format filter uses. A type that is not listed is one the generator cannot write, and a thumbnail mapped to it becomes a JPEG instead." introductionVersion:"%%NEXT%%"` ThumbnailTypeMapping []string `yaml:"thumbnail_type_mapping" env:"OC_THUMBNAIL_TYPE_MAPPING;WEBDAV_THUMBNAIL_TYPE_MAPPING" desc:"The type a thumbnail is written in, per type of the image it is made from, as mime:mime entries, e.g. image/vnd.microsoft.icon:image/png. The target is asked for by the name WEBDAV_THUMBNAIL_GENERATOR_FORMATS gives it. A type that is not listed, and one whose target the generator cannot write, becomes a JPEG." introductionVersion:"%%NEXT%%"` diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index 59739317f7..7412d21f4e 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -117,7 +117,7 @@ func (g Webdav) Search(w http.ResponseWriter, r *http.Request) { func (g Webdav) sendSearchResponse(davPrefix string, rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request, user *userv1beta1.User) { logger := g.log.SubloggerWithRequestID(r.Context()) - responsesXML, err := multistatusResponse(r.Context(), davPrefix, g.config.OpenCloudPublicURL, rsp.Matches, user) + responsesXML, err := multistatusResponse(r.Context(), davPrefix, g.config.OpenCloudPublicURL, rsp.Matches, user, g.sourceTypes) if err != nil { logger.Error().Err(err).Msg("error formatting propfind") w.WriteHeader(http.StatusInternalServerError) @@ -135,10 +135,10 @@ func (g Webdav) sendSearchResponse(davPrefix string, rsp *searchsvc.SearchRespon } // multistatusResponse converts a list of matches into a multistatus response string -func multistatusResponse(ctx context.Context, davPrefix, publicURL string, matches []*searchmsg.Match, user *userv1beta1.User) ([]byte, error) { +func multistatusResponse(ctx context.Context, davPrefix, publicURL string, matches []*searchmsg.Match, user *userv1beta1.User, sourceTypes thumbnail.SourceTypes) ([]byte, error) { responses := make([]*propfind.ResponseXML, 0, len(matches)) for i := range matches { - res, err := matchToPropResponse(ctx, davPrefix, publicURL, matches[i], user) + res, err := matchToPropResponse(ctx, davPrefix, publicURL, matches[i], user, sourceTypes) if err != nil { return nil, err } @@ -154,7 +154,7 @@ func multistatusResponse(ctx context.Context, davPrefix, publicURL string, match return msg, nil } -func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match *searchmsg.Match, user *userv1beta1.User) (*propfind.ResponseXML, error) { +func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match *searchmsg.Match, user *userv1beta1.User, sourceTypes thumbnail.SourceTypes) (*propfind.ResponseXML, error) { // unfortunately, search uses own versions of ResourceId and Ref. So we need to assert them here var ( ref string @@ -222,7 +222,7 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", match.Entity.Permissions)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType)) - _, isSupportedMimeType := thumbnail.SupportedMimeTypes[match.Entity.MimeType] + isSupportedMimeType := sourceTypes.Supports(match.Entity.MimeType) if isSupportedMimeType { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1")) } else { @@ -269,15 +269,6 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match return &response, nil } -func hasPreview(md *provider.ResourceInfo, appendToOK func(p ...prop.PropertyXML)) { - _, match := thumbnail.SupportedMimeTypes[md.MimeType] - if match { - appendToOK(prop.Escaped("oc:has-preview", "1")) - } else { - appendToOK(prop.Escaped("oc:has-preview", "0")) - } -} - type report struct { SearchFiles *reportSearchFiles // FilterFiles TODO add this for tag based search diff --git a/services/webdav/pkg/service/v0/service.go b/services/webdav/pkg/service/v0/service.go index bb1eda3a50..910460b434 100644 --- a/services/webdav/pkg/service/v0/service.go +++ b/services/webdav/pkg/service/v0/service.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "path" + "sort" "strconv" "strings" "time" @@ -70,6 +71,8 @@ func NewService(opts ...Option) (Service, error) { ), ) + sourceTypes := thumbnail.NewSourceTypes(conf.ThumbnailSourceTypes) + tm, err := pool.StringToTLSMode(conf.GRPCClientTLS.Mode) if err != nil { return nil, err @@ -88,7 +91,7 @@ func NewService(opts ...Option) (Service, error) { // and its cache are never built var wf *workflow.ThumbnailWorkflow if !conf.DisablePreviews { - wf, err = newThumbnailWorkflow(conf, options.Logger, gatewaySelector) + wf, err = newThumbnailWorkflow(conf, sourceTypes, options.Logger, gatewaySelector) if err != nil { return nil, fmt.Errorf("create thumbnail workflow: %w", err) } @@ -96,6 +99,7 @@ func NewService(opts ...Option) (Service, error) { svc := Webdav{ config: conf, + sourceTypes: sourceTypes, log: options.Logger, mux: m, searchClient: searchsvc.NewSearchProviderService("eu.opencloud.api.search", conf.GrpcClient), @@ -160,7 +164,7 @@ func NewService(opts ...Option) (Service, error) { // newThumbnailWorkflow wires the preview pipeline: the generator client, the // cache and the gateway-backed lookups. -func newThumbnailWorkflow(conf *config.Config, logger log.Logger, gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]) (*workflow.ThumbnailWorkflow, error) { +func newThumbnailWorkflow(conf *config.Config, sourceTypes thumbnail.SourceTypes, logger log.Logger, gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]) (*workflow.ThumbnailWorkflow, error) { genTimeout, _ := time.ParseDuration(conf.ThumbnailGeneratorTimeout) if genTimeout == 0 { genTimeout = 30 * time.Second @@ -176,6 +180,12 @@ func newThumbnailWorkflow(conf *config.Config, logger log.Logger, gatewaySelecto return nil, fmt.Errorf("parse max input file size: %w", err) } + typeMapping := thumbnail.NewTypeMapping(conf.ThumbnailTypeMapping) + formats := generator.NewFormats(conf.ThumbnailGeneratorFormats) + if err := checkTypeMapping(typeMapping, formats); err != nil { + return nil, err + } + s3cfg := cache.BuildS3CacheConfig( conf.ThumbnailCacheS3Bucket, conf.ThumbnailCacheS3Region, @@ -194,8 +204,9 @@ func newThumbnailWorkflow(conf *config.Config, logger log.Logger, gatewaySelecto workflow.WithHTTPClient(httpClient), workflow.WithMaxInputSize(maxInputSize), workflow.WithSizes(sizes), - workflow.WithFormats(generator.NewFormats(conf.ThumbnailGeneratorFormats)), - workflow.WithTypeMapping(thumbnail.NewTypeMapping(conf.ThumbnailTypeMapping)), + workflow.WithSourceTypes(sourceTypes), + workflow.WithFormats(formats), + workflow.WithTypeMapping(typeMapping), workflow.WithWebdavNamespace(conf.WebdavNamespace), workflow.WithFontMapFile(conf.ThumbnailFontMapFile), workflow.WithLogger(logger), @@ -206,8 +217,27 @@ func newThumbnailWorkflow(conf *config.Config, logger log.Logger, gatewaySelecto ) } +// checkTypeMapping refuses a mapping that asks for a type the generator has no +// format name for: the thumbnail would silently become a JPEG instead. +func checkTypeMapping(mapping thumbnail.TypeMapping, formats generator.Formats) error { + var unknown []string + for source, target := range mapping.Targets() { + if target == "" || formats.For(target) != "" { + continue + } + unknown = append(unknown, fmt.Sprintf("%s (mapped from %s)", target, source)) + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + + return fmt.Errorf("thumbnail type mapping: no generator format for %s", strings.Join(unknown, ", ")) +} + // Webdav implements the business logic for Service. type Webdav struct { + sourceTypes thumbnail.SourceTypes config *config.Config log log.Logger mux *chi.Mux diff --git a/services/webdav/pkg/service/v0/typemapping_test.go b/services/webdav/pkg/service/v0/typemapping_test.go new file mode 100644 index 0000000000..c9024d95c8 --- /dev/null +++ b/services/webdav/pkg/service/v0/typemapping_test.go @@ -0,0 +1,48 @@ +package svc + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/generator" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +var _ = Describe("checkTypeMapping", func() { + It("accepts the defaults", func() { + Expect(checkTypeMapping(thumbnail.NewTypeMapping(nil), generator.NewFormats(nil))).To(Succeed()) + }) + + It("accepts a pair that was configured together", func() { + Expect(checkTypeMapping( + thumbnail.NewTypeMapping([]string{"image/png:image/webp"}), + generator.NewFormats([]string{"image/webp:webp"}), + )).To(Succeed()) + }) + + It("refuses a target the generator has no format for", func() { + err := checkTypeMapping( + thumbnail.NewTypeMapping([]string{"image/png:image/webp"}), + generator.NewFormats(nil), + ) + + Expect(err).To(MatchError(ContainSubstring("no generator format for image/webp (mapped from image/png)"))) + }) + + It("names every bad entry", func() { + err := checkTypeMapping( + thumbnail.NewTypeMapping([]string{"image/png:image/avif", "image/gif:image/webp"}), + generator.NewFormats(nil), + ) + + Expect(err).To(MatchError(ContainSubstring("image/avif (mapped from image/png)"))) + Expect(err).To(MatchError(ContainSubstring("image/webp (mapped from image/gif)"))) + }) + + It("takes an empty target as no override", func() { + Expect(checkTypeMapping( + thumbnail.NewTypeMapping([]string{"image/png:"}), + generator.NewFormats(nil), + )).To(Succeed()) + }) +}) diff --git a/services/webdav/pkg/thumbnail/mimetype.go b/services/webdav/pkg/thumbnail/mimetype.go deleted file mode 100644 index 26ce86e794..0000000000 --- a/services/webdav/pkg/thumbnail/mimetype.go +++ /dev/null @@ -1,13 +0,0 @@ -package thumbnail - -import "mime" - -// IsMimeTypeSupported validates 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 -} diff --git a/services/webdav/pkg/thumbnail/mimetypes.go b/services/webdav/pkg/thumbnail/mimetypes.go deleted file mode 100644 index d93f9a738e..0000000000 --- a/services/webdav/pkg/thumbnail/mimetypes.go +++ /dev/null @@ -1,24 +0,0 @@ -package thumbnail - -var ( - // SupportedMimeTypes contains all mimetypes a preview is made of. Whether - // the generator can read one is its own business: webdav does not decode an - // image, so a build without libvips answers 400 for a webp instead of - // refusing it here. - 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": {}, - } -) diff --git a/services/webdav/pkg/thumbnail/sourcetypes.go b/services/webdav/pkg/thumbnail/sourcetypes.go new file mode 100644 index 0000000000..3495d5b011 --- /dev/null +++ b/services/webdav/pkg/thumbnail/sourcetypes.go @@ -0,0 +1,59 @@ +package thumbnail + +import ( + "mime" + "strings" + + thumbnails "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" +) + +// SourceTypes are the types a preview is announced and attempted for. The +// defaults are the ones the thumbnails service compiles in, which is also where +// reva reads them for oc:has-preview, so a build with libvips brings its extra +// types along. Configured entries are merged on top here, per service: nobody +// writes into that package's map. +// +// The list is a hint, not a verdict. The generator decides what it can really +// read, and a source it refuses answers 404. +type SourceTypes struct { + byType map[string]struct{} +} + +// NewSourceTypes merges the configured types onto the compiled-in defaults. +func NewSourceTypes(entries []string) SourceTypes { + t := SourceTypes{byType: make(map[string]struct{}, len(thumbnails.SupportedMimeTypes)+len(entries))} + for mimeType := range thumbnails.SupportedMimeTypes { + t.byType[mimeType] = struct{}{} + } + for _, entry := range entries { + if entry = strings.ToLower(strings.TrimSpace(entry)); entry != "" { + t.byType[entry] = struct{}{} + } + } + + return t +} + +// Supports reports whether a preview is made of this type. +func (t SourceTypes) Supports(contentType string) bool { + parsed, _, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + _, supported := t.byType[parsed] + + return supported +} + +// All lists the types, so a caller can iterate them. +func (t SourceTypes) All() map[string]struct{} { return t.byType } + +// List is the types as a slice, for a configuration that takes one. +func (t SourceTypes) List() []string { + out := make([]string, 0, len(t.byType)) + for mimeType := range t.byType { + out = append(out, mimeType) + } + + return out +} diff --git a/services/webdav/pkg/thumbnail/typemapping.go b/services/webdav/pkg/thumbnail/typemapping.go index 23e808815a..8322c7bbfe 100644 --- a/services/webdav/pkg/thumbnail/typemapping.go +++ b/services/webdav/pkg/thumbnail/typemapping.go @@ -54,3 +54,6 @@ func (m TypeMapping) TargetFor(contentType string) string { return m.byType[parsed] } + +// Targets lists the type a thumbnail is written in, by source type. +func (m TypeMapping) Targets() map[string]string { return m.byType } diff --git a/services/webdav/pkg/thumbnail/typemapping_test.go b/services/webdav/pkg/thumbnail/typemapping_test.go index 8ab901f56b..d3ae199300 100644 --- a/services/webdav/pkg/thumbnail/typemapping_test.go +++ b/services/webdav/pkg/thumbnail/typemapping_test.go @@ -12,7 +12,7 @@ var _ = Describe("TypeMapping", func() { m := thumbnail.NewTypeMapping(nil) It("answers for every type a preview is made of", func() { - for mimeType := range thumbnail.SupportedMimeTypes { + for mimeType := range thumbnail.NewSourceTypes(nil).All() { Expect(m.TargetFor(mimeType)).ToNot(BeEmpty(), "%s has no output type", mimeType) } }) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index db542bfadc..fd3aadb608 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -139,6 +139,7 @@ type ThumbnailWorkflow struct { maxInputSize uint64 sizes *thumbnail.Sizes typeMapping thumbnail.TypeMapping + sourceTypes thumbnail.SourceTypes formats generator.Formats webdavNS string fontMapFile string @@ -209,6 +210,11 @@ func WithWebdavNamespace(ns string) Option { return func(w *ThumbnailWorkflow) { w.webdavNS = ns } } +// WithSourceTypes sets the types a preview is attempted for. +func WithSourceTypes(t thumbnail.SourceTypes) Option { + return func(w *ThumbnailWorkflow) { w.sourceTypes = t } +} + // WithFormats sets the formats the generator writes, by type. func WithFormats(f generator.Formats) Option { return func(w *ThumbnailWorkflow) { w.formats = f } @@ -298,7 +304,7 @@ func (w *ThumbnailWorkflow) Head(ctx context.Context, tr *requests.ThumbnailRequ return ErrPermissionDenied } - if !thumbnail.IsMimeTypeSupported(info.GetMimeType()) { + if !w.sourceTypes.Supports(info.GetMimeType()) { return fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType()) } @@ -406,7 +412,7 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R return nil, "", false, ErrPermissionDenied } - if !thumbnail.IsMimeTypeSupported(info.GetMimeType()) { + if !w.sourceTypes.Supports(info.GetMimeType()) { return nil, "", false, fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType()) } @@ -682,6 +688,12 @@ func (w *ThumbnailWorkflow) postToGenerator(ctx context.Context, url string, img if httpRsp.StatusCode == http.StatusUnprocessableEntity { return nil, "", ErrImageTooLarge } + // The generator refuses what it cannot read: imagor answers 406, the + // built-in one 400. That is a missing preview, not a server fault, so + // the client gets a 404 and stops asking. + if httpRsp.StatusCode == http.StatusBadRequest || httpRsp.StatusCode == http.StatusNotAcceptable { + return nil, "", fmt.Errorf("%w: generator refused the source: %s", ErrUnsupportedFileType, strings.TrimSpace(string(respBody))) + } return nil, "", fmt.Errorf("generator returned status %d: %s", httpRsp.StatusCode, string(respBody)) } diff --git a/services/webdav/pkg/thumbnail/workflow/workflow_test.go b/services/webdav/pkg/thumbnail/workflow/workflow_test.go index 65ec7c7a2a..ec17301d7c 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow_test.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow_test.go @@ -188,6 +188,7 @@ func setupPreprocessTest( WithCache(testCache), WithHTTPClient(&http.Client{}), WithWebdavNamespace("/users/{{.Id.OpaqueId}}"), + WithSourceTypes(thumbnail.NewSourceTypes(nil)), WithFormats(generator.NewFormats(nil)), WithTypeMapping(thumbnail.NewTypeMapping(nil)), WithLogger(logger), @@ -303,6 +304,7 @@ var _ = Describe("ThumbnailWorkflow", func() { WithCache(testCache), WithHTTPClient(&http.Client{}), WithWebdavNamespace("/users/{{.Id.OpaqueId}}"), + WithSourceTypes(thumbnail.NewSourceTypes(nil)), WithFormats(generator.NewFormats(nil)), WithTypeMapping(thumbnail.NewTypeMapping(nil)), WithLogger(logger), @@ -1046,6 +1048,7 @@ var _ = Describe("ThumbnailWorkflow", func() { WithCache(testCache), WithHTTPClient(&http.Client{}), WithMaxInputSize(1024), + WithSourceTypes(thumbnail.NewSourceTypes(nil)), WithWebdavNamespace("/users/{{.Id.OpaqueId}}"), WithLogger(logger), WithStater(NewGatewayStater(pool.GetSelector[gatewayv1beta1.GatewayAPIClient]( diff --git a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/config/config.go b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/config/config.go index a0f4f2477a..03e78837c5 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/config/config.go +++ b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/config/config.go @@ -37,6 +37,8 @@ type Config struct { URLSigningSharedSecret string `mapstructure:"url_signing_shared_secret"` MachineAuthAPIKey string `mapstructure:"machine_auth_apikey"` + + PreviewMimeTypes []string `mapstructure:"preview_mime_types"` } // NameValidation is the validation configuration for file and folder names diff --git a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/ocdav.go b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/ocdav.go index 4746358ecb..245df52d37 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/ocdav.go +++ b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/ocdav.go @@ -35,6 +35,7 @@ import ( "github.com/jellydator/ttlcache/v2" "github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/config" "github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/net" + "github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/propfind" "github.com/opencloud-eu/reva/v2/pkg/appctx" ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/errtypes" @@ -69,6 +70,7 @@ type svc struct { userIdentifierCache *ttlcache.Cache nameValidators []Validator urlSigner signedurl.Signer + previewMimeTypes map[string]struct{} } func (s *svc) Config() *config.Config { @@ -128,6 +130,7 @@ func NewWith(conf *config.Config, ls LockSystem, _ *zerolog.Logger, selector poo userIdentifierCache: ttlcache.NewCache(), nameValidators: ValidatorsFromConfig(conf), urlSigner: signer, + previewMimeTypes: propfind.PreviewMimeTypes(conf), } _ = s.userIdentifierCache.SetTTL(60 * time.Second) diff --git a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/propfind/propfind.go b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/propfind/propfind.go index d3a7a24f8f..da869a905b 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/propfind/propfind.go +++ b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/propfind/propfind.go @@ -38,7 +38,6 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/iancoleman/strcase" - "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" "github.com/opencloud-eu/reva/v2/internal/grpc/services/storageprovider" "github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/config" "github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/errors" @@ -236,19 +235,32 @@ func NewMultiStatusResponseXML() *MultiStatusResponseXML { // Handler handles propfind requests type Handler struct { - PublicURL string - selector pool.Selectable[gateway.GatewayAPIClient] - c *config.Config - urlSigner signedurl.Signer + PublicURL string + selector pool.Selectable[gateway.GatewayAPIClient] + c *config.Config + urlSigner signedurl.Signer + previewMimeTypes map[string]struct{} +} + +// PreviewMimeTypes turns the configured list into a set, for the handlers that +// build a propfind response themselves. +func PreviewMimeTypes(c *config.Config) map[string]struct{} { + types := make(map[string]struct{}, len(c.PreviewMimeTypes)) + for _, mimeType := range c.PreviewMimeTypes { + types[mimeType] = struct{}{} + } + + return types } // NewHandler returns a new PropfindHandler instance func NewHandler(publicURL string, selector pool.Selectable[gateway.GatewayAPIClient], signer signedurl.Signer, c *config.Config) *Handler { return &Handler{ - PublicURL: publicURL, - selector: selector, - c: c, - urlSigner: signer, + PublicURL: publicURL, + selector: selector, + c: c, + urlSigner: signer, + previewMimeTypes: PreviewMimeTypes(c), } } @@ -521,7 +533,7 @@ func (p *Handler) propfindResponse(ctx context.Context, w http.ResponseWriter, r prefer := net.ParsePrefer(r.Header.Get(net.HeaderPrefer)) returnMinimal := prefer[net.HeaderPreferReturn] == "minimal" - propRes, err := MultistatusResponse(ctx, &pf, resourceInfos, p.PublicURL, namespace, linkshares, returnMinimal, p.urlSigner) + propRes, err := MultistatusResponse(ctx, &pf, resourceInfos, p.PublicURL, namespace, linkshares, returnMinimal, p.urlSigner, p.previewMimeTypes) if err != nil { log.Error().Err(err).Msg("error formatting propfind") w.WriteHeader(http.StatusInternalServerError) @@ -1018,7 +1030,7 @@ func ReadPropfind(r io.Reader) (pf XML, status int, err error) { } // MultistatusResponse converts a list of resource infos into a multistatus response string -func MultistatusResponse(ctx context.Context, pf *XML, mds []*provider.ResourceInfo, publicURL, ns string, linkshares map[string]struct{}, returnMinimal bool, downloadURLSigner signedurl.Signer) ([]byte, error) { +func MultistatusResponse(ctx context.Context, pf *XML, mds []*provider.ResourceInfo, publicURL, ns string, linkshares map[string]struct{}, returnMinimal bool, downloadURLSigner signedurl.Signer, previewMimeTypes map[string]struct{}) ([]byte, error) { g, ctx := errgroup.WithContext(ctx) type work struct { @@ -1053,7 +1065,7 @@ func MultistatusResponse(ctx context.Context, pf *XML, mds []*provider.ResourceI for i := 0; i < numWorkers; i++ { g.Go(func() error { for work := range workChan { - res, err := mdToPropResponse(ctx, pf, work.info, publicURL, ns, linkshares, returnMinimal, downloadURLSigner) + res, err := mdToPropResponse(ctx, pf, work.info, publicURL, ns, linkshares, returnMinimal, downloadURLSigner, previewMimeTypes) if err != nil { return err } @@ -1094,7 +1106,7 @@ func MultistatusResponse(ctx context.Context, pf *XML, mds []*provider.ResourceI // mdToPropResponse converts the CS3 metadata into a webdav PropResponse // ns is the CS3 namespace that needs to be removed from the CS3 path before // prefixing it with the baseURI -func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, publicURL, ns string, linkshares map[string]struct{}, returnMinimal bool, urlSigner signedurl.Signer) (*ResponseXML, error) { +func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, publicURL, ns string, linkshares map[string]struct{}, returnMinimal bool, urlSigner signedurl.Signer, previewMimeTypes map[string]struct{}) (*ResponseXML, error) { ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "md_to_prop_response") span.SetAttributes(attribute.KeyValue{Key: "publicURL", Value: attribute.StringValue(publicURL)}) span.SetAttributes(attribute.KeyValue{Key: "ns", Value: attribute.StringValue(ns)}) @@ -1331,7 +1343,7 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p // directories have no preview appendToNotFound(prop.NotFound("oc:has-preview")) } else if md.MimeType != "" { - hasPreview(md, appendToOK) + hasPreview(md, appendToOK, previewMimeTypes) } // ls do not report any properties as missing by default @@ -1644,7 +1656,7 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p // directories have no preview appendToNotFound(prop.NotFound("oc:has-preview")) } else if md.MimeType != "" { - hasPreview(md, appendToOK) + hasPreview(md, appendToOK, previewMimeTypes) } default: appendToNotFound(prop.NotFound("oc:" + pf.Prop[i].Local)) @@ -1771,8 +1783,8 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p return &response, nil } -func hasPreview(md *provider.ResourceInfo, appendToOK func(p ...prop.PropertyXML)) { - _, match := thumbnail.SupportedMimeTypes[md.MimeType] +func hasPreview(md *provider.ResourceInfo, appendToOK func(p ...prop.PropertyXML), previewMimeTypes map[string]struct{}) { + _, match := previewMimeTypes[md.MimeType] if match { appendToOK(prop.Escaped("oc:has-preview", "1")) } else { diff --git a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/publicfile.go b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/publicfile.go index d4411c741d..aab421a23b 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/publicfile.go +++ b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/publicfile.go @@ -147,7 +147,7 @@ func (s *svc) handlePropfindOnToken(w http.ResponseWriter, r *http.Request, ns s prefer := net.ParsePrefer(r.Header.Get("prefer")) returnMinimal := prefer[net.HeaderPreferReturn] == "minimal" - propRes, err := propfind.MultistatusResponse(ctx, &pf, infos, s.c.PublicURL, ns, nil, returnMinimal, nil) + propRes, err := propfind.MultistatusResponse(ctx, &pf, infos, s.c.PublicURL, ns, nil, returnMinimal, nil, s.previewMimeTypes) if err != nil { sublog.Error().Err(err).Msg("error formatting propfind") w.WriteHeader(http.StatusInternalServerError) diff --git a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/versions.go b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/versions.go index a621957f82..0adcc1b411 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/versions.go +++ b/vendor/github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/versions.go @@ -200,7 +200,7 @@ func (h *VersionsHandler) doListVersions(w http.ResponseWriter, r *http.Request, prefer := net.ParsePrefer(r.Header.Get("prefer")) returnMinimal := prefer[net.HeaderPreferReturn] == "minimal" - propRes, err := propfind.MultistatusResponse(ctx, &pf, infos, s.c.PublicURL, "", nil, returnMinimal, nil) + propRes, err := propfind.MultistatusResponse(ctx, &pf, infos, s.c.PublicURL, "", nil, returnMinimal, nil, s.previewMimeTypes) if err != nil { sublog.Error().Err(err).Msg("error formatting propfind") w.WriteHeader(http.StatusInternalServerError)