mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-30 10:08:39 -04:00
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:
1 parent
07d6829bd6
commit
32f4ee02e8
25 files changed
+234
-94
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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%%"`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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](
|
||||
|
||||
Generated
Vendored
+2
@@ -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
|
||||
|
||||
Generated
Vendored
+3
@@ -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)
|
||||
|
||||
|
||||
Generated
Vendored
+29
-17
@@ -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 {
|
||||
|
||||
Generated
Vendored
+1
-1
@@ -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)
|
||||
|
||||
Generated
Vendored
+1
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user