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.
This commit is contained in:
Dominik Schmidt committed 2026-09-16 13:04:15 +00:00
1 parent b306a32dac
commit d8cd5805d6
11 files changed
+383 -19

No files matched your search

+2
View File
@@ -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
+2
View File
@@ -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:"-"`
}
+3 -2
View File
@@ -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")
)
@@ -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{})
}
}
+98
View File
@@ -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 ""
}
@@ -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"))
})
})
@@ -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)),
@@ -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")
}
+57
View File
@@ -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
}
@@ -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"),
)
})
@@ -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)