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.
This commit is contained in:
Dominik Schmidt committed 2026-10-19 14:57:35 +02:00
1 parent 07d6829bd6
commit 32f4ee02e8
25 files changed
+234 -94

No files matched your search

+2
View File
@@ -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"`
@@ -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,
+9 -7
View File
@@ -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"`
+1 -1
View File
@@ -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)
+2
View File
@@ -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
+2
View File
@@ -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,
+1 -1
View File
@@ -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
}
}
@@ -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
}
}
+7 -7
View File
@@ -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)})
+2
View File
@@ -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%%"`
+5 -14
View File
@@ -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
+34 -4
View File
@@ -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
@@ -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())
})
})
-13
View File
@@ -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
}
@@ -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": {},
}
)
@@ -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
}
@@ -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 }
@@ -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)
}
})
@@ -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))
}
@@ -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](
@@ -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
@@ -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)
@@ -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 {
@@ -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)
@@ -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)