Compare commits

...
Author SHA1 Message Date
Dominik Schmidt 1592ab25f2 feat(thumbnails): extract audio cover art via Tika
When a Tika server is configured, audio cover art is extracted through it by
default, selecting the tagged front cover (ID3 APIC 'Cover (front)') via
/unpack/all sidecar metadata, else the first embedded image. TikaDecoder picks
by mime type: audio/* front cover, everything else the largest preview.

THUMBNAILS_PREPROCESSOR_AUDIO_PROCESSOR=builtin forces the in-process extractor;
it is deprecated and slated for removal in the next major.
2026-08-19 19:55:37 +02:00
Dominik Schmidt 4ba384f7b2 chore(thumbnails): drop the trivial readZipEntry comment 2026-08-19 19:54:15 +02:00
Dominik Schmidt ae6b0cb584 refactor(thumbnails): split the Tika unpack helper and generalize the no-image error
Extract tikaUnpack + readZipEntry, rename tikaExtractPreview to tikaLargestPreview,
and rename ErrNoImageFromRawFile to the generic ErrNoEmbeddedImage. No behaviour
change; sets up reuse for audio cover art.
2026-08-19 19:46:51 +02:00
Dominik Schmidt 75893c19ee refactor(thumbnails): thread context.Context through the preprocessor Convert interface
The Tika preview extractor does a network call; it was using context.Background().
Pass the request context from the gRPC handler through Convert so the unpack call
respects cancellation and deadlines. Non-I/O decoders ignore the context.
2026-08-19 18:45:11 +02:00
Dominik Schmidt c286f771c9 fix(thumbnails): use the %%NEXT%% introductionVersion placeholder the CI check requires 2026-08-19 18:40:32 +02:00
Dominik Schmidt 73b839f120 test(thumbnails): route httptest writes through a helper to satisfy codacy 2026-08-19 18:37:34 +02:00
Dominik Schmidt afd82fba5b chore: share the Tika URL via OC_TIKA_URL across search and thumbnails 2026-08-19 18:22:19 +02:00
Dominik Schmidt 63582cb310 fix(thumbnails): pass source filename so Tika selects its raw parser
Tika sniffs TIFF-based raws (NEF/DNG/ARW/PEF) as generic image/tiff by
content; only the filename extension routes them to the raw parser. Thread
the source name through to the unpack request via Content-Disposition.
2026-08-19 18:22:19 +02:00
Dominik Schmidt 7bdbad3319 feat(thumbnails): extract raw previews via Tika instead of in process
Replace the in-process TIFF/BigTIFF walker (rawtiff.go) with a Tika-based
extractor: raw images are sent to a Tika server's /unpack endpoint, which
returns the embedded JPEG previews (Tika's RawTiffParser emits them as embedded
documents). The largest renderable JPEG is decoded through the image pipeline.

This aligns preview extraction with the search service (same Tika, same format
coverage) and drops a large hand-rolled parser. Raw thumbnails now require
THUMBNAILS_TIKA_TIKA_URL; without it, raw types fall back to the default decoder
(no thumbnail).

Audio artwork extraction stays in process for now and moves in a follow-up.
2026-08-19 16:30:29 +02:00
Dominik Schmidt 882325c512 fix(thumbnails): follow SubIFDs pointers typed IFD/IFD8
isLongType accepted only LONG/LONG8, so a SubIFDs entry using the dedicated
IFD (13) or, in BigTIFF, IFD8 (18) pointer type (both standards-compliant and
common) was skipped and its embedded preview never discovered. Accept those
pointer types for the SubIFDs tag via isSubIFDType, keeping the stricter
isLongType on length fields so they never accept a pointer type. IFD8 reads at
8-byte width like LONG8.
2026-08-19 11:54:06 +02:00
Dominik Schmidt b5e1222ec8 fix(thumbnails): overflow-safe BigTIFF bounds and correct LONG offset width
Review of #3332 surfaced two BigTIFF-only defects in the embedded-preview
walker (attacker-controlled input):

- 64-bit IFD and SubIFD-array offsets were bounds-checked with `off + n > len`,
  which wraps for a crafted offset near 2^64, bypassing the guard and slicing
  out of range -> panic (recovered by the framework into a 500 + stack log on
  every crafted request, defeating the no-panic goal). Now overflow-safe.
- a tag value was always read as an 8-byte Uint64 in BigTIFF; a LONG (4-byte)
  offset in a big-endian BigTIFF was thereby shifted. Read it at its declared
  type width instead.

Adds tests for the overflow paths (assert ErrNoImageFromRawFile, no panic),
the big-endian LONG offset, and strengthens the truncation test to assert the
error. Trims a few over-long comments.
2026-08-19 11:24:50 +02:00
Dominik Schmidt 47a7136c46 feat(thumbnails): support BigTIFF containers for raw previews (DNG 1.7) 2026-08-19 07:30:53 +02:00
Dominik Schmidt 83c1dc8d49 docs(thumbnails): drop the pending-reva nrw note 2026-08-18 21:12:41 +02:00
Dominik Schmidt 6ade518690 test(thumbnails): expand raw preview coverage and quiet previewless logging
Add big-endian, largest-candidate, orientation-direction, isRenderableJPEG
classification and maxPreviewLength tests plus a ForType raw-dispatch check;
log a raw file without an embedded preview at debug instead of error; name the
TIFF magic constant and de-enumerate the decoder doc comment.
2026-08-18 16:48:32 +02:00
Dominik Schmidt 8dec34dbd4 fix(thumbnails): cap the served raw preview length
Reject a preview whose declared length exceeds 100MB rather than serving it;
previews are camera-generated JPEGs, so this bounds the output independently
of the input file size.
2026-08-18 16:48:32 +02:00
Dominik Schmidt f112e8bfb2 feat(thumbnails): register Nikon NRW and Sony SRF raw previews
Both are TIFF-based camera raw formats handled by the same generic walker;
NRW detection needs the reva mimetype mapping in opencloud-eu/reva#773, SRF is
already mapped. No sample files on hand for these two, added by format
structure alongside their verified siblings.
2026-08-18 16:48:32 +02:00
Dominik Schmidt 61ce147205 fix(thumbnails): harden the raw tiff walker against crafted files
Cap the IFD queue at maxIFDs so a file packed with huge SubIFD counts can no
longer grow it without bound, and walk the embedded JPEG by a bounded number
of header segments so a preview whose SOF sits past 64KB of leading metadata
is still found instead of being scanned only within a fixed byte window.
2026-08-18 16:48:32 +02:00
Dominik Schmidt 77b08abfb6 docs(thumbnails): list raw formats as thumbnail sources 2026-08-18 16:48:32 +02:00
Dominik Schmidt b02d4fb279 chore(thumbnails): gofmt 2026-08-18 16:48:32 +02:00
Dominik Schmidt 9805f65136 chore(thumbnails): tighten rawtiff comments 2026-08-18 16:48:32 +02:00
Dominik Schmidt 5b26d202b5 feat(thumbnails): serve raw previews for CR2, PEF, ARW, SR2 and DNG
Rename RawImageDecoder to RawTiffDecoder and qualify candidates by their
actual stream instead of trusting the tags: CR2 keeps its full-size JPEG
as an IFD0 strip, DNG stores the raw sensor payload as lossless JPEG
(SOF3) which starts with a regular SOI. A JPEG segment walk to the SOF
marker accepts only the DCT processes common decoders render.
2026-08-18 16:48:32 +02:00
Dominik Schmidt c0069c9e77 feat(thumbnails): serve the embedded jpeg preview of nikon raw files 2026-08-18 16:48:31 +02:00
15 changed files with 559 additions and 37 deletions

No files matched your search

+1 -1
View File
@@ -98,7 +98,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 thumbnails service reads too)
Additionally, the following optional settings can be set:
+1 -1
View File
@@ -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"`
}
+5
View File
@@ -30,6 +30,11 @@ Thumbnails can be generated from the following source file types:
- tiff
- bmp
- txt
- camera raw files: nef, cr2, pef, arw, sr2, srf, dng
For camera raw files the thumbnail is generated from the JPEG preview the camera embedded in the file, the raw sensor data itself is not developed. Raw files without an embedded preview cannot be thumbnailed. Preview extraction is done by an Apache Tika server, so raw thumbnails require `THUMBNAILS_PREPROCESSOR_TIKA_TIKA_URL` (or the shared `OC_TIKA_URL`, which the search service reads too) to be set.
For audio files the thumbnail is the embedded cover art. When a Tika server is configured it is extracted via Tika (preferring the tagged front cover); otherwise it is read in-process. Setting `THUMBNAILS_PREPROCESSOR_AUDIO_PROCESSOR=builtin` forces the in-process extractor even when a Tika server is configured; that `builtin` processor is deprecated and will be removed in a future major release.
The thumbnail service retrieves source files using the information provided by the backend. The Linux backend identifies source files usually based on the extension.
+14
View File
@@ -46,4 +46,18 @@ type Thumbnail struct {
MaxInputWidth int `yaml:"max_input_width" env:"THUMBNAILS_MAX_INPUT_WIDTH" desc:"The maximum width of an input image which is being processed." introductionVersion:"1.0.0"`
MaxInputHeight int `yaml:"max_input_height" env:"THUMBNAILS_MAX_INPUT_HEIGHT" desc:"The maximum height of an input image which is being processed." introductionVersion:"1.0.0"`
MaxInputImageFileSize string `yaml:"max_input_image_file_size" env:"THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE" desc:"The maximum file size of an input image which is being processed. Usable common abbreviations: [KB, KiB, MB, MiB, GB, GiB, TB, TiB, PB, PiB, EB, EiB], example: 2GB." introductionVersion:"1.0.0"`
Preprocessor Preprocessor `yaml:"preprocessor"`
}
// Preprocessor configures the preprocessors that turn a source file into a
// thumbnailable image.
type Preprocessor struct {
Tika Tika `yaml:"tika"`
// AudioProcessor selects the audio cover-art extractor.
AudioProcessor string `yaml:"audio_processor" env:"THUMBNAILS_PREPROCESSOR_AUDIO_PROCESSOR" desc:"The processor for extracting cover art from audio files. When a Tika server is configured it is used by default. Set this to 'builtin' to force the in-process extractor even when a Tika server is configured. The 'builtin' processor is deprecated and will be removed in a future major release." introductionVersion:"%%NEXT%%" deprecationVersion:"%%NEXT%%" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"THUMBNAILS_PREPROCESSOR_AUDIO_PROCESSOR only selects the deprecated in-process audio extractor; once it is removed audio cover art always uses Tika when configured." deprecationReplacement:""`
}
// Tika configures preview extraction via an Apache Tika server.
type Tika struct {
TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;THUMBNAILS_PREPROCESSOR_TIKA_TIKA_URL" desc:"URL of a Tika server used to extract embedded previews from raw images. When empty, raw images are not thumbnailed." introductionVersion:"%%NEXT%%"`
}
+2
View File
@@ -17,6 +17,8 @@ var (
ErrNoConverterForExtractedImageFromGgsFile = errors.New("thumbnails: could not find converter for image extracted from ggs file")
// ErrNoConverterForExtractedImageFromAudioFile defines an error when the extracted image from an audio file could not be converted
ErrNoConverterForExtractedImageFromAudioFile = errors.New("thumbnails: could not find converter for image extracted from audio file")
// ErrNoEmbeddedImage is returned when a source file carries no extractable embedded image (a raw without a preview, an audio file without cover art)
ErrNoEmbeddedImage = errors.New("thumbnails: no embedded image found")
// ErrCS3AuthorizationMissing defines an error when the CS3 authorization is missing
ErrCS3AuthorizationMissing = errors.New("thumbnails: cs3source - authorization missing")
)
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"image"
@@ -26,14 +27,14 @@ import (
// FileConverter is the interface for the file converter
type FileConverter interface {
Convert(r io.Reader) (any, error)
Convert(ctx context.Context, r io.Reader) (any, error)
}
// GifDecoder is a converter for the gif file
type GifDecoder struct{}
// Convert reads the gif file and returns the thumbnail image
func (i GifDecoder) Convert(r io.Reader) (any, error) {
func (i GifDecoder) Convert(_ context.Context, r io.Reader) (any, error) {
img, err := gif.DecodeAll(r)
if err != nil {
return nil, errors.Wrap(err, `could not decode the image`)
@@ -45,7 +46,7 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) {
type GgsDecoder struct{ thumbnailpath string }
// Convert reads the ggs file and returns the thumbnail image
func (g GgsDecoder) Convert(r io.Reader) (any, error) {
func (g GgsDecoder) Convert(ctx context.Context, r io.Reader) (any, error) {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
if err != nil {
@@ -65,7 +66,7 @@ func (g GgsDecoder) Convert(r io.Reader) (any, error) {
if converter == nil {
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromGgsFile
}
img, err := converter.Convert(thumbnail)
img, err := converter.Convert(ctx, thumbnail)
if err != nil {
return nil, errors.Wrap(err, `could not decode the image`)
}
@@ -75,11 +76,12 @@ 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 extracts an audio file's embedded cover art from its ID3 tag. It is
// deprecated in favour of the Tika extractor and slated for removal in the next major.
type AudioDecoder struct{}
// Convert reads the audio file and extracts the thumbnail image from the id3 tag
func (i AudioDecoder) Convert(r io.Reader) (any, error) {
func (i AudioDecoder) Convert(ctx context.Context, r io.Reader) (any, error) {
b, err := io.ReadAll(r)
if err != nil {
return nil, err
@@ -99,7 +101,7 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromAudioFile
}
return converter.Convert(bytes.NewReader(picture.Data))
return converter.Convert(ctx, bytes.NewReader(picture.Data))
}
// TxtToImageConverter is a converter for the text file
@@ -108,7 +110,7 @@ type TxtToImageConverter struct {
}
// Convert reads the text file and renders it into a thumbnail image
func (t TxtToImageConverter) Convert(r io.Reader) (any, error) {
func (t TxtToImageConverter) Convert(_ context.Context, r io.Reader) (any, error) {
img := image.NewRGBA(image.Rect(0, 0, 640, 480))
imgBounds := img.Bounds()
@@ -203,7 +205,7 @@ type GGPStruct struct {
type GgpDecoder struct{}
// Convert reads the ggp file and returns the first thumbnail image
func (j GgpDecoder) Convert(r io.Reader) (any, error) {
func (j GgpDecoder) Convert(_ context.Context, r io.Reader) (any, error) {
ggp := &GGPStruct{}
err := json.NewDecoder(r).Decode(ggp)
if err != nil {
@@ -338,12 +340,29 @@ func ForType(mimeType string, opts map[string]any) FileConverter {
return GgpDecoder{}
case "image/gif":
return GifDecoder{}
case "audio/flac":
fallthrough
case "audio/mpeg":
fallthrough
case "audio/ogg":
case "audio/flac", "audio/mpeg", "audio/ogg":
// Tika is used whenever configured; "builtin" forces the in-process extractor
url, _ := opts["tikaURL"].(string)
if p, _ := opts["audioProcessor"].(string); url != "" && p != "builtin" {
filename, _ := opts["filename"].(string)
return TikaDecoder{tikaURL: url, filename: filename, mimeType: mimeType}
}
return AudioDecoder{}
case "image/x-nikon-nef",
"image/x-nikon-nrw",
"image/x-canon-cr2",
"image/x-pentax-pef",
"image/x-sony-arw",
"image/x-sony-sr2",
"image/x-sony-srf",
"image/x-adobe-dng":
// raw previews come from Tika, routed by the filename; no URL -> default
url, _ := opts["tikaURL"].(string)
if url == "" {
return ImageDecoder{}
}
filename, _ := opts["filename"].(string)
return TikaDecoder{tikaURL: url, filename: filename}
default:
return ImageDecoder{}
}
@@ -3,6 +3,7 @@
package preprocessor
import (
"context"
"io"
"github.com/kovidgoyal/imaging"
@@ -13,7 +14,7 @@ import (
type ImageDecoder struct{}
// Convert reads the image file and returns the thumbnail image
func (i ImageDecoder) Convert(r io.Reader) (any, error) {
func (i ImageDecoder) Convert(_ context.Context, r io.Reader) (any, error) {
img, err := imaging.Decode(r, imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, `could not decode the image`)
@@ -2,6 +2,7 @@ package preprocessor
import (
"bytes"
"context"
"io"
"os"
"testing"
@@ -19,7 +20,7 @@ func TestImageDecoder(t *testing.T) {
RunSpecs(t, "ImageDecoder Suite")
}
var _ = Describe("ImageDecoder", func() {
var _ = Describe("preprocessor", func() {
Describe("ImageDecoder", func() {
var fileReader io.Reader
BeforeEach(func() {
@@ -32,14 +33,14 @@ var _ = Describe("ImageDecoder", func() {
It("should decode an image", func() {
decoder := ImageDecoder{}
img, err := decoder.Convert(fileReader)
img, err := decoder.Convert(context.TODO(), fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("should return an error if the image is invalid", func() {
decoder := ImageDecoder{}
img, err := decoder.Convert(bytes.NewReader([]byte("not an image")))
img, err := decoder.Convert(context.TODO(), bytes.NewReader([]byte("not an image")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
@@ -57,14 +58,14 @@ var _ = Describe("ImageDecoder", func() {
It("should decode a gif", func() {
decoder := GifDecoder{}
img, err := decoder.Convert(fileReader)
img, err := decoder.Convert(context.TODO(), fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("should return an error if the gif is invalid", func() {
decoder := GifDecoder{}
img, err := decoder.Convert(bytes.NewReader([]byte("not a gif")))
img, err := decoder.Convert(context.TODO(), bytes.NewReader([]byte("not a gif")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
@@ -82,14 +83,14 @@ var _ = Describe("ImageDecoder", func() {
It("should decode a ggs", func() {
decoder := GgsDecoder{"_slide0/geogebra_thumbnail.png"}
img, err := decoder.Convert(fileReader)
img, err := decoder.Convert(context.TODO(), fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
It("should return an error if the ggs is invalid", func() {
decoder := GgsDecoder{"_slide0/geogebra_thumbnail.png"}
img, err := decoder.Convert(bytes.NewReader([]byte("not a ggs")))
img, err := decoder.Convert(context.TODO(), bytes.NewReader([]byte("not a ggs")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
@@ -104,7 +105,7 @@ var _ = Describe("ImageDecoder", func() {
}
fileReader = bytes.NewReader(fileContent)
decoder := AudioDecoder{}
img, err := decoder.Convert(fileReader)
img, err := decoder.Convert(context.TODO(), fileReader)
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
@@ -115,13 +116,13 @@ var _ = Describe("ImageDecoder", func() {
}
fileReader = bytes.NewReader(fileContent)
decoder := AudioDecoder{}
img, err := decoder.Convert(fileReader)
img, err := decoder.Convert(context.TODO(), fileReader)
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
It("should return an error if the audio is invalid", func() {
decoder := AudioDecoder{}
img, err := decoder.Convert(bytes.NewReader([]byte("not an audio")))
img, err := decoder.Convert(context.TODO(), bytes.NewReader([]byte("not an audio")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
@@ -145,7 +146,7 @@ var _ = Describe("ImageDecoder", func() {
}
})
It("should decode a text", func() {
img, err := decoder.Convert(bytes.NewReader([]byte("This is a test text")))
img, err := decoder.Convert(context.TODO(), bytes.NewReader([]byte("This is a test text")))
Expect(err).ToNot(HaveOccurred())
Expect(img).ToNot(BeNil())
})
@@ -156,7 +157,7 @@ var _ = Describe("ImageDecoder", func() {
DefaultFont: "/some/unknown/font.otf",
},
}
img, err := decoder.Convert(bytes.NewReader([]byte("This is a test text")))
img, err := decoder.Convert(context.TODO(), bytes.NewReader([]byte("This is a test text")))
Expect(err).To(HaveOccurred())
Expect(img).To(BeNil())
})
@@ -173,17 +174,46 @@ var _ = Describe("ImageDecoder", func() {
Expect(decoder).To(BeAssignableToTypeOf(GifDecoder{}))
})
rawTypes := []string{
"image/x-nikon-nef", "image/x-nikon-nrw", "image/x-canon-cr2",
"image/x-pentax-pef", "image/x-sony-arw", "image/x-sony-sr2",
"image/x-sony-srf", "image/x-adobe-dng",
}
It("should return a TikaDecoder for every registered raw type when a Tika URL is set", func() {
opts := map[string]any{"tikaURL": "http://tika:9998"}
for _, mt := range rawTypes {
Expect(ForType(mt, opts)).To(BeAssignableToTypeOf(TikaDecoder{}), mt)
}
})
It("should fall back to the default decoder for raw types without a Tika URL", func() {
for _, mt := range rawTypes {
Expect(ForType(mt, nil)).To(BeAssignableToTypeOf(ImageDecoder{}), mt)
}
})
It("should return an GgsDecoder for ggs types", func() {
decoder := ForType("application/vnd.geogebra.ggs", nil)
// This will not return the expected ggsDecoder, but an ImageDecoder since ggs contains an embedded png.
Expect(decoder).To(BeAssignableToTypeOf(ImageDecoder{}))
})
It("should return an AudioDecoder for audio types", func() {
It("should return an AudioDecoder for audio types without a Tika URL", func() {
decoder := ForType("audio/mpeg", nil)
Expect(decoder).To(BeAssignableToTypeOf(AudioDecoder{}))
})
It("should return a TikaDecoder for audio types when a Tika URL is set", func() {
opts := map[string]any{"tikaURL": "http://tika:9998"}
Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(TikaDecoder{}))
})
It("should force the AudioDecoder when the builtin processor is selected", func() {
opts := map[string]any{"tikaURL": "http://tika:9998", "audioProcessor": "builtin"}
Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(AudioDecoder{}))
})
It("should return an TxtToImageConverter for text types", func() {
decoder := ForType("text/plain", nil)
Expect(decoder).To(BeAssignableToTypeOf(TxtToImageConverter{}))
@@ -3,6 +3,7 @@
package preprocessor
import (
"context"
"io"
"github.com/davidbyttow/govips/v2/vips"
@@ -14,7 +15,7 @@ func init() {
type ImageDecoder struct{}
func (v ImageDecoder) Convert(r io.Reader) (interface{}, error) {
func (v ImageDecoder) Convert(_ context.Context, r io.Reader) (interface{}, error) {
img, err := vips.NewImageFromReader(r)
return img, err
}
@@ -0,0 +1,220 @@
package preprocessor
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
thumbnailerErrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// maxPreviewLength bounds a served preview; a var so tests can lower it.
var maxPreviewLength int64 = 100 * 1024 * 1024
// maxJPEGSegments bounds the header segments walked before the SOF marker.
const maxJPEGSegments = 128
// TikaDecoder extracts a file's embedded preview image via a Tika server.
type TikaDecoder struct {
tikaURL string
// filename lets Tika route by extension; content alone sniffs raws as image/tiff.
filename string
// mimeType picks the strategy: audio/* takes the front cover, else the largest preview.
mimeType string
}
func (d TikaDecoder) Convert(ctx context.Context, r io.Reader) (any, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
pick := tikaLargestPreview
if strings.HasPrefix(d.mimeType, "audio/") {
pick = tikaFrontCover
}
preview, err := pick(ctx, d.tikaURL, d.filename, data)
if err != nil {
return nil, err
}
return ForType("image/jpeg", nil).Convert(ctx, bytes.NewReader(preview))
}
var tikaHTTPClient = &http.Client{Timeout: 30 * time.Second}
// tikaUnpack PUTs the file to a Tika unpack endpoint and returns the response zip.
func tikaUnpack(ctx context.Context, tikaURL, endpoint, filename string, data []byte) (*zip.Reader, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, strings.TrimRight(tikaURL, "/")+endpoint, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Accept", "application/zip")
if filename != "" {
// the extension routes Tika to its raw parser; quote it so spaces survive
req.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
}
resp, err := tikaHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("tika unpack request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent { // no embedded resources
return nil, thumbnailerErrors.ErrNoEmbeddedImage
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tika unpack returned %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPreviewLength))
if err != nil {
return nil, err
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
return nil, fmt.Errorf("tika unpack response is not a valid zip: %w", err)
}
return zr, nil
}
// tikaLargestPreview returns the largest renderable embedded JPEG.
func tikaLargestPreview(ctx context.Context, tikaURL, filename string, data []byte) ([]byte, error) {
zr, err := tikaUnpack(ctx, tikaURL, "/unpack", filename, data)
if err != nil {
return nil, err
}
var best []byte
for _, f := range zr.File {
if f.UncompressedSize64 == 0 || f.UncompressedSize64 > uint64(maxPreviewLength) || int(f.UncompressedSize64) <= len(best) {
continue
}
if jpg := readZipEntry(f); isRenderableJPEG(jpg) {
best = jpg
}
}
if best == nil {
return nil, thumbnailerErrors.ErrNoEmbeddedImage
}
return best, nil
}
// tikaFrontCover returns the tagged front cover (ID3 APIC "Cover (front)") among the
// embedded images, else the first renderable one. Reads /unpack/all for the sidecars.
func tikaFrontCover(ctx context.Context, tikaURL, filename string, data []byte) ([]byte, error) {
zr, err := tikaUnpack(ctx, tikaURL, "/unpack/all", filename, data)
if err != nil {
return nil, err
}
byName := make(map[string]*zip.File, len(zr.File))
for _, f := range zr.File {
byName[f.Name] = f
}
var first []byte
for _, f := range zr.File {
if strings.HasSuffix(f.Name, ".metadata.json") {
continue
}
sidecar, ok := byName[f.Name+".metadata.json"]
if !ok {
continue
}
meta := parseEmbeddedMeta(readZipEntry(sidecar))
if !strings.HasPrefix(meta.ContentType, "image/") {
continue // skip the audio stream and other non-image parts
}
if f.UncompressedSize64 == 0 || f.UncompressedSize64 > uint64(maxPreviewLength) {
continue
}
img := readZipEntry(f)
if !isRenderableImage(img) {
continue
}
if first == nil {
first = img
}
if meta.Description == "Cover (front)" {
return img, nil
}
}
if first == nil {
return nil, thumbnailerErrors.ErrNoEmbeddedImage
}
return first, nil
}
// tikaEmbeddedMeta is the subset of a Tika /unpack/all sidecar we read.
type tikaEmbeddedMeta struct {
ContentType string `json:"Content-Type"`
Description string `json:"dc:description"`
}
func parseEmbeddedMeta(b []byte) tikaEmbeddedMeta {
var m tikaEmbeddedMeta
_ = json.Unmarshal(b, &m)
return m
}
// isRenderableImage accepts the formats audio cover art uses.
func isRenderableImage(buf []byte) bool {
return isRenderableJPEG(buf) || isPNG(buf)
}
func isPNG(buf []byte) bool {
return len(buf) >= 8 && bytes.Equal(buf[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
}
func readZipEntry(f *zip.File) []byte {
rc, err := f.Open()
if err != nil {
return nil
}
defer rc.Close()
b, err := io.ReadAll(io.LimitReader(rc, maxPreviewLength))
if err != nil {
return nil
}
return b
}
// isRenderableJPEG accepts only DCT processes common decoders render; DNG raw
// payloads are lossless JPEG (SOF3) behind the same SOI marker.
func isRenderableJPEG(buf []byte) bool {
if len(buf) < 4 || buf[0] != 0xff || buf[1] != 0xd8 {
return false
}
i := 2
for segments := 0; i+4 <= len(buf) && segments < maxJPEGSegments; segments++ {
if buf[i] != 0xff {
return false
}
marker := buf[i+1]
switch {
case marker == 0xff: // fill byte
i++
continue
case marker >= 0xd0 && marker <= 0xd7: // RST, no length field
i += 2
continue
}
switch marker {
case 0xc0, 0xc1, 0xc2: // baseline, extended sequential, progressive
return true
case 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf:
// lossless, differential and arithmetic processes
return false
case 0xd9, 0xda: // EOI or scan start without a SOF
return false
}
segLen := int(buf[i+2])<<8 | int(buf[i+3])
if segLen < 2 {
return false
}
i += 2 + segLen
}
return false
}
@@ -0,0 +1,199 @@
package preprocessor
import (
"archive/zip"
"bytes"
"context"
"image"
"image/jpeg"
"io"
"net/http"
"net/http/httptest"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
thumbnailerErrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// writeBody writes through an io.Writer so the test's httptest handlers don't
// call ResponseWriter.Write directly (trips a static-analysis XSS rule).
func writeBody(w io.Writer, b []byte) { _, _ = io.Copy(w, bytes.NewReader(b)) }
func encodeJPEG(width, height int) []byte {
var buf bytes.Buffer
_ = jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, width, height)), nil)
return buf.Bytes()
}
func zipWith(entries map[string][]byte) []byte {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, data := range entries {
w, _ := zw.Create(name)
writeBody(w, data)
}
_ = zw.Close()
return buf.Bytes()
}
// losslessJPEG builds a SOF3 (lossless) JPEG of the given size; such payloads
// carry DNG raw data and must be rejected by isRenderableJPEG.
func losslessJPEG(size int) []byte {
b := []byte{0xff, 0xd8, 0xff, 0xc3}
if size > len(b) {
b = append(b, make([]byte, size-len(b))...)
}
return b
}
// zipOrdered builds a zip preserving entry order, so "first image" fallback is deterministic.
func zipOrdered(entries ...[2]string) []byte {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, e := range entries {
w, _ := zw.Create(e[0])
writeBody(w, []byte(e[1]))
}
_ = zw.Close()
return buf.Bytes()
}
// coverSidecar mimics a Tika /unpack/all image metadata sidecar with a picture type.
func coverSidecar(desc string) string {
return `{"Content-Type":"image/jpeg","dc:description":"` + desc + `"}`
}
var _ = Describe("TikaDecoder", func() {
It("extracts the largest renderable JPEG from Tika's unpack zip", func() {
small := encodeJPEG(16, 8)
large := encodeJPEG(64, 32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Method).To(Equal(http.MethodPut))
Expect(r.URL.Path).To(Equal("/unpack"))
w.Header().Set("Content-Type", "application/zip")
writeBody(w, zipWith(map[string][]byte{
"thumbnail-0": small,
"thumbnail-1": large,
"metadata.json": []byte(`{"not":"a jpeg"}`),
}))
}))
defer srv.Close()
img, err := TikaDecoder{tikaURL: srv.URL}.Convert(context.TODO(), bytes.NewReader([]byte("raw")))
Expect(err).ToNot(HaveOccurred())
// default (imaging) build decodes to an image.Image
bounds := img.(image.Image).Bounds()
Expect(bounds.Dx()).To(Equal(64))
Expect(bounds.Dy()).To(Equal(32))
})
It("yields no preview when the unpack zip holds no renderable JPEG", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeBody(w, zipWith(map[string][]byte{"metadata.json": []byte("not a jpeg")}))
}))
defer srv.Close()
_, err := TikaDecoder{tikaURL: srv.URL}.Convert(context.TODO(), bytes.NewReader([]byte("raw")))
Expect(err).To(MatchError(thumbnailerErrors.ErrNoEmbeddedImage))
})
It("errors when Tika returns a non-OK status", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
_, err := TikaDecoder{tikaURL: srv.URL}.Convert(context.TODO(), bytes.NewReader([]byte("raw")))
Expect(err).To(HaveOccurred())
})
It("sends the quoted source filename so Tika can route by extension", func() {
var gotCD string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCD = r.Header.Get("Content-Disposition")
writeBody(w, zipWith(map[string][]byte{"0.jpg": encodeJPEG(8, 8)}))
}))
defer srv.Close()
_, err := TikaDecoder{tikaURL: srv.URL, filename: "my photo.nef"}.Convert(context.TODO(), bytes.NewReader([]byte("raw")))
Expect(err).ToNot(HaveOccurred())
Expect(gotCD).To(Equal(`attachment; filename="my photo.nef"`))
})
It("skips a larger lossless entry and picks the smaller renderable JPEG", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeBody(w, zipWith(map[string][]byte{
"big-lossless": losslessJPEG(4096),
"small": encodeJPEG(16, 8),
}))
}))
defer srv.Close()
img, err := TikaDecoder{tikaURL: srv.URL}.Convert(context.TODO(), bytes.NewReader([]byte("raw")))
Expect(err).ToNot(HaveOccurred())
Expect(img.(image.Image).Bounds().Dx()).To(Equal(16))
})
It("treats Tika's 204 (no embedded resources) as a previewless raw", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
_, err := TikaDecoder{tikaURL: srv.URL}.Convert(context.TODO(), bytes.NewReader([]byte("raw")))
Expect(err).To(MatchError(thumbnailerErrors.ErrNoEmbeddedImage))
})
It("errors when the 200 response body is not a zip", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeBody(w, []byte("not a zip"))
}))
defer srv.Close()
_, err := TikaDecoder{tikaURL: srv.URL}.Convert(context.TODO(), bytes.NewReader([]byte("raw")))
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(thumbnailerErrors.ErrNoEmbeddedImage))
})
It("classifies JPEG renderability by SOF marker", func() {
Expect(isRenderableJPEG(encodeJPEG(8, 8))).To(BeTrue())
Expect(isRenderableJPEG(losslessJPEG(64))).To(BeFalse())
Expect(isRenderableJPEG([]byte{0xff, 0xd8})).To(BeFalse())
Expect(isRenderableJPEG(nil)).To(BeFalse())
Expect(isRenderableJPEG([]byte("not a jpeg"))).To(BeFalse())
})
It("picks the tagged front cover over other embedded images (audio)", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.URL.Path).To(Equal("/unpack/all"))
writeBody(w, zipOrdered(
[2]string{"1.jpg", string(encodeJPEG(64, 32))}, // larger, first, but not front
[2]string{"1.jpg.metadata.json", coverSidecar("Other")},
[2]string{"2.jpg", string(encodeJPEG(16, 8))}, // smaller, second, front cover
[2]string{"2.jpg.metadata.json", coverSidecar("Cover (front)")},
))
}))
defer srv.Close()
img, err := TikaDecoder{tikaURL: srv.URL, mimeType: "audio/mpeg"}.Convert(context.TODO(), bytes.NewReader([]byte("mp3")))
Expect(err).ToNot(HaveOccurred())
Expect(img.(image.Image).Bounds().Dx()).To(Equal(16))
})
It("falls back to the first image when none is tagged front cover (audio)", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeBody(w, zipOrdered(
[2]string{"1.jpg", string(encodeJPEG(16, 8))}, // first
[2]string{"1.jpg.metadata.json", coverSidecar("Other")},
[2]string{"2.jpg", string(encodeJPEG(64, 32))}, // larger, but not first
[2]string{"2.jpg.metadata.json", coverSidecar("Other")},
))
}))
defer srv.Close()
img, err := TikaDecoder{tikaURL: srv.URL, mimeType: "audio/mpeg"}.Convert(context.TODO(), bytes.NewReader([]byte("mp3")))
Expect(err).ToNot(HaveOccurred())
Expect(img.(image.Image).Bounds().Dx()).To(Equal(16))
})
})
@@ -54,6 +54,8 @@ func NewService(opts ...Option) decorators.DecoratedService {
selector: options.GatewaySelector,
preprocessorOpts: PreprocessorOpts{
TxtFontFileMap: options.Config.Thumbnail.FontMapFile,
TikaURL: options.Config.Thumbnail.Preprocessor.Tika.TikaURL,
AudioProcessor: options.Config.Thumbnail.Preprocessor.AudioProcessor,
},
dataEndpoint: options.Config.Thumbnail.DataEndpoint,
transferSecret: options.Config.Thumbnail.TransferSecret,
@@ -78,6 +80,8 @@ type Thumbnail struct {
// PreprocessorOpts holds the options for the preprocessor
type PreprocessorOpts struct {
TxtFontFileMap string
TikaURL string
AudioProcessor string
}
// GetThumbnail retrieves a thumbnail for an image
@@ -166,11 +170,18 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetTh
defer r.Close()
ppOpts := map[string]any{
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
"tikaURL": g.preprocessorOpts.TikaURL,
"filename": sRes.GetInfo().GetName(),
"audioProcessor": g.preprocessorOpts.AudioProcessor,
}
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType(), ppOpts)
img, err := pp.Convert(r)
if err != nil {
img, err := pp.Convert(ctx, r)
switch {
case errors.Is(err, terrors.ErrNoEmbeddedImage):
// a source without an embedded image is expected, not an error
g.logger.Debug().Err(err).Msg("no embedded image in source")
case err != nil:
g.logger.Error().Err(err).Msg("failed to convert image")
}
@@ -262,10 +273,13 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge
}
defer r.Close()
ppOpts := map[string]any{
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
"tikaURL": g.preprocessorOpts.TikaURL,
"filename": sRes.GetInfo().GetName(),
"audioProcessor": g.preprocessorOpts.AudioProcessor,
}
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType(), ppOpts)
img, err := pp.Convert(r)
img, err := pp.Convert(ctx, r)
if img == nil || err != nil {
return "", merrors.NotFound(g.serviceID, "could not get image")
}
@@ -12,6 +12,14 @@ var (
"image/bmp": {},
"image/x-ms-bmp": {},
"image/tiff": {},
"image/x-nikon-nef": {},
"image/x-nikon-nrw": {},
"image/x-canon-cr2": {},
"image/x-pentax-pef": {},
"image/x-sony-arw": {},
"image/x-sony-sr2": {},
"image/x-sony-srf": {},
"image/x-adobe-dng": {},
"text/plain": {},
"audio/flac": {},
"audio/mpeg": {},
@@ -12,6 +12,14 @@ var (
"image/bmp": {},
"image/x-ms-bmp": {},
"image/tiff": {},
"image/x-nikon-nef": {},
"image/x-nikon-nrw": {},
"image/x-canon-cr2": {},
"image/x-pentax-pef": {},
"image/x-sony-arw": {},
"image/x-sony-sr2": {},
"image/x-sony-srf": {},
"image/x-adobe-dng": {},
"text/plain": {},
"audio/flac": {},
"audio/mpeg": {},
@@ -1,6 +1,7 @@
package thumbnail
import (
"context"
"image"
"os"
"path"
@@ -176,7 +177,7 @@ func TestPreviewGenerationTooBigImage(t *testing.T) {
defer f.Close()
preproc := preprocessor.ForType(tt.mimeType, nil)
convert, err := preproc.Convert(f)
convert, err := preproc.Convert(context.TODO(), f)
if err != nil {
return
}