From e3bacbf66742b98fec66ce28ebc11ea45be69648 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sun, 6 Sep 2026 21:46:42 +0200 Subject: [PATCH] feat(webdav): take the thumbnail tika marks, and the rendering of a metafile --- services/webdav/pkg/preprocessor/tika.go | 143 ++++++++++++++++-- services/webdav/pkg/preprocessor/tika_test.go | 118 +++++++++++++-- 2 files changed, 238 insertions(+), 23 deletions(-) diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go index 781d2fec2c..24b4e9990f 100644 --- a/services/webdav/pkg/preprocessor/tika.go +++ b/services/webdav/pkg/preprocessor/tika.go @@ -1,8 +1,8 @@ package preprocessor import ( + "archive/zip" "bytes" - "encoding/base64" "encoding/json" "fmt" "io" @@ -40,17 +40,20 @@ func (t TikaThumbnail) Convert(r io.Reader) (*Image, error) { return &Image{ContentType: contentType, Data: img}, nil } -// tikaThumbnail returns the thumbnail Tika picks (/unpack/thumbnail, Tika >= 4.1) and its content type. +// 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. 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)) + req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/all", bytes.NewReader(data)) if err != nil { return "", nil, err } req.Header.Set("Content-Type", contentType) - req.Header.Set("Accept", "application/json") + req.Header.Set("Accept", "application/zip") + // the extension is what routes a raw image to its parser if filename != "" { req.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) } @@ -66,21 +69,135 @@ func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, case http.StatusNoContent: return "", nil, ErrNoThumbnail default: - return "", nil, fmt.Errorf("tika thumbnail returned %s", resp.Status) + return "", nil, fmt.Errorf("tika unpack returned %s", resp.Status) } - var body struct { - Metadata map[string]any `json:"metadata"` - Image string `json:"image"` + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTikaResponse)) + if err != nil { + return "", nil, fmt.Errorf("tika unpack response: %w", err) } - if err := json.NewDecoder(io.LimitReader(resp.Body, maxTikaResponse)).Decode(&body); err != nil { - return "", nil, fmt.Errorf("tika thumbnail response: %w", err) + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + return "", nil, fmt.Errorf("tika unpack response: %w", err) } - img, err := base64.StdEncoding.DecodeString(body.Image) + return thumbnailFromUnpack(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 + for _, f := range zr.File { + if !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) + } + } + 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 + } + } + 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, fmt.Errorf("tika thumbnail response: no image") + return "", nil, ErrNoThumbnail } - return metadataString(body.Metadata, "Content-Type"), img, nil + 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 +} + +func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { + rc, err := zr.Open(name) + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, maxTikaResponse)) } // metadataString reads a key; values are strings or lists. diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go index 7c0472cb01..595e10e633 100644 --- a/services/webdav/pkg/preprocessor/tika_test.go +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -1,8 +1,8 @@ package preprocessor import ( + "archive/zip" "bytes" - "encoding/base64" "encoding/json" "image" "image/color" @@ -16,6 +16,25 @@ 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 { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, data := range entries { + w, err := zw.Create(name) + 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() +} + func pngBytes() []byte { img := image.NewRGBA(image.Rect(0, 0, 4, 3)) img.Set(1, 1, color.RGBA{R: 255, A: 255}) @@ -36,7 +55,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/thumbnail" { + if r.URL.Path != "/unpack/all" { w.WriteHeader(http.StatusNotFound) return } @@ -48,17 +67,19 @@ var _ = Describe("TikaThumbnail", func() { server.Close() }) - It("takes the thumbnail Tika picks, with its metadata, from one request", func() { + It("takes the entry Tika marked as the thumbnail, in 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()), - }) + 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"}, + }, + )) } img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw"))) @@ -67,6 +88,83 @@ var _ = Describe("TikaThumbnail", func() { Expect(requests).To(HaveLen(1)) }) + It("ignores embedded images that are not the thumbnail", 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"}}, + )) + } + + _, 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"))) + 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") + }) + It("reports a document without a thumbnail", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } @@ -81,7 +179,7 @@ var _ = Describe("TikaThumbnail", func() { Expect(err).To(MatchError(ContainSubstring("500"))) }) - It("reports a Tika without the endpoint", func() { + 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")))