mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-08 11:53:07 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1592ab25f2 | ||
|
|
4ba384f7b2 | ||
|
|
ae6b0cb584 | ||
|
|
75893c19ee | ||
|
|
c286f771c9 | ||
|
|
73b839f120 | ||
|
|
afd82fba5b | ||
|
|
63582cb310 | ||
|
|
7bdbad3319 | ||
|
|
882325c512 | ||
|
|
b5e1222ec8 | ||
|
|
47a7136c46 | ||
|
|
83c1dc8d49 | ||
|
|
6ade518690 | ||
|
|
8dec34dbd4 | ||
|
|
f112e8bfb2 | ||
|
|
61ce147205 | ||
|
|
77b08abfb6 | ||
|
|
b02d4fb279 | ||
|
|
9805f65136 | ||
|
|
5b26d202b5 | ||
|
|
c0069c9e77 |
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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%%"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user