Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .woodpecker.star
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions devtools/deployments/opencloud_full/config/tika/tika-config.json
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 6 additions & 1 deletion devtools/deployments/opencloud_full/tika.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
2 changes: 1 addition & 1 deletion services/search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion services/search/pkg/config/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
2 changes: 2 additions & 0 deletions services/webdav/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

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.

Check notice on line 13 in services/webdav/README.md

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/webdav/README.md#L13

Expected: 80; Actual: 821

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
Expand Down
3 changes: 3 additions & 0 deletions services/webdav/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
}
2 changes: 2 additions & 0 deletions services/webdav/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package defaults

import (
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
"path"
"strings"

Expand Down Expand Up @@ -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{
Expand Down
5 changes: 3 additions & 2 deletions services/webdav/pkg/preprocessor/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
48 changes: 34 additions & 14 deletions services/webdav/pkg/preprocessor/preprocessor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
"image"
"image/draw"
"image/gif"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
125 changes: 125 additions & 0 deletions services/webdav/pkg/preprocessor/tika.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading