diff --git a/.woodpecker.star b/.woodpecker.star index 6baa8b3b77..556c54d259 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -8,7 +8,7 @@ docker_repo_slug = "opencloudeu/opencloud" # images ALPINE_GIT = "alpine/git:latest" -APACHE_TIKA = "apache/tika:4.0.0-full" +APACHE_TIKA = "apache/tika:4.1.0-full" CHKO_DOCKER_PUSHRM = "chko/docker-pushrm:1" CODACY_COVERAGE_REPORTER = "codacy/codacy-coverage-reporter:14.1.3" COLLABORA_CODE = "collabora/code:24.04.5.1.1" @@ -3447,6 +3447,17 @@ def tikaService(): # tika 4 discovers its plugins relative to the image working directory, # the workspace default would leave the pipes fetchers empty "directory": "/opt/tika-server", + # the thumbnail preset ships inert; the config is what activates it + "entrypoint": [ + "java", + "-cp", + "/opt/tika-server/*:/opt/tika-server/lib/*:/tika-extras/*", + "org.apache.tika.server.core.TikaServerCli", + "-h", + "0.0.0.0", + "-c", + "%s/tests/config/woodpecker/tika-config.json" % dirs["base"], + ], "detach": True, }, { "name": "wait-for-tika-service", diff --git a/devtools/deployments/opencloud_full/config/tika/tika-config.json b/devtools/deployments/opencloud_full/config/tika/tika-config.json new file mode 100644 index 0000000000..5e0ded5f0a --- /dev/null +++ b/devtools/deployments/opencloud_full/config/tika/tika-config.json @@ -0,0 +1,12 @@ +{ + // -h and -p still come from the command line; a config file has to carry the block + "server": { + "port": 9998, + "host": "0.0.0.0" + }, + // the thumbnail preset ships inert in tika-pipes-core; activating it is what serves + // PUT /unpack/preset/thumbnail, which the webdav thumbnailer asks for + "presets": { + "thumbnail": true + } +} diff --git a/devtools/deployments/opencloud_full/tika.yml b/devtools/deployments/opencloud_full/tika.yml index 081f2876b5..faf21e13d5 100644 --- a/devtools/deployments/opencloud_full/tika.yml +++ b/devtools/deployments/opencloud_full/tika.yml @@ -3,6 +3,10 @@ services: tika: image: ${TIKA_IMAGE:-apache/tika:latest-full} # release notes: https://tika.apache.org + # the thumbnail preset ships inert; the config is what activates it + command: ['-c', '/tika-config.json'] + volumes: + - ./config/tika/tika-config.json:/tika-config.json networks: opencloud-net: restart: always @@ -13,5 +17,6 @@ services: environment: # fulltext search SEARCH_EXTRACTOR_TYPE: tika - SEARCH_EXTRACTOR_TIKA_TIKA_URL: http://tika:9998 + # the search extractor and the webdav thumbnailer both read it + OC_TIKA_URL: http://tika:9998 FRONTEND_FULL_TEXT_SEARCH_ENABLED: "true" diff --git a/services/search/README.md b/services/search/README.md index 96bdc00c21..bdaee131e6 100644 --- a/services/search/README.md +++ b/services/search/README.md @@ -107,7 +107,7 @@ As soon as Tika is installed and configured, the search service needs to be told The following settings must be set: * `SEARCH_EXTRACTOR_TYPE=tika` -* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL` +* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL` (or the shared `OC_TIKA_URL`, which the webdav service reads for thumbnails too) Additionally, the following optional settings can be set: diff --git a/services/search/pkg/config/content.go b/services/search/pkg/config/content.go index 515db03b63..bd9baa7a82 100644 --- a/services/search/pkg/config/content.go +++ b/services/search/pkg/config/content.go @@ -9,6 +9,6 @@ type Extractor struct { // ExtractorTika configures the Tika extractor type ExtractorTika struct { - TikaURL string `yaml:"tika_url" env:"SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"` + TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"` CleanStopWords bool `yaml:"clean_stop_words" env:"SEARCH_EXTRACTOR_TIKA_CLEAN_STOP_WORDS" desc:"Defines if stop words should be cleaned or not. See the documentation for more details." introductionVersion:"1.0.0"` } diff --git a/services/webdav/README.md b/services/webdav/README.md index 68952561a2..037dcf6c83 100644 --- a/services/webdav/README.md +++ b/services/webdav/README.md @@ -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. 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. #### Thumbnail Query String Parameters diff --git a/services/webdav/pkg/config/config.go b/services/webdav/pkg/config/config.go index f071bcd187..8ea9131c49 100644 --- a/services/webdav/pkg/config/config.go +++ b/services/webdav/pkg/config/config.go @@ -38,6 +38,9 @@ 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%%"` + ThumbnailFormats []string `yaml:"thumbnail_formats" env:"OC_THUMBNAIL_FORMATS;WEBDAV_THUMBNAIL_FORMATS" desc:"The type a thumbnail is written in, per type of the image it is made from, as mime:mime entries, e.g. image/vnd.microsoft.icon:image/png. A type that is not listed, or one the generator cannot write, keeps the format of the image itself." introductionVersion:"%%NEXT%%"` Context context.Context `yaml:"-"` } diff --git a/services/webdav/pkg/config/defaults/defaultconfig.go b/services/webdav/pkg/config/defaults/defaultconfig.go index fa852eddde..63b9dbe523 100644 --- a/services/webdav/pkg/config/defaults/defaultconfig.go +++ b/services/webdav/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,7 @@ package defaults import ( + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" "path" "strings" @@ -48,6 +49,7 @@ func DefaultConfig() *config.Config { ThumbnailGeneratorURL: "http://127.0.0.1:9186", ThumbnailGeneratorTimeout: "30s", MaxInputFileSize: "50MB", + ThumbnailFormats: thumbnail.DefaultFormats, ThumbnailCacheBackend: "file", ThumbnailCacheDir: path.Join(cored.BaseDataPath(), "thumbnails", "files"), ThumbnailResolutions: []string{ diff --git a/services/webdav/pkg/preprocessor/errors.go b/services/webdav/pkg/preprocessor/errors.go index 72625bdd2f..98402cd37f 100644 --- a/services/webdav/pkg/preprocessor/errors.go +++ b/services/webdav/pkg/preprocessor/errors.go @@ -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") ) diff --git a/services/webdav/pkg/preprocessor/preprocessor.go b/services/webdav/pkg/preprocessor/preprocessor.go index 0bc562f68b..d8cd12698b 100644 --- a/services/webdav/pkg/preprocessor/preprocessor.go +++ b/services/webdav/pkg/preprocessor/preprocessor.go @@ -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,22 @@ 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{}) } } + +// Encoded is an image a converter hands on as it found it. The type decides +// the format the thumbnail is written in. +type Encoded struct { + ContentType string + Data []byte +} diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go new file mode 100644 index 0000000000..bf224b6ef0 --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika.go @@ -0,0 +1,125 @@ +package preprocessor + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "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. +type TikaThumbnail struct { + tikaURL string + // detection hints + filename string + contentType string +} + +// Convert hands the thumbnail on undecoded: the generator reads formats webdav +// cannot, an .ico icon group out of a PE binary for one. +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 Encoded{ContentType: contentType, Data: img}, nil +} + +// 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/preset/thumbnail", bytes.NewReader(data)) + if err != nil { + return "", nil, err + } + req.Header.Set("Content-Type", contentType) + 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)) + } + + 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 + 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) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTikaResponse)) + if err != nil { + return "", nil, fmt.Errorf("tika unpack 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) + } + return thumbnailFromZip(zr) +} + +// 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 f.FileInfo().IsDir() || strings.HasSuffix(f.Name, ".metadata.json") { + continue + } + 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 +} + +// 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 http.DetectContentType(data) +} + +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)) +} diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go new file mode 100644 index 0000000000..38194e6660 --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -0,0 +1,145 @@ +package preprocessor + +import ( + "archive/zip" + "bytes" + "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" +) + +// 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 { + w, err := zw.Create(name) + Expect(err).ToNot(HaveOccurred()) + _, err = w.Write(data) + Expect(err).ToNot(HaveOccurred()) + } + 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}) + 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/preset/thumbnail" { + w.WriteHeader(http.StatusNotFound) + return + } + thumbnail(w, r) + })) + }) + + AfterEach(func() { + server.Close() + }) + + 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(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"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil()) + Expect(requests).To(HaveLen(1)) + }) + + 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(presetZip(map[string][]byte{"thumbnail": pngBytes()})) + } + + img, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil(), "the bytes name the type when the entry does not") + }) + + 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 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) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ContainSubstring("500"))) + }) + + 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") + }) +}) + +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")) + }) +}) diff --git a/services/webdav/pkg/service/v0/service.go b/services/webdav/pkg/service/v0/service.go index d905d64db2..192d8b2464 100644 --- a/services/webdav/pkg/service/v0/service.go +++ b/services/webdav/pkg/service/v0/service.go @@ -119,6 +119,8 @@ 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.WithFormats(thumbnail.NewFormats(conf.ThumbnailFormats)), workflow.WithLogger(options.Logger), workflow.WithStater(workflow.NewGatewayStater(gatewaySelector)), workflow.WithFileDownloader(workflow.NewGatewayFileDownloader(gatewaySelector, httpClient)), @@ -405,6 +407,11 @@ func (g Webdav) handleWorkflowError(w http.ResponseWriter, r *http.Request, err renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) return } + if errors.Is(err, workflow.ErrNoThumbnail) { + logger.Debug().Err(err).Msg("the file carries no thumbnail") + renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) + return + } // Anything else (generator down, download failure, timeout, ...) is a server // error: clients must not cache it as "no preview" the way they do 404s. logger.Error().Err(err).Msg("thumbnail workflow failed") @@ -442,6 +449,11 @@ func (g Webdav) handleHeadError(w http.ResponseWriter, r *http.Request, err erro renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) return } + if errors.Is(err, workflow.ErrNoThumbnail) { + logger.Debug().Err(err).Msg("the file carries no thumbnail") + renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) + return + } logger.Error().Err(err).Msg("thumbnail head check failed") renderError(w, r, errInternalError("could not check thumbnail")) } diff --git a/services/webdav/pkg/service/v0/thumbnail_errors_test.go b/services/webdav/pkg/service/v0/thumbnail_errors_test.go new file mode 100644 index 0000000000..488653f49e --- /dev/null +++ b/services/webdav/pkg/service/v0/thumbnail_errors_test.go @@ -0,0 +1,46 @@ +package svc + +import ( + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/dav/requests" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail/workflow" +) + +var _ = Describe("thumbnail error mapping", func() { + var ( + g Webdav + tr *requests.ThumbnailRequest + logger log.Logger + ) + + BeforeEach(func() { + logger = log.NopLogger() + g = Webdav{log: logger} + tr = &requests.ThumbnailRequest{Filename: "song.mp3"} + }) + + record := func(handle func(http.ResponseWriter, *http.Request, error, *requests.ThumbnailRequest, log.Logger), err error) int { + rr := httptest.NewRecorder() + handle(rr, httptest.NewRequest(http.MethodGet, "/song.mp3", nil), err, tr, logger) + return rr.Code + } + + DescribeTable("a file without a thumbnail is not a server error", + func(handle func(http.ResponseWriter, *http.Request, error, *requests.ThumbnailRequest, log.Logger)) { + // clients cache a 404 as "no preview"; a 500 makes them retry forever + Expect(record(handle, workflow.ErrNoThumbnail)).To(Equal(http.StatusNotFound)) + }, + Entry("on GET", func(w http.ResponseWriter, r *http.Request, err error, tr *requests.ThumbnailRequest, l log.Logger) { + g.handleWorkflowError(w, r, err, tr, l) + }), + Entry("on HEAD", func(w http.ResponseWriter, r *http.Request, err error, tr *requests.ThumbnailRequest, l log.Logger) { + g.handleHeadError(w, r, err, tr, l) + }), + ) +}) diff --git a/services/webdav/pkg/thumbnail/formats.go b/services/webdav/pkg/thumbnail/formats.go new file mode 100644 index 0000000000..58acc60b84 --- /dev/null +++ b/services/webdav/pkg/thumbnail/formats.go @@ -0,0 +1,41 @@ +package thumbnail + +import ( + "mime" + "strings" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/generator" +) + +// No generator writes an icon back, and the format they fall back to is JPEG, +// which has no alpha channel. +var DefaultFormats = []string{ + "image/vnd.microsoft.icon:image/png", + "image/x-icon:image/png", +} + +// Formats says which type a thumbnail of an image is written in, for the types +// where the image's own is not the answer. +type Formats struct { + byType map[string]string +} + +func NewFormats(entries []string) Formats { + f := Formats{byType: map[string]string{}} + for _, entry := range entries { + source, target, _ := strings.Cut(strings.ToLower(strings.TrimSpace(entry)), ":") + if source = strings.TrimSpace(source); source != "" { + f.byType[source] = strings.TrimSpace(target) + } + } + return f +} + +// ExtFor is the generator's name for the format, "" when nothing is mapped. +func (f Formats) ExtFor(contentType string) string { + m, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "" + } + return generator.ExtForMime(f.byType[m]) +} diff --git a/services/webdav/pkg/thumbnail/formats_test.go b/services/webdav/pkg/thumbnail/formats_test.go new file mode 100644 index 0000000000..761c81bb9b --- /dev/null +++ b/services/webdav/pkg/thumbnail/formats_test.go @@ -0,0 +1,36 @@ +package thumbnail_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +var _ = Describe("Formats", func() { + It("writes an icon as a png by default, since no generator writes icons", func() { + f := thumbnail.NewFormats(thumbnail.DefaultFormats) + Expect(f.ExtFor("image/vnd.microsoft.icon")).To(Equal("png")) + Expect(f.ExtFor("image/x-icon")).To(Equal("png")) + }) + + It("leaves every other type to the caller", func() { + f := thumbnail.NewFormats(thumbnail.DefaultFormats) + Expect(f.ExtFor("image/png")).To(BeEmpty()) + Expect(f.ExtFor("image/jpeg")).To(BeEmpty()) + }) + + It("takes the mapping from the configuration", func() { + f := thumbnail.NewFormats([]string{" Image/TIFF : Image/PNG ", "image/bmp:image/jpeg"}) + Expect(f.ExtFor("image/tiff; charset=binary")).To(Equal("png")) + Expect(f.ExtFor("image/bmp")).To(Equal("jpg")) + Expect(f.ExtFor("image/vnd.microsoft.icon")).To(BeEmpty(), "nothing is mapped unless configured") + }) +}) + +var _ = Describe("Formats, types the generator cannot write", func() { + It("ignores a target the generator has no format for", func() { + f := thumbnail.NewFormats([]string{"image/x-icon:image/vnd.microsoft.icon"}) + Expect(f.ExtFor("image/x-icon")).To(BeEmpty(), "an icon is what we are mapping away from") + }) +}) diff --git a/services/webdav/pkg/thumbnail/thumbnail_suite_test.go b/services/webdav/pkg/thumbnail/thumbnail_suite_test.go new file mode 100644 index 0000000000..7217b6a146 --- /dev/null +++ b/services/webdav/pkg/thumbnail/thumbnail_suite_test.go @@ -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") +} diff --git a/services/webdav/pkg/thumbnail/tika.go b/services/webdav/pkg/thumbnail/tika.go new file mode 100644 index 0000000000..ab4749a9da --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika.go @@ -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 +} diff --git a/services/webdav/pkg/thumbnail/tika_test.go b/services/webdav/pkg/thumbnail/tika_test.go new file mode 100644 index 0000000000..9b5c2ad23d --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika_test.go @@ -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"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/workflow/encode_imaging.go b/services/webdav/pkg/thumbnail/workflow/encode_imaging.go index e732dd9d25..e92608b219 100644 --- a/services/webdav/pkg/thumbnail/workflow/encode_imaging.go +++ b/services/webdav/pkg/thumbnail/workflow/encode_imaging.go @@ -10,7 +10,7 @@ import ( "image/jpeg" "image/png" - "github.com/opencloud-eu/opencloud/services/webdav/pkg/generator" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/preprocessor" ) // encodeForUpload turns the value produced by the preprocessing step into @@ -19,7 +19,9 @@ import ( func encodeForUpload(v any, mimeType string) ([]byte, string, error) { switch data := v.(type) { case []byte: - return data, generator.MimeToExt(mimeType), nil + return data, mimeType, nil + case preprocessor.Encoded: + return data.Data, data.ContentType, nil case image.Image: var ( buf bytes.Buffer @@ -27,16 +29,16 @@ func encodeForUpload(v any, mimeType string) ([]byte, string, error) { ) if mimeType == "image/jpeg" || mimeType == "image/jpg" { err = jpeg.Encode(&buf, data, &jpeg.Options{Quality: 85}) - return buf.Bytes(), "jpg", err + return buf.Bytes(), "image/jpeg", err } err = png.Encode(&buf, data) - return buf.Bytes(), "png", err + return buf.Bytes(), "image/png", err case *gif.GIF: var buf bytes.Buffer if err := gif.EncodeAll(&buf, data); err != nil { return nil, "", fmt.Errorf("encode gif: %w", err) } - return buf.Bytes(), "gif", nil + return buf.Bytes(), "image/gif", nil default: return nil, "", fmt.Errorf("unsupported converted type %T", v) } diff --git a/services/webdav/pkg/thumbnail/workflow/encode_vips.go b/services/webdav/pkg/thumbnail/workflow/encode_vips.go index d3bce98a54..6e7e3bafa1 100644 --- a/services/webdav/pkg/thumbnail/workflow/encode_vips.go +++ b/services/webdav/pkg/thumbnail/workflow/encode_vips.go @@ -9,7 +9,7 @@ import ( vips "github.com/davidbyttow/govips/v2/vips" - "github.com/opencloud-eu/opencloud/services/webdav/pkg/generator" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/preprocessor" ) // encodeForUpload turns the value produced by the preprocessing step into @@ -18,7 +18,9 @@ import ( func encodeForUpload(v any, mimeType string) ([]byte, string, error) { switch data := v.(type) { case []byte: - return data, generator.MimeToExt(mimeType), nil + return data, mimeType, nil + case preprocessor.Encoded: + return data.Data, data.ContentType, nil case image.Image: img, err := vips.NewImageFromGoImage(data) if err != nil { @@ -28,10 +30,10 @@ func encodeForUpload(v any, mimeType string) ([]byte, string, error) { if mimeType == "image/jpeg" || mimeType == "image/jpg" { buf, _, err := img.ExportJpeg(&vips.JpegExportParams{Quality: 85}) - return buf, "jpg", err + return buf, "image/jpeg", err } buf, _, err := img.ExportPng(&vips.PngExportParams{}) - return buf, "png", err + return buf, "image/png", err case *gif.GIF: if len(data.Image) == 0 { return nil, "", fmt.Errorf("gif has no frames") @@ -43,7 +45,7 @@ func encodeForUpload(v any, mimeType string) ([]byte, string, error) { defer img.Close() buf, _, err := img.ExportGIF(&vips.GifExportParams{}) - return buf, "gif", err + return buf, "image/gif", err default: return nil, "", fmt.Errorf("unsupported converted type %T", v) } diff --git a/services/webdav/pkg/thumbnail/workflow/format_test.go b/services/webdav/pkg/thumbnail/workflow/format_test.go new file mode 100644 index 0000000000..a7d42324b6 --- /dev/null +++ b/services/webdav/pkg/thumbnail/workflow/format_test.go @@ -0,0 +1,18 @@ +package workflow + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("thumbnail format", func() { + DescribeTable("extOf reads the format back out of cached bytes", + func(data []byte, ext string) { + Expect(extOf(data, "fallback")).To(Equal(ext)) + }, + Entry("png", []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, "png"), + Entry("jpeg", []byte{0xff, 0xd8, 0xff, 0xe0}, "jpg"), + Entry("gif", []byte("GIF89a"), "gif"), + Entry("anything else keeps the fallback", []byte("whatever"), "fallback"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index 6acc5b48c6..9eebacfa7e 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -69,6 +69,26 @@ type UserResolver interface { // surface this as HTTP 425 Too Early with a Retry-After header. var ErrFileProcessing = fmt.Errorf("file is processing") +// keys a cache entry before the format is known, so it is found without +// downloading the file +const tikaExtPlaceholder = "tika" + +// extOf names the format of bytes cached under the placeholder. +func extOf(data []byte, fallback string) string { + switch { + case bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G'}): + return "png" + case bytes.HasPrefix(data, []byte{0xff, 0xd8}): + return "jpg" + case bytes.HasPrefix(data, []byte("GIF8")): + return "gif" + } + return fallback +} + +// 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 +158,8 @@ type ThumbnailWorkflow struct { maxInputSize uint64 resolutions *thumbnail.Resolutions webdavNS string + tika thumbnail.Tika + formats thumbnail.Formats fontMapFile string log log.Logger stater Stater @@ -207,6 +229,15 @@ 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 WithFormats(f thumbnail.Formats) Option { + return func(w *ThumbnailWorkflow) { w.formats = f } +} + func WithFontMapFile(file string) Option { return func(w *ThumbnailWorkflow) { w.fontMapFile = file } } @@ -285,7 +316,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 +424,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()) } @@ -418,6 +449,14 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R if ext := generator.ExtForMime(mimeType); ext != "" { outputExt = ext } + if ext := w.formats.ExtFor(mimeType); ext != "" { + outputExt = ext + } + // the format follows the type Tika answers with, which is not known yet + fromTika := !strings.HasPrefix(mimeType, "image/") && w.tika.Supports(mimeType) + if fromTika { + outputExt = tikaExtPlaceholder + } // The operation is resolved up front so it can be part of the cache key: a // fill and a fit-in request with the same dimensions produce different images @@ -434,7 +473,11 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R if w.cache != nil { if cached, err := w.cache.Get(cacheKey); err == nil { - return cached, outputExt, aIgnored, nil + // empty entry: known to have no thumbnail + if len(cached) == 0 { + return nil, "", false, ErrNoThumbnail + } + return cached, extOf(cached, outputExt), aIgnored, nil } } @@ -451,19 +494,32 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R // generator fill that box. Only fit-in suppresses upscaling (noUpscale); for // fill/thumbnail/resize the generator may enlarge a small source (e.g. a text // file rendered at a fixed canvas) to fill the snapped box, matching main. - genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputExt, noUpscale) - // Produce the source image to send to the generator, never decoding an image // in webdav: real images (incl. gif) are streamed straight through undecoded; // non-image sources are converted to image bytes here so the generator always // receives an image. - imgStream, cleanup, err := w.sourceImage(ctx, ref, auth, mimeType, tr.Filename, logger) + imgStream, sourceContentType, cleanup, err := w.sourceImage(ctx, ref, auth, mimeType, tr.Filename, logger) if err != nil { - logger.Error().Err(err).Msg("could not obtain source image for thumbnail") + if errors.Is(err, ErrNoThumbnail) { + // a file that carries no thumbnail is an answer, not a failure + logger.Debug().Err(err).Msg("no source image for thumbnail") + } else { + logger.Error().Err(err).Msg("could not obtain source image for thumbnail") + } return nil, "", false, err } defer cleanup() + if fromTika { + outputExt = generator.OutputFormat(tr.Extension) + if ext := generator.ExtForMime(sourceContentType); ext != "" { + outputExt = ext + } + if ext := w.formats.ExtFor(sourceContentType); ext != "" { + outputExt = ext + } + } + genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputExt, noUpscale) thumbBytes, err := w.postToGenerator(ctx, genURL, imgStream, tr.Filename) if err != nil { logger.Error().Err(err).Msg("could not generate thumbnail") @@ -486,40 +542,50 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R // undecoded; the generator handles decoding and multi-frame gifs itself. // - text/plain is rendered to an image here (the only true conversion). // - audio and geogebra sources have their embedded image extracted to bytes. -func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta1.Reference, auth, mimeType, filename string, logger log.Logger) (io.Reader, func(), error) { +// +// sourceImage returns the source image and the type of its bytes, empty when +// the file is streamed through as it is. +func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta1.Reference, auth, mimeType, filename string, logger log.Logger) (io.Reader, string, func(), error) { m, _, _ := mime.ParseMediaType(mimeType) if strings.HasPrefix(m, "image/") { body, err := w.fileDownloader.DownloadStream(ctx, ref, auth) if err != nil { - return nil, nil, fmt.Errorf("download: %w", err) + return nil, "", nil, fmt.Errorf("download: %w", err) } - return body, func() { _ = body.Close() }, nil + return body, "", func() { _ = body.Close() }, nil } body, err := w.fileDownloader.DownloadStream(ctx, ref, auth) if err != nil { - return nil, nil, fmt.Errorf("download: %w", err) + return nil, "", nil, fmt.Errorf("download: %w", err) } defer body.Close() fileBytes, err := io.ReadAll(body) if err != nil { - return nil, nil, fmt.Errorf("read source: %w", err) + 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) + return nil, "", nil, fmt.Errorf("%w: could not get image", ErrNotFound) } - data, _, err := encodeForUpload(img, mimeType) + data, contentType, err := encodeForUpload(img, mimeType) if err != nil { - return nil, nil, fmt.Errorf("encode converted image: %w", err) + return nil, "", nil, fmt.Errorf("encode converted image: %w", err) } - return bytes.NewReader(data), func() {}, nil + return bytes.NewReader(data), contentType, func() {}, nil } // matchOperation resolves the generator resize/crop operation for a request from @@ -657,6 +723,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) diff --git a/tests/acceptance/docker/src/tika.yml b/tests/acceptance/docker/src/tika.yml index fe8eff86e8..467001dbcc 100644 --- a/tests/acceptance/docker/src/tika.yml +++ b/tests/acceptance/docker/src/tika.yml @@ -5,4 +5,7 @@ services: - tika command: tika:9998 tika: - image: apache/tika:4.0.0-full + image: apache/tika:4.1.0-full + command: ['-c', '/tika-config.json'] + volumes: + - ../../../config/woodpecker/tika-config.json:/tika-config.json diff --git a/tests/config/woodpecker/tika-config.json b/tests/config/woodpecker/tika-config.json new file mode 100644 index 0000000000..5e0ded5f0a --- /dev/null +++ b/tests/config/woodpecker/tika-config.json @@ -0,0 +1,12 @@ +{ + // -h and -p still come from the command line; a config file has to carry the block + "server": { + "port": 9998, + "host": "0.0.0.0" + }, + // the thumbnail preset ships inert in tika-pipes-core; activating it is what serves + // PUT /unpack/preset/thumbnail, which the webdav thumbnailer asks for + "presets": { + "thumbnail": true + } +}