From 32f4ee02e82114bc293136cb0300f39223881aed Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sat, 17 Oct 2026 00:54:27 +0200 Subject: [PATCH] feat(webdav): let a deployment add the types a preview is made of webdav, graph and ocdav read the same list, so a configured type is announced and attempted by all three. A type the generator cannot answer is refused at startup, and ocdav takes the types from its own configuration instead of importing an OpenCloud package, which the vendored reva carries until opencloud-eu/reva#885 lands. --- services/frontend/pkg/config/config.go | 2 + services/frontend/pkg/revaconfig/config.go | 4 ++ services/graph/pkg/config/config.go | 16 ++--- services/graph/pkg/service/v0/driveitems.go | 2 +- services/graph/pkg/service/v0/graph.go | 2 + services/graph/pkg/service/v0/service.go | 2 + services/graph/pkg/service/v0/sharedbyme.go | 2 +- services/graph/pkg/service/v0/sharedwithme.go | 2 +- services/graph/pkg/service/v0/thumbnails.go | 14 ++--- services/webdav/pkg/config/config.go | 2 + services/webdav/pkg/service/v0/search.go | 19 ++---- services/webdav/pkg/service/v0/service.go | 38 ++++++++++-- .../webdav/pkg/service/v0/typemapping_test.go | 48 +++++++++++++++ services/webdav/pkg/thumbnail/mimetype.go | 13 ---- services/webdav/pkg/thumbnail/mimetypes.go | 24 -------- services/webdav/pkg/thumbnail/sourcetypes.go | 59 +++++++++++++++++++ services/webdav/pkg/thumbnail/typemapping.go | 3 + .../webdav/pkg/thumbnail/typemapping_test.go | 2 +- .../webdav/pkg/thumbnail/workflow/workflow.go | 16 ++++- .../pkg/thumbnail/workflow/workflow_test.go | 3 + .../services/owncloud/ocdav/config/config.go | 2 + .../http/services/owncloud/ocdav/ocdav.go | 3 + .../owncloud/ocdav/propfind/propfind.go | 46 +++++++++------ .../services/owncloud/ocdav/publicfile.go | 2 +- .../http/services/owncloud/ocdav/versions.go | 2 +- 25 files changed, 234 insertions(+), 94 deletions(-) create mode 100644 services/webdav/pkg/service/v0/typemapping_test.go delete mode 100644 services/webdav/pkg/thumbnail/mimetype.go delete mode 100644 services/webdav/pkg/thumbnail/mimetypes.go create mode 100644 services/webdav/pkg/thumbnail/sourcetypes.go 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)