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.
This commit is contained in:
Dominik Schmidt committed 2026-10-07 20:28:48 +02:00
1 parent 1d0d9b1170
commit e1da7e8a50
3 files changed
+51 -210

No files matched your search

+1 -1
View File
@@ -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.
+27 -118
View File
@@ -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 ""
}
+23 -91
View File
@@ -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")
})
})