From d8cd5805d66c19c821e02de8b61ee0c17030aed0 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sun, 30 Aug 2026 14:04:49 +0200 Subject: [PATCH] feat(webdav): thumbnails through a Tika server Every file the built-in converters cannot render goes to /unpack/thumbnail (Tika 4.1) and comes back with the image Tika marks as its thumbnail. --- services/webdav/README.md | 2 + services/webdav/pkg/config/config.go | 2 + services/webdav/pkg/preprocessor/errors.go | 5 +- .../webdav/pkg/preprocessor/preprocessor.go | 41 ++++--- services/webdav/pkg/preprocessor/tika.go | 98 +++++++++++++++ services/webdav/pkg/preprocessor/tika_test.go | 115 ++++++++++++++++++ services/webdav/pkg/service/v0/service.go | 1 + .../pkg/thumbnail/thumbnail_suite_test.go | 13 ++ services/webdav/pkg/thumbnail/tika.go | 57 +++++++++ services/webdav/pkg/thumbnail/tika_test.go | 37 ++++++ .../webdav/pkg/thumbnail/workflow/workflow.go | 31 ++++- 11 files changed, 383 insertions(+), 19 deletions(-) create mode 100644 services/webdav/pkg/preprocessor/tika.go create mode 100644 services/webdav/pkg/preprocessor/tika_test.go create mode 100644 services/webdav/pkg/thumbnail/thumbnail_suite_test.go create mode 100644 services/webdav/pkg/thumbnail/tika.go create mode 100644 services/webdav/pkg/thumbnail/tika_test.go diff --git a/services/webdav/README.md b/services/webdav/README.md index 68952561a2..17ef35c3d8 100644 --- a/services/webdav/README.md +++ b/services/webdav/README.md @@ -10,6 +10,8 @@ Currently, the webdav service handles request for two functionalities, which are The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints. +Thumbnails for documents, raw photos, audio cover art and other formats the webdav service cannot render itself come from an Apache Tika server (4.1 or newer) when `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated. + Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAIL_CACHE_BACKEND` and `WEBDAV_THUMBNAIL_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache. #### Thumbnail Query String Parameters diff --git a/services/webdav/pkg/config/config.go b/services/webdav/pkg/config/config.go index f071bcd187..38a4ecc424 100644 --- a/services/webdav/pkg/config/config.go +++ b/services/webdav/pkg/config/config.go @@ -38,6 +38,8 @@ type Config struct { ThumbnailCacheS3AccessKey string `yaml:"thumbnail_cache_s3_access_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_ACCESS_KEY" desc:"S3 access key for thumbnail cache authentication." introductionVersion:"1.0.0"` ThumbnailCacheS3SecretKey string `yaml:"thumbnail_cache_s3_secret_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_SECRET_KEY" desc:"S3 secret key for thumbnail cache authentication." introductionVersion:"1.0.0"` FontMapFile string `yaml:"font_map_file" env:"WEBDAV_THUMBNAILS_TXT_FONTMAP_FILE;THUMBNAILS_TXT_FONTMAP_FILE" desc:"The path to a font map file for txt thumbnails." introductionVersion:"1.0.0"` + TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;WEBDAV_TIKA_URL" desc:"URL of an Apache Tika server. When set, thumbnails are generated for documents, raw photos, audio cover art and other formats Tika can extract a preview from." introductionVersion:"%%NEXT%%"` + TikaThumbnailMimeTypes []string `yaml:"tika_thumbnail_mime_types" env:"OC_TIKA_THUMBNAIL_MIME_TYPES;WEBDAV_TIKA_THUMBNAIL_MIME_TYPES" desc:"Restrict thumbnail generation via Tika to these mime types. Empty means all types that are not handled directly. An entry can map a type to the one Tika knows the format by, e.g. image/x-raw-samsung:image/x-samsung-srw." introductionVersion:"%%NEXT%%"` Context context.Context `yaml:"-"` } diff --git a/services/webdav/pkg/preprocessor/errors.go b/services/webdav/pkg/preprocessor/errors.go index 72625bdd2f..98402cd37f 100644 --- a/services/webdav/pkg/preprocessor/errors.go +++ b/services/webdav/pkg/preprocessor/errors.go @@ -3,7 +3,8 @@ package preprocessor import "errors" var ( - ErrNoImageFromAudioFile = errors.New("preprocessor: could not extract image from audio file") - ErrNoConverterForExtractedImageFromGgsFile = errors.New("preprocessor: could not find converter for image extracted from ggs file") + ErrNoImageFromAudioFile = errors.New("preprocessor: could not extract image from audio file") + ErrNoConverterForExtractedImageFromGgsFile = errors.New("preprocessor: could not find converter for image extracted from ggs file") ErrNoConverterForExtractedImageFromAudioFile = errors.New("preprocessor: could not find converter for image extracted from audio file") + ErrNoThumbnail = errors.New("preprocessor: the document has no thumbnail") ) diff --git a/services/webdav/pkg/preprocessor/preprocessor.go b/services/webdav/pkg/preprocessor/preprocessor.go index 0bc562f68b..0f7f54de23 100644 --- a/services/webdav/pkg/preprocessor/preprocessor.go +++ b/services/webdav/pkg/preprocessor/preprocessor.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" "image" "image/draw" "image/gif" @@ -39,7 +40,9 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) { return img, nil } -// GgsDecoder is a converter for the geogebra slides file +// GgsDecoder is a converter for the geogebra slides file. +// +// Deprecated: Tika provides the thumbnail; kept for setups without Tika. type GgsDecoder struct{ thumbnailpath string } // Convert reads the ggs file and returns the thumbnail image. @@ -79,7 +82,9 @@ func (g GgsDecoder) Convert(r io.Reader) (any, error) { return nil, errors.Errorf("%s not found", g.thumbnailpath) } -// AudioDecoder is a converter for the audio file +// AudioDecoder is a converter for the audio file. +// +// Deprecated: Tika provides the cover art; kept for setups without Tika. type AudioDecoder struct{} // Convert reads the audio file and extracts the thumbnail image from the id3 tag. @@ -209,7 +214,9 @@ type GGPStruct struct { } } -// GgpDecoder is a converter for the geogebra pinboard file +// GgpDecoder is a converter for the geogebra pinboard file. +// +// Deprecated: see GgsDecoder. type GgpDecoder struct{} // Convert reads the ggp file and returns the first thumbnail image. @@ -313,6 +320,16 @@ func drawWord(canvas *font.Drawer, word string, minX, maxX, incY, maxY fixed.Int } } +// tikaOr prefers a configured Tika server, else the fallback +func tikaOr(opts map[string]any, mimeType string, fallback FileConverter) FileConverter { + tika, _ := opts["tika"].(thumbnail.Tika) + if !tika.Supports(mimeType) { + return fallback + } + filename, _ := opts["filename"].(string) + return TikaThumbnail{tikaURL: tika.URL, filename: filename, contentType: tika.ContentType(mimeType)} +} + // ForType returns the converter for the specified mimeType func ForType(mimeType string, opts map[string]any) FileConverter { // We can ignore the error here because we parse it in IsMimeTypeSupported before and if it fails @@ -348,19 +365,15 @@ func ForType(mimeType string, opts map[string]any) FileConverter { return TxtToImageConverter{ fontLoader: fontLoader, } - case "application/vnd.geogebra.slides": - return GgsDecoder{"_slide0/geogebra_thumbnail.png"} - case "application/vnd.geogebra.pinboard": - return GgpDecoder{} case "image/gif": return GifDecoder{} - case "audio/flac": - fallthrough - case "audio/mpeg": - fallthrough - case "audio/ogg": - return AudioDecoder{} + case "application/vnd.geogebra.slides": + return tikaOr(opts, mimeType, GgsDecoder{"_slide0/geogebra_thumbnail.png"}) + case "application/vnd.geogebra.pinboard": + return tikaOr(opts, mimeType, GgpDecoder{}) + case "audio/flac", "audio/mpeg", "audio/ogg": + return tikaOr(opts, mimeType, AudioDecoder{}) default: - return ImageDecoder{} + return tikaOr(opts, mimeType, ImageDecoder{}) } } diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go new file mode 100644 index 0000000000..f06619bae2 --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika.go @@ -0,0 +1,98 @@ +package preprocessor + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// maxTikaResponse bounds what is read back from Tika +const maxTikaResponse = 100 * 1024 * 1024 + +var tikaHTTPClient = &http.Client{Timeout: 60 * time.Second} + +// TikaThumbnail asks a Tika server for the document's thumbnail and decodes it. +type TikaThumbnail struct { + tikaURL string + // detection hints + filename string + contentType string +} + +// Convert reads the file and returns its thumbnail as an image. +func (t TikaThumbnail) Convert(r io.Reader) (any, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + contentType, img, err := tikaThumbnail(t.tikaURL, t.filename, t.contentType, data) + if err != nil { + return nil, err + } + + return ForType(contentType, nil).Convert(bytes.NewReader(img)) +} + +// tikaThumbnail returns the thumbnail Tika picks (/unpack/thumbnail, Tika >= 4.1) and its content type. +func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, []byte, error) { + if contentType == "" { + contentType = "application/octet-stream" + } + req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/thumbnail?renderThumbnails=true", bytes.NewReader(data)) + if err != nil { + return "", nil, err + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + if filename != "" { + req.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + } + + resp, err := tikaHTTPClient.Do(req) + if err != nil { + return "", nil, fmt.Errorf("tika request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + return "", nil, ErrNoThumbnail + default: + return "", nil, fmt.Errorf("tika thumbnail returned %s", resp.Status) + } + + var body struct { + Metadata map[string]any `json:"metadata"` + Image string `json:"image"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxTikaResponse)).Decode(&body); err != nil { + return "", nil, fmt.Errorf("tika thumbnail response: %w", err) + } + img, err := base64.StdEncoding.DecodeString(body.Image) + if err != nil || len(img) == 0 { + return "", nil, fmt.Errorf("tika thumbnail response: no image") + } + return metadataString(body.Metadata, "Content-Type"), img, nil +} + +// metadataString reads a key; values are strings or lists. +func metadataString(meta map[string]any, key string) string { + switch v := meta[key].(type) { + case string: + return v + case []any: + if len(v) > 0 { + if s, ok := v[0].(string); ok { + return s + } + } + } + return "" +} diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go new file mode 100644 index 0000000000..4c3cd30d7d --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -0,0 +1,115 @@ +package preprocessor + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +func pngBytes() []byte { + img := image.NewRGBA(image.Rect(0, 0, 4, 3)) + img.Set(1, 1, color.RGBA{R: 255, A: 255}) + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return buf.Bytes() +} + +var _ = Describe("TikaThumbnail", func() { + var ( + server *httptest.Server + requests []*http.Request + thumbnail func(w http.ResponseWriter, r *http.Request) + ) + + BeforeEach(func() { + requests = nil + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r) + if r.URL.Path != "/unpack/thumbnail" { + w.WriteHeader(http.StatusNotFound) + return + } + thumbnail(w, r) + })) + }) + + AfterEach(func() { + server.Close() + }) + + It("takes the thumbnail Tika picks, with its metadata, from one request", func() { + thumbnail = func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPut)) + Expect(r.URL.Query().Get("renderThumbnails")).To(Equal("true")) + Expect(r.Header.Get("Content-Disposition")).To(ContainSubstring(`filename="shot.nef"`)) + Expect(r.Header.Get("Content-Type")).To(Equal("image/x-nikon-nef"), "the file's type travels as the detection hint") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "metadata": map[string]any{"Content-Type": "image/png", "tk:embedded-resource-type": "THUMBNAIL"}, + "image": base64.StdEncoding.EncodeToString(pngBytes()), + }) + } + + img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil()) + Expect(requests).To(HaveLen(1)) + }) + + It("reports a document without a thumbnail", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("zip"))) + Expect(err).To(MatchError(ErrNoThumbnail)) + }) + + It("reports a failing server", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ContainSubstring("500"))) + }) + + It("reports a Tika without the endpoint", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ContainSubstring("404"))) + }) +}) + +var _ = Describe("ForType with a Tika server", func() { + It("routes everything Tika is asked for to Tika, text and gif stay native", func() { + opts := map[string]any{"tika": thumbnail.NewTika("http://tika:9998", nil), "filename": "song.mp3"} + Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("image/x-nikon-nef", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("application/vnd.geogebra.slides", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("text/plain", opts)).To(BeAssignableToTypeOf(TxtToImageConverter{})) + Expect(ForType("image/gif", opts)).To(BeAssignableToTypeOf(GifDecoder{})) + }) + + It("keeps the in-process converters without a Tika server", func() { + Expect(ForType("audio/mpeg", nil)).To(BeAssignableToTypeOf(AudioDecoder{})) + Expect(ForType("application/vnd.geogebra.slides", nil)).To(BeAssignableToTypeOf(GgsDecoder{})) + Expect(ForType("image/x-nikon-nef", nil)).To(BeAssignableToTypeOf(ImageDecoder{})) + }) + + It("follows a configured list and hands the mapped type to Tika", func() { + opts := map[string]any{"tika": thumbnail.NewTika("http://tika:9998", []string{"application/pdf", "image/x-raw-samsung:image/x-samsung-srw"})} + Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(AudioDecoder{}), "not in the configured list") + Expect(ForType("image/x-raw-samsung", opts).(TikaThumbnail).contentType).To(Equal("image/x-samsung-srw")) + }) +}) diff --git a/services/webdav/pkg/service/v0/service.go b/services/webdav/pkg/service/v0/service.go index d905d64db2..409d4be7b6 100644 --- a/services/webdav/pkg/service/v0/service.go +++ b/services/webdav/pkg/service/v0/service.go @@ -119,6 +119,7 @@ func NewService(opts ...Option) (Service, error) { workflow.WithResolutions(resolutions), workflow.WithWebdavNamespace(conf.WebdavNamespace), workflow.WithFontMapFile(conf.FontMapFile), + workflow.WithTika(thumbnail.NewTika(conf.TikaURL, conf.TikaThumbnailMimeTypes)), workflow.WithLogger(options.Logger), workflow.WithStater(workflow.NewGatewayStater(gatewaySelector)), workflow.WithFileDownloader(workflow.NewGatewayFileDownloader(gatewaySelector, httpClient)), diff --git a/services/webdav/pkg/thumbnail/thumbnail_suite_test.go b/services/webdav/pkg/thumbnail/thumbnail_suite_test.go new file mode 100644 index 0000000000..7217b6a146 --- /dev/null +++ b/services/webdav/pkg/thumbnail/thumbnail_suite_test.go @@ -0,0 +1,13 @@ +package thumbnail_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestThumbnail(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Thumbnail Suite") +} diff --git a/services/webdav/pkg/thumbnail/tika.go b/services/webdav/pkg/thumbnail/tika.go new file mode 100644 index 0000000000..ab4749a9da --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika.go @@ -0,0 +1,57 @@ +package thumbnail + +import ( + "mime" + "strings" +) + +// Tika is the server that thumbnails what the built-in converters cannot. +// By default every non-image file is sent and Tika decides. +type Tika struct { + URL string + // our mime type -> Tika's, "" when equal; "*" means every type + mimeTypes map[string]string +} + +// NewTika parses "mime[:tika-mime]" entries; empty means every type. +func NewTika(url string, mimeTypes []string) Tika { + t := Tika{URL: url, mimeTypes: map[string]string{}} + for _, entry := range mimeTypes { + ours, theirs, _ := strings.Cut(strings.ToLower(strings.TrimSpace(entry)), ":") + if ours = strings.TrimSpace(ours); ours != "" { + t.mimeTypes[ours] = strings.TrimSpace(theirs) + } + } + if len(t.mimeTypes) == 0 { + t.mimeTypes["*"] = "" + } + return t +} + +// Supports reports whether the type is sent to Tika for its thumbnail. +func (t Tika) Supports(mimeType string) bool { + if t.URL == "" { + return false + } + m, _, err := mime.ParseMediaType(mimeType) + if err != nil || m == "httpd/unix-directory" { + return false + } + if _, ok := t.mimeTypes[m]; ok { + return true + } + _, all := t.mimeTypes["*"] + return all +} + +// ContentType is the type Tika is told, mapped where configured. +func (t Tika) ContentType(mimeType string) string { + m, _, err := mime.ParseMediaType(mimeType) + if err != nil { + return "" + } + if theirs := t.mimeTypes[m]; theirs != "" { + return theirs + } + return m +} diff --git a/services/webdav/pkg/thumbnail/tika_test.go b/services/webdav/pkg/thumbnail/tika_test.go new file mode 100644 index 0000000000..9b5c2ad23d --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika_test.go @@ -0,0 +1,37 @@ +package thumbnail_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +var _ = Describe("Tika", func() { + none := thumbnail.NewTika("", nil) + all := thumbnail.NewTika("http://tika:9998", nil) + some := thumbnail.NewTika("http://tika:9998", []string{" Application/PDF ", "audio/mpeg", "image/x-raw-samsung:image/x-samsung-srw"}) + + DescribeTable("Supports", + func(tika thumbnail.Tika, mimeType string, want bool) { + Expect(tika.Supports(mimeType)).To(Equal(want)) + }, + Entry("nothing without a server", none, "application/pdf", false), + Entry("everything by default", all, "application/x-anything; charset=binary", true), + Entry("but no directories", all, "httpd/unix-directory", false), + Entry("nor an unparsable type", all, "not a mime", false), + Entry("a listed type", some, "application/pdf", true), + Entry("a listed type with parameters", some, "audio/mpeg; charset=binary", true), + Entry("a mapped type", some, "image/x-raw-samsung", true), + Entry("an unlisted type", some, "application/zip", false), + ) + + DescribeTable("ContentType", + func(tika thumbnail.Tika, mimeType, want string) { + Expect(tika.ContentType(mimeType)).To(Equal(want)) + }, + Entry("maps a type to the one Tika knows", some, "image/x-raw-samsung", "image/x-samsung-srw"), + Entry("passes an unmapped type through, without parameters", some, "application/pdf; charset=binary", "application/pdf"), + Entry("passes everything through by default", all, "audio/mpeg", "audio/mpeg"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index 6acc5b48c6..adac11069c 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -69,6 +69,9 @@ type UserResolver interface { // surface this as HTTP 425 Too Early with a Retry-After header. var ErrFileProcessing = fmt.Errorf("file is processing") +// ErrNoThumbnail is returned when the file has no thumbnail; cached. +var ErrNoThumbnail = errors.New("thumbnails: the file has no thumbnail") + // ErrImageTooLarge is returned when the input image exceeds the configured // maximum width/height. Callers should surface this as HTTP 403 Forbidden, // matching the legacy thumbnail service error message. @@ -138,6 +141,7 @@ type ThumbnailWorkflow struct { maxInputSize uint64 resolutions *thumbnail.Resolutions webdavNS string + tika thumbnail.Tika fontMapFile string log log.Logger stater Stater @@ -207,6 +211,11 @@ func WithWebdavNamespace(ns string) Option { } // WithFontMapFile sets the font map file used for text thumbnail rendering. +// WithTika sets the Tika server the preprocessing falls back to. +func WithTika(t thumbnail.Tika) Option { + return func(w *ThumbnailWorkflow) { w.tika = t } +} + func WithFontMapFile(file string) Option { return func(w *ThumbnailWorkflow) { w.fontMapFile = file } } @@ -285,7 +294,7 @@ func (w *ThumbnailWorkflow) Head(ctx context.Context, tr *requests.ThumbnailRequ return ErrPermissionDenied } - if !thumbnail.IsMimeTypeSupported(info.GetMimeType()) { + if !w.supportsMimeType(info.GetMimeType()) { return fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType()) } @@ -393,7 +402,7 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R return nil, "", false, ErrPermissionDenied } - if !thumbnail.IsMimeTypeSupported(info.GetMimeType()) { + if !w.supportsMimeType(info.GetMimeType()) { return nil, "", false, fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType()) } @@ -434,6 +443,10 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R if w.cache != nil { if cached, err := w.cache.Get(cacheKey); err == nil { + // empty entry: known to have no thumbnail + if len(cached) == 0 { + return nil, "", false, ErrNoThumbnail + } return cached, outputExt, aIgnored, nil } } @@ -508,8 +521,15 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta return nil, nil, fmt.Errorf("read source: %w", err) } - ppOpts := map[string]any{"fontFileMap": w.fontMapFile} + ppOpts := map[string]any{ + "fontFileMap": w.fontMapFile, + "tika": w.tika, + "filename": filename, + } img, err := preprocessor.ForType(mimeType, ppOpts).Convert(bytes.NewReader(fileBytes)) + if errors.Is(err, preprocessor.ErrNoThumbnail) { + return nil, nil, ErrNoThumbnail + } if img == nil || err != nil { logger.Debug().Err(err).Msg("could not convert file to image") return nil, nil, fmt.Errorf("%w: could not get image", ErrNotFound) @@ -657,6 +677,11 @@ func (w *ThumbnailWorkflow) postToGenerator(ctx context.Context, url string, img return rspData, nil } +// supportsMimeType is the built-in list plus what Tika is asked for. +func (w *ThumbnailWorkflow) supportsMimeType(mimeType string) bool { + return thumbnail.IsMimeTypeSupported(mimeType) || w.tika.Supports(mimeType) +} + // gatewaySelector is the interface for selecting a gateway client. type gatewaySelector interface { Next(...pool.Option) (gatewayv1beta1.GatewayAPIClient, error)