From e1da7e8a5059973d1b1dc9d13146a3e5cbc397d1 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 16 Sep 2026 13:08:48 +0000 Subject: [PATCH] feat(webdav): use Tika's merged thumbnail preset Tika merged the thumbnail catalog preset and ThumbnailUnpackSelector (TIKA-4856): PUT /unpack/preset/thumbnail returns exactly the one raster image a client would show for the document - a stored thumbnail, the rendering of a metafile thumbnail, or the rendered first page. The client-side selection over /unpack/all (embedded-resource-type marks, metafile renderings, id-path matching) moves to the server; the preset zip carries one image without metadata sidecars, so the entry's extension names the type and the bytes break the tie. A 404 now points at the preset activation the server config needs. --- services/webdav/README.md | 2 +- services/webdav/pkg/preprocessor/tika.go | 145 ++++-------------- services/webdav/pkg/preprocessor/tika_test.go | 114 +++----------- 3 files changed, 51 insertions(+), 210 deletions(-) diff --git a/services/webdav/README.md b/services/webdav/README.md index 17ef35c3d8..037dcf6c83 100644 --- a/services/webdav/README.md +++ b/services/webdav/README.md @@ -10,7 +10,7 @@ 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. +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. The webdav service calls `PUT /unpack/preset/thumbnail`, so the Tika server must activate the `thumbnail` catalog preset in its configuration (`"presets": {"thumbnail": true}`). 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. diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go index 95dd22bead..bf224b6ef0 100644 --- a/services/webdav/pkg/preprocessor/tika.go +++ b/services/webdav/pkg/preprocessor/tika.go @@ -3,10 +3,11 @@ package preprocessor import ( "archive/zip" "bytes" - "encoding/json" "fmt" "io" + "mime" "net/http" + "path/filepath" "strings" "time" ) @@ -40,14 +41,17 @@ func (t TikaThumbnail) Convert(r io.Reader) (any, error) { return Encoded{ContentType: contentType, Data: img}, nil } -// tikaThumbnail returns the thumbnail Tika found in the document and its content -// type. Tika 4.1 marks it as a THUMBNAIL embedded document, so /unpack/all is -// enough: no preset, no endpoint of our own. +// tikaThumbnail returns the thumbnail Tika found in the document and its +// content type. The thumbnail catalog preset (Tika 4.1, TIKA-4856) unpacks +// exactly the raster image a client would show for the document — a stored +// thumbnail such as a raw photo's embedded preview or an audio file's cover, +// the rendering of an office document's metafile thumbnail, or the rendered +// first page of a PDF — so the zip carries that one image and nothing else. 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/all", bytes.NewReader(data)) + req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/preset/thumbnail", bytes.NewReader(data)) if err != nil { return "", nil, err } @@ -68,6 +72,9 @@ func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, case http.StatusOK: case http.StatusNoContent: return "", nil, ErrNoThumbnail + case http.StatusNotFound: + // the route exists once the catalog preset is active + return "", nil, fmt.Errorf(`tika unpack returned %s: the "thumbnail" preset needs Tika >= 4.1 and "presets": {"thumbnail": true} in its config`, resp.Status) default: return "", nil, fmt.Errorf("tika unpack returned %s", resp.Status) } @@ -80,115 +87,32 @@ func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, if err != nil { return "", nil, fmt.Errorf("tika unpack response: %w", err) } - return thumbnailFromUnpack(zr) + return thumbnailFromZip(zr) } -// vectorThumbnailTypes are thumbnail types nothing here can decode. Office -// documents carry their preview as a metafile, so the THUMBNAIL entry is a -// vector and the usable image is its rendering. -var vectorThumbnailTypes = map[string]struct{}{ - "image/emf": {}, - "image/wmf": {}, - "image/x-emf": {}, - "image/x-wmf": {}, - "image/svg+xml": {}, -} - -// unpacked is one entry of an /unpack/all response: the bytes and the metadata -// are separate zip entries, "1.jpg" alongside "1.jpg.metadata.json". -type unpacked struct { - name string - kind string - mimeType string - idPath string -} - -// thumbnailFromUnpack picks the image to use out of an unpack response. Tika -// marks the document's preview as THUMBNAIL. For office documents that preview -// is a metafile, and the raster we want is the RENDERING nested under it, which -// tika only emits when its emf/wmf parser has renderImage turned on. -func thumbnailFromUnpack(zr *zip.Reader) (string, []byte, error) { - var entries []unpacked +// thumbnailFromZip takes the one image the preset unpacked. Lenient about the +// shape: the first regular file that is not a metadata sidecar wins. +func thumbnailFromZip(zr *zip.Reader) (string, []byte, error) { for _, f := range zr.File { - if !strings.HasSuffix(f.Name, ".metadata.json") { + if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, ".metadata.json") { continue } - meta, err := readZipJSON(f) - if err != nil { - continue - } - entries = append(entries, unpacked{ - name: strings.TrimSuffix(f.Name, ".metadata.json"), - kind: metadataString(meta, "tk:embedded-resource-type"), - mimeType: metadataString(meta, "Content-Type"), - idPath: metadataString(meta, "tk:embedded-id-path"), - }) - } - - for _, e := range entries { - if e.kind != "THUMBNAIL" { - continue - } - if _, vector := vectorThumbnailTypes[e.mimeType]; !vector { - return readThumbnail(zr, e) - } - if r, ok := renderingOf(entries, e); ok { - return readThumbnail(zr, r) - } - return "", nil, ErrNoThumbnail - } - - // a document without a preview of its own can still have been rendered, - // a pdf page for instance - for _, e := range entries { - if e.kind == "RENDERING" { - return readThumbnail(zr, e) + img, err := readZipEntry(zr, f.Name) + if err != nil || len(img) == 0 { + return "", nil, ErrNoThumbnail } + return entryContentType(f.Name, img), img, nil } return "", nil, ErrNoThumbnail } -// renderingOf finds the rendering tika nested under a thumbnail. The rendering -// is a child of the image it renders, so its id path extends the thumbnail's. -func renderingOf(entries []unpacked, thumbnail unpacked) (unpacked, bool) { - for _, e := range entries { - if e.kind == "RENDERING" && thumbnail.idPath != "" && strings.HasPrefix(e.idPath, thumbnail.idPath+"/") { - return e, true - } +// entryContentType names the image's type. The preset zip carries no metadata +// entries, so the extension decides and the bytes break the tie. +func entryContentType(name string, data []byte) string { + if byExt := mime.TypeByExtension(filepath.Ext(name)); byExt != "" { + return byExt } - return unpacked{}, false -} - -func readThumbnail(zr *zip.Reader, e unpacked) (string, []byte, error) { - img, err := readZipEntry(zr, e.name) - if err != nil || len(img) == 0 { - return "", nil, ErrNoThumbnail - } - return e.mimeType, img, nil -} - -// readZipJSON decodes a metadata entry. Tika writes either an object or a -// single element list. -func readZipJSON(f *zip.File) (map[string]any, error) { - rc, err := f.Open() - if err != nil { - return nil, err - } - defer rc.Close() - - var raw json.RawMessage - if err := json.NewDecoder(io.LimitReader(rc, maxTikaResponse)).Decode(&raw); err != nil { - return nil, err - } - var meta map[string]any - if err := json.Unmarshal(raw, &meta); err == nil { - return meta, nil - } - var list []map[string]any - if err := json.Unmarshal(raw, &list); err != nil || len(list) == 0 { - return nil, fmt.Errorf("unexpected metadata shape") - } - return list[0], nil + return http.DetectContentType(data) } func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { @@ -199,18 +123,3 @@ func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { defer rc.Close() return io.ReadAll(io.LimitReader(rc, maxTikaResponse)) } - -// 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 index 8d6a6a6332..38194e6660 100644 --- a/services/webdav/pkg/preprocessor/tika_test.go +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -3,7 +3,6 @@ package preprocessor import ( "archive/zip" "bytes" - "encoding/json" "image" "image/color" "image/png" @@ -16,9 +15,9 @@ import ( "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) -// unpackZip builds what /unpack/all returns: the entries and their metadata -// side by side, "1.png" next to "1.png.metadata.json". -func unpackZip(entries map[string][]byte, meta map[string]map[string]any) []byte { +// presetZip builds what /unpack/preset/thumbnail returns: the one image the +// preset selected, and nothing else. +func presetZip(entries map[string][]byte) []byte { var buf bytes.Buffer zw := zip.NewWriter(&buf) for name, data := range entries { @@ -26,10 +25,6 @@ func unpackZip(entries map[string][]byte, meta map[string]map[string]any) []byte Expect(err).ToNot(HaveOccurred()) _, err = w.Write(data) Expect(err).ToNot(HaveOccurred()) - - w, err = zw.Create(name + ".metadata.json") - Expect(err).ToNot(HaveOccurred()) - Expect(json.NewEncoder(w).Encode(meta[name])).To(Succeed()) } Expect(zw.Close()).To(Succeed()) return buf.Bytes() @@ -55,7 +50,7 @@ var _ = Describe("TikaThumbnail", func() { 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/all" { + if r.URL.Path != "/unpack/preset/thumbnail" { w.WriteHeader(http.StatusNotFound) return } @@ -67,19 +62,13 @@ var _ = Describe("TikaThumbnail", func() { server.Close() }) - It("takes the entry Tika marked as the thumbnail, in one request", func() { + It("takes the image the preset unpacked, in one request", func() { thumbnail = func(w http.ResponseWriter, r *http.Request) { Expect(r.Method).To(Equal(http.MethodPut)) 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/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"0.nef": []byte("the raw itself"), "1.png": pngBytes()}, - map[string]map[string]any{ - "0.nef": {"Content-Type": "image/x-nikon-nef"}, - "1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "THUMBNAIL"}, - }, - )) + _, _ = w.Write(presetZip(map[string][]byte{"1.png": pngBytes()})) } img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw"))) @@ -88,81 +77,15 @@ var _ = Describe("TikaThumbnail", func() { Expect(requests).To(HaveLen(1)) }) - It("ignores embedded images that are not the thumbnail", func() { + It("decodes an image whose entry name has no telling extension", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.png": pngBytes()}, - map[string]map[string]any{"1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "INLINE"}}, - )) + _, _ = w.Write(presetZip(map[string][]byte{"thumbnail": pngBytes()})) } - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) - Expect(err).To(MatchError(ErrNoThumbnail), "an inline image is not a thumbnail, tika 4.0 marks cover art that way") - }) - - It("takes the rendering when the thumbnail is a metafile", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.wmf": []byte("vector"), "2.png": pngBytes()}, - map[string]map[string]any{ - "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, - "2.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1/2"}, - }, - )) - } - - img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.doc"}.Convert(bytes.NewReader([]byte("ole2"))) + img, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) Expect(err).ToNot(HaveOccurred()) - Expect(img).ToNot(BeNil(), "office documents carry a metafile preview, the raster is its rendering") - }) - - It("reports no thumbnail when the metafile was not rendered", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.wmf": []byte("vector")}, - map[string]map[string]any{ - "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, - }, - )) - } - - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2"))) - Expect(err).To(MatchError(ErrNoThumbnail), "rendering is off in tika by default") - }) - - It("does not take a rendering that belongs to another image", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.wmf": []byte("vector"), "3.png": pngBytes()}, - map[string]map[string]any{ - "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, - "3.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/2/3"}, - }, - )) - } - - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2"))) - Expect(err).To(MatchError(ErrNoThumbnail)) - }) - - It("takes a rendering of the document itself", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.png": pngBytes()}, - map[string]map[string]any{ - "1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1"}, - }, - )) - } - - img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.pdf"}.Convert(bytes.NewReader([]byte("pdf"))) - Expect(err).ToNot(HaveOccurred()) - Expect(img).ToNot(BeNil(), "a pdf has no preview of its own, a rendered page is one") + Expect(img).ToNot(BeNil(), "the bytes name the type when the entry does not") }) It("reports a document without a thumbnail", func() { @@ -172,6 +95,16 @@ var _ = Describe("TikaThumbnail", func() { Expect(err).To(MatchError(ErrNoThumbnail)) }) + It("reports an empty zip as no thumbnail", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(presetZip(nil)) + } + + _, 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) } @@ -179,11 +112,10 @@ var _ = Describe("TikaThumbnail", func() { Expect(err).To(MatchError(ContainSubstring("500"))) }) - It("reports a Tika that does not know the route", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) } - - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + It("points at the preset config when the route is missing", func() { + _, err := TikaThumbnail{tikaURL: server.URL + "/nowhere"}.Convert(bytes.NewReader([]byte("raw"))) Expect(err).To(MatchError(ContainSubstring("404"))) + Expect(err).To(MatchError(ContainSubstring("preset")), "a 404 means the catalog preset is not active") }) })