mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-17 08:10:38 -04:00
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:
1 parent
b306a32dac
commit
d8cd5805d6
11 files changed
+383
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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{})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user