Make photo library offline-first and fix video thumbnails
This commit is contained in:
+1
-1
@@ -36,7 +36,7 @@ RUN make release-linux-docker-${TARGETARCH} ENABLE_VIPS=true DIST=/dist
|
||||
FROM alpine:3.23
|
||||
ARG TARGETARCH=arm64
|
||||
|
||||
RUN apk add --no-cache attr ca-certificates curl mailcap tree vips && \
|
||||
RUN apk add --no-cache attr ca-certificates curl ffmpeg mailcap tree vips && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="QSfera" \
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM amd64/alpine:edge
|
||||
ARG VERSION=""
|
||||
ARG REVISION=""
|
||||
|
||||
RUN apk add --no-cache attr bash ca-certificates curl delve inotify-tools libc6-compat mailcap tree vips patch && \
|
||||
RUN apk add --no-cache attr bash ca-certificates curl delve ffmpeg inotify-tools libc6-compat mailcap tree vips patch && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="QSfera" \
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM arm64v8/alpine:edge
|
||||
ARG VERSION=""
|
||||
ARG REVISION=""
|
||||
|
||||
RUN apk add --no-cache attr bash ca-certificates curl delve inotify-tools libc6-compat mailcap tree vips patch && \
|
||||
RUN apk add --no-cache attr bash ca-certificates curl delve ffmpeg inotify-tools libc6-compat mailcap tree vips patch && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="QSfera" \
|
||||
|
||||
@@ -20,7 +20,7 @@ ARG REVISION
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache attr bash ca-certificates curl imagemagick \
|
||||
RUN apk add --no-cache attr bash ca-certificates curl ffmpeg imagemagick \
|
||||
inotify-tools libc6-compat mailcap tree vips \
|
||||
vips-magick patch && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
@@ -78,6 +78,10 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
|
||||
|
||||
bleveReq := bleve.NewSearchRequest(q)
|
||||
bleveReq.Highlight = bleve.NewHighlight()
|
||||
// Keep relevance as the primary order, but make equal-score media searches stable and
|
||||
// newest-first. Without explicit tie breakers, increasing PageSize can reshuffle results
|
||||
// that all have the same mediatype score and makes paged photo grids jump.
|
||||
bleveReq.SortBy([]string{"-_score", "-Mtime", "_id"})
|
||||
|
||||
switch {
|
||||
case sir.PageSize == -1:
|
||||
|
||||
@@ -111,6 +111,11 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
|
||||
},
|
||||
boolQuery,
|
||||
osu.SearchBodyParams{
|
||||
Sort: []any{
|
||||
map[string]any{"_score": map[string]string{"order": "desc"}},
|
||||
map[string]any{"Mtime": map[string]string{"order": "desc"}},
|
||||
map[string]any{"_id": map[string]string{"order": "asc"}},
|
||||
},
|
||||
Highlight: &osu.BodyParamHighlight{
|
||||
HighlightOptions: osu.HighlightOptions{
|
||||
NumberOfFragments: 2,
|
||||
|
||||
@@ -95,6 +95,7 @@ func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyPa
|
||||
|
||||
type SearchBodyParams struct {
|
||||
Highlight *BodyParamHighlight `json:"highlight,omitempty"`
|
||||
Sort []any `json:"sort,omitempty"`
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
@@ -96,7 +96,18 @@ func (ma matchArray) Swap(i, j int) {
|
||||
ma[i], ma[j] = ma[j], ma[i]
|
||||
}
|
||||
func (ma matchArray) Less(i, j int) bool {
|
||||
return ma[i].GetScore() > ma[j].GetScore()
|
||||
if ma[i].GetScore() != ma[j].GetScore() {
|
||||
return ma[i].GetScore() > ma[j].GetScore()
|
||||
}
|
||||
leftTime := ma[i].GetEntity().GetLastModifiedTime()
|
||||
rightTime := ma[j].GetEntity().GetLastModifiedTime()
|
||||
if leftTime.GetSeconds() != rightTime.GetSeconds() {
|
||||
return leftTime.GetSeconds() > rightTime.GetSeconds()
|
||||
}
|
||||
if leftTime.GetNanos() != rightTime.GetNanos() {
|
||||
return leftTime.GetNanos() > rightTime.GetNanos()
|
||||
}
|
||||
return ma[i].GetEntity().GetId().GetOpaqueId() < ma[j].GetEntity().GetId().GetOpaqueId()
|
||||
}
|
||||
|
||||
func logDocCount(engine Engine, logger log.Logger) {
|
||||
|
||||
@@ -46,4 +46,5 @@ 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"`
|
||||
MaxInputVideoFileSize string `yaml:"max_input_video_file_size" env:"THUMBNAILS_MAX_INPUT_VIDEO_FILE_SIZE" desc:"The maximum file size of an input video used to generate a preview frame." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func DefaultConfig() *config.Config {
|
||||
GRPC: config.GRPCConfig{
|
||||
Addr: "127.0.0.1:9185",
|
||||
Namespace: "qsfera.api",
|
||||
MaxConcurrentRequests: 0,
|
||||
MaxConcurrentRequests: 2,
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9186",
|
||||
@@ -47,7 +47,7 @@ func DefaultConfig() *config.Config {
|
||||
Name: "thumbnails",
|
||||
},
|
||||
Thumbnail: config.Thumbnail{
|
||||
Resolutions: []string{"16x16", "32x32", "64x64", "128x128", "1080x1920", "1920x1080", "2160x3840", "3840x2160", "4320x7680", "7680x4320"},
|
||||
Resolutions: []string{"16x16", "32x32", "64x64", "128x128", "256x256", "512x512", "1024x1024", "1080x1920", "1920x1080", "2160x3840", "3840x2160", "4320x7680", "7680x4320"},
|
||||
FileSystemStorage: config.FileSystemStorage{
|
||||
RootDirectory: path.Join(defaults.BaseDataPath(), "thumbnails"),
|
||||
},
|
||||
@@ -58,6 +58,7 @@ func DefaultConfig() *config.Config {
|
||||
MaxInputWidth: 7680,
|
||||
MaxInputHeight: 7680,
|
||||
MaxInputImageFileSize: "50MB",
|
||||
MaxInputVideoFileSize: "2GB",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
_ "image/jpeg"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/image/font"
|
||||
@@ -41,6 +46,59 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) {
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// VideoDecoder extracts a bounded preview frame with ffmpeg. The input is first persisted to a
|
||||
// temporary file so ffmpeg can seek to MP4/MOV metadata stored at the end of large camera files.
|
||||
// The file lives on disk and is never retained in process memory.
|
||||
type VideoDecoder struct{}
|
||||
|
||||
func (VideoDecoder) Convert(r io.Reader) (any, error) {
|
||||
input, err := os.CreateTemp("", "qsfera-video-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputPath := input.Name()
|
||||
defer os.Remove(inputPath)
|
||||
if _, err = io.Copy(input, r); err != nil {
|
||||
input.Close()
|
||||
return nil, errors.Wrap(err, "could not persist video for preview extraction")
|
||||
}
|
||||
if err = input.Sync(); err != nil {
|
||||
input.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err = input.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), videoFrameExtractionTimeout)
|
||||
defer cancel()
|
||||
frame, err := extractVideoFrame(ctx, inputPath, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not extract video preview frame")
|
||||
}
|
||||
|
||||
decoded, _, err := image.Decode(bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not decode video preview frame")
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
const videoFrameExtractionTimeout = 90 * time.Second
|
||||
|
||||
func extractVideoFrame(ctx context.Context, input string, stdin io.Reader) ([]byte, error) {
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
"ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error", "-i", input,
|
||||
"-map", "0:v:0", "-an", "-sn", "-frames:v", "1",
|
||||
"-vf", "thumbnail=30,scale=1920:-2:force_original_aspect_ratio=decrease",
|
||||
"-f", "image2pipe", "-vcodec", "png", "pipe:1",
|
||||
)
|
||||
cmd.Stdin = stdin
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// GgsDecoder is a converter for the geogebra slides file
|
||||
type GgsDecoder struct{ thumbnailpath string }
|
||||
|
||||
@@ -303,6 +361,9 @@ func ForType(mimeType string, opts map[string]any) FileConverter {
|
||||
// return the service call. So we should only get here when the mimeType parses fine.
|
||||
mimeType, _, _ = mime.ParseMediaType(mimeType)
|
||||
switch mimeType {
|
||||
case "video/mp4", "video/quicktime", "video/webm", "video/x-matroska", "video/x-msvideo", "video/mpeg", "video/3gpp",
|
||||
"video/x-m4v", "video/mp2t", "video/ogg", "video/x-ms-wmv", "video/x-flv", "video/hevc":
|
||||
return VideoDecoder{}
|
||||
case "text/plain":
|
||||
fontFileMap := ""
|
||||
fontFaceOpts := &opentype.FaceOptions{
|
||||
|
||||
@@ -173,6 +173,13 @@ var _ = Describe("ImageDecoder", func() {
|
||||
Expect(decoder).To(BeAssignableToTypeOf(GifDecoder{}))
|
||||
})
|
||||
|
||||
It("should return a VideoDecoder for supported video types", func() {
|
||||
for _, mimeType := range []string{"video/mp4", "video/quicktime", "video/webm", "video/x-matroska"} {
|
||||
decoder := ForType(mimeType, nil)
|
||||
Expect(decoder).To(BeAssignableToTypeOf(VideoDecoder{}))
|
||||
}
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/service/grpc"
|
||||
"github.com/qsfera/server/pkg/service/grpc/handler/ratelimiter"
|
||||
@@ -10,8 +12,6 @@ import (
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/service/grpc/v0/decorators"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail/imgsource"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail/storage"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
// NewService initializes the grpc service and server.
|
||||
@@ -62,20 +62,25 @@ func NewService(opts ...Option) grpc.Service {
|
||||
options.Logger.Error().Err(err).Msg("could not parse MaxInputImageFileSize")
|
||||
return grpc.Service{}
|
||||
}
|
||||
videoLimit, err := bytesize.Parse(tconf.MaxInputVideoFileSize)
|
||||
if err != nil {
|
||||
options.Logger.Error().Err(err).Msg("could not parse MaxInputVideoFileSize")
|
||||
return grpc.Service{}
|
||||
}
|
||||
|
||||
var thumbnail decorators.DecoratedService
|
||||
{
|
||||
thumbnail = svc.NewService(
|
||||
svc.Config(options.Config),
|
||||
svc.Logger(options.Logger),
|
||||
svc.ThumbnailSource(imgsource.NewWebDavSource(tconf, b)),
|
||||
svc.ThumbnailSource(imgsource.NewWebDavSource(tconf, b, videoLimit)),
|
||||
svc.ThumbnailStorage(
|
||||
storage.NewFileSystemStorage(
|
||||
tconf.FileSystemStorage,
|
||||
options.Logger,
|
||||
),
|
||||
),
|
||||
svc.CS3Source(imgsource.NewCS3Source(tconf, gatewaySelector, b)),
|
||||
svc.CS3Source(imgsource.NewCS3Source(tconf, gatewaySelector, b, videoLimit)),
|
||||
svc.GatewaySelector(gatewaySelector),
|
||||
)
|
||||
thumbnail = decorators.NewInstrument(thumbnail, options.Metrics)
|
||||
|
||||
@@ -156,6 +156,7 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetTh
|
||||
}
|
||||
|
||||
ctx = imgsource.ContextSetAuthorization(ctx, src.GetAuthorization())
|
||||
ctx = imgsource.ContextSetVideoSource(ctx, strings.HasPrefix(sRes.GetInfo().GetMimeType(), "video/"))
|
||||
r, err := g.cs3Source.Get(ctx, src.GetPath())
|
||||
switch {
|
||||
case errors.Is(err, terrors.ErrImageTooLarge):
|
||||
@@ -245,6 +246,7 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge
|
||||
if src.GetWebdavAuthorization() != "" {
|
||||
ctx = imgsource.ContextSetAuthorization(ctx, src.GetWebdavAuthorization())
|
||||
}
|
||||
ctx = imgsource.ContextSetVideoSource(ctx, strings.HasPrefix(sRes.GetInfo().GetMimeType(), "video/"))
|
||||
|
||||
// add signature and expiration to webdav url
|
||||
signature, expiration := imgURL.Query().Get("signature"), imgURL.Query().Get("expiration")
|
||||
|
||||
@@ -5,11 +5,11 @@ package thumbnail
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"strings"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/errors"
|
||||
"golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
// SimpleGenerator is the default image generator and is used for all image types expect gif.
|
||||
@@ -41,11 +41,13 @@ func (g SimpleGenerator) ProcessorID() string {
|
||||
func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}) (interface{}, error) {
|
||||
var m *vips.ImageRef
|
||||
var err error
|
||||
switch img.(type) {
|
||||
case *image.RGBA:
|
||||
// This comes from the txt preprocessor
|
||||
switch typed := img.(type) {
|
||||
case image.Image:
|
||||
// Preprocessors for text and video return standard-library image types. Convert
|
||||
// them to a lossless stream before handing them to libvips; decoded PNG video
|
||||
// frames are usually *image.NRGBA and were previously rejected as invalid.
|
||||
var buf bytes.Buffer
|
||||
if err = bmp.Encode(&buf, img.(*image.RGBA)); err != nil {
|
||||
if err = png.Encode(&buf, typed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err = vips.NewImageFromReader(&buf)
|
||||
@@ -71,13 +73,11 @@ func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}) (interf
|
||||
}
|
||||
|
||||
func (g SimpleGenerator) Dimensions(img interface{}) (image.Rectangle, error) {
|
||||
switch img.(type) {
|
||||
case *image.RGBA:
|
||||
m := img.(*image.RGBA)
|
||||
return m.Bounds(), nil
|
||||
switch typed := img.(type) {
|
||||
case image.Image:
|
||||
return typed.Bounds(), nil
|
||||
case *vips.ImageRef:
|
||||
m := img.(*vips.ImageRef)
|
||||
return image.Rect(0, 0, m.Width(), m.Height()), nil
|
||||
return image.Rect(0, 0, typed.Width(), typed.Height()), nil
|
||||
default:
|
||||
return image.Rectangle{}, errors.ErrInvalidType
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build enable_vips
|
||||
|
||||
package thumbnail
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVipsGeneratorAcceptsDecodedVideoFrameDimensions(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1920, 1080))
|
||||
generator, err := NewSimpleGenerator(typePng, "fit")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dimensions, err := generator.Dimensions(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("decoded video frame must be accepted: %v", err)
|
||||
}
|
||||
if dimensions.Dx() != 1920 || dimensions.Dy() != 1080 {
|
||||
t.Fatalf("unexpected dimensions: %v", dimensions)
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,18 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/config"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/errors"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rhttp"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/config"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/errors"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
@@ -31,14 +32,20 @@ type CS3 struct {
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
insecure bool
|
||||
maxImageFileSize uint64
|
||||
maxVideoFileSize uint64
|
||||
}
|
||||
|
||||
// NewCS3Source configures a new CS3 image source
|
||||
func NewCS3Source(cfg config.Thumbnail, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], b bytesize.ByteSize) CS3 {
|
||||
func NewCS3Source(
|
||||
cfg config.Thumbnail,
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient],
|
||||
imageLimit, videoLimit bytesize.ByteSize,
|
||||
) CS3 {
|
||||
return CS3{
|
||||
gatewaySelector: gatewaySelector,
|
||||
insecure: cfg.CS3AllowInsecure,
|
||||
maxImageFileSize: b.Bytes(),
|
||||
maxImageFileSize: imageLimit.Bytes(),
|
||||
maxVideoFileSize: videoLimit.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +65,10 @@ func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx = metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, auth)
|
||||
// Preserve source metadata already attached to the request context. In particular, the
|
||||
// video marker selects the separate video size limit; replacing the context here made
|
||||
// every video larger than the image limit fail before ffmpeg was reached.
|
||||
ctx = withCS3Authorization(ctx, auth)
|
||||
err = s.checkImageFileSize(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -88,31 +98,65 @@ func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
ep, tk = rsp.GetProtocols()[0].GetDownloadEndpoint(), rsp.GetProtocols()[0].GetToken()
|
||||
}
|
||||
|
||||
httpReq, err := rhttp.NewRequest(ctx, "GET", ep, nil)
|
||||
downloadCtx := ctx
|
||||
cancelDownload := func() {}
|
||||
if contextIsVideoSource(ctx) {
|
||||
downloadCtx, cancelDownload = detachedVideoDownloadContext(ctx)
|
||||
}
|
||||
httpReq, err := rhttp.NewRequest(downloadCtx, "GET", ep, nil)
|
||||
if err != nil {
|
||||
cancelDownload()
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set(revactx.TokenHeader, auth)
|
||||
httpReq.Header.Set(TokenTransportHeader, tk)
|
||||
|
||||
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: s.insecure, //nolint:gosec
|
||||
}
|
||||
client := &http.Client{}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
cancelDownload()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
cancelDownload()
|
||||
return nil, fmt.Errorf("could not get the image \"%s\". Request returned with statuscode %d ", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
if contextIsVideoSource(ctx) {
|
||||
return cancelOnCloseReadCloser{ReadCloser: resp.Body, cancel: cancelDownload}, nil
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
type cancelOnCloseReadCloser struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (r cancelOnCloseReadCloser) Close() error {
|
||||
err := r.ReadCloser.Close()
|
||||
r.cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
func detachedVideoDownloadContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), videoDownloadTimeout)
|
||||
}
|
||||
|
||||
const videoDownloadTimeout = 15 * time.Minute
|
||||
|
||||
func withCS3Authorization(ctx context.Context, auth string) context.Context {
|
||||
return metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, auth)
|
||||
}
|
||||
|
||||
func (s CS3) checkImageFileSize(ctx context.Context, ref provider.Reference) error {
|
||||
gwc, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
@@ -125,7 +169,11 @@ func (s CS3) checkImageFileSize(ctx context.Context, ref provider.Reference) err
|
||||
if stat.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
return fmt.Errorf("could not stat image: %s", stat.GetStatus().GetMessage())
|
||||
}
|
||||
if stat.GetInfo().GetSize() > s.maxImageFileSize {
|
||||
limit := s.maxImageFileSize
|
||||
if contextIsVideoSource(ctx) {
|
||||
limit = s.maxVideoFileSize
|
||||
}
|
||||
if stat.GetInfo().GetSize() > limit {
|
||||
return errors.ErrImageTooLarge
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package imgsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCS3AuthorizationPreservesVideoSourceMarker(t *testing.T) {
|
||||
ctx := ContextSetVideoSource(context.Background(), true)
|
||||
ctx = withCS3Authorization(ctx, "token")
|
||||
|
||||
if !contextIsVideoSource(ctx) {
|
||||
t.Fatal("CS3 authorization must preserve the video source marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedVideoDownloadSurvivesCallerCancellation(t *testing.T) {
|
||||
parent, cancelParent := context.WithCancel(context.Background())
|
||||
download, cancelDownload := detachedVideoDownloadContext(parent)
|
||||
defer cancelDownload()
|
||||
|
||||
cancelParent()
|
||||
|
||||
select {
|
||||
case <-download.Done():
|
||||
t.Fatal("video cache warming must continue after the requesting client disconnects")
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type key int
|
||||
|
||||
const (
|
||||
auth key = iota
|
||||
video
|
||||
)
|
||||
|
||||
// Source defines the interface for image sources
|
||||
@@ -16,6 +17,16 @@ type Source interface {
|
||||
Get(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// ContextSetVideoSource selects the separately bounded video input limit.
|
||||
func ContextSetVideoSource(parent context.Context, isVideo bool) context.Context {
|
||||
return context.WithValue(parent, video, isVideo)
|
||||
}
|
||||
|
||||
func contextIsVideoSource(ctx context.Context) bool {
|
||||
value, _ := ctx.Value(video).(bool)
|
||||
return value
|
||||
}
|
||||
|
||||
// ContextSetAuthorization puts the authorization in the context.
|
||||
func ContextSetAuthorization(parent context.Context, authorization string) context.Context {
|
||||
return context.WithValue(parent, auth, authorization)
|
||||
|
||||
@@ -11,17 +11,18 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/config"
|
||||
thumbnailerErrors "github.com/qsfera/server/services/thumbnails/pkg/errors"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/config"
|
||||
thumbnailerErrors "github.com/qsfera/server/services/thumbnails/pkg/errors"
|
||||
)
|
||||
|
||||
// NewWebDavSource creates a new webdav instance.
|
||||
func NewWebDavSource(cfg config.Thumbnail, b bytesize.ByteSize) WebDav {
|
||||
func NewWebDavSource(cfg config.Thumbnail, imageLimit, videoLimit bytesize.ByteSize) WebDav {
|
||||
return WebDav{
|
||||
insecure: cfg.WebdavAllowInsecure,
|
||||
maxImageFileSize: b.Bytes(),
|
||||
maxImageFileSize: imageLimit.Bytes(),
|
||||
maxVideoFileSize: videoLimit.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ func NewWebDavSource(cfg config.Thumbnail, b bytesize.ByteSize) WebDav {
|
||||
type WebDav struct {
|
||||
insecure bool
|
||||
maxImageFileSize uint64
|
||||
maxVideoFileSize uint64
|
||||
}
|
||||
|
||||
// Get downloads the file from a webdav service
|
||||
@@ -67,7 +69,11 @@ func (s WebDav) Get(ctx context.Context, url string) (io.ReadCloser, error) {
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, `could not parse content length of webdav response "%s"`, url)
|
||||
}
|
||||
if c > s.maxImageFileSize {
|
||||
limit := s.maxImageFileSize
|
||||
if contextIsVideoSource(ctx) {
|
||||
limit = s.maxVideoFileSize
|
||||
}
|
||||
if c > limit {
|
||||
return nil, thumbnailerErrors.ErrImageTooLarge
|
||||
}
|
||||
|
||||
|
||||
@@ -18,5 +18,18 @@ var (
|
||||
"audio/ogg": {},
|
||||
"application/vnd.geogebra.slides": {},
|
||||
"application/vnd.geogebra.pinboard": {},
|
||||
"video/mp4": {},
|
||||
"video/quicktime": {},
|
||||
"video/webm": {},
|
||||
"video/x-matroska": {},
|
||||
"video/x-msvideo": {},
|
||||
"video/mpeg": {},
|
||||
"video/3gpp": {},
|
||||
"video/x-m4v": {},
|
||||
"video/mp2t": {},
|
||||
"video/ogg": {},
|
||||
"video/x-ms-wmv": {},
|
||||
"video/x-flv": {},
|
||||
"video/hevc": {},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -27,5 +27,18 @@ var (
|
||||
"application/vnd.geogebra.slides": {},
|
||||
"application/vnd.geogebra.pinboard": {},
|
||||
"image/webp": {},
|
||||
"video/mp4": {},
|
||||
"video/quicktime": {},
|
||||
"video/webm": {},
|
||||
"video/x-matroska": {},
|
||||
"video/x-msvideo": {},
|
||||
"video/mpeg": {},
|
||||
"video/3gpp": {},
|
||||
"video/x-m4v": {},
|
||||
"video/mp2t": {},
|
||||
"video/ogg": {},
|
||||
"video/x-ms-wmv": {},
|
||||
"video/x-flv": {},
|
||||
"video/hevc": {},
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user