Files
André Duffeck 489f00288d Look up the user of a thumbnail path with the requester's token
Without it the user provider searched with an empty tenant.
2026-10-23 14:20:13 +02:00

963 lines
36 KiB
Go

package workflow
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"path/filepath"
"strconv"
"strings"
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
chimiddleware "github.com/go-chi/chi/v5/middleware"
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/utils"
grpcmetadata "google.golang.org/grpc/metadata"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/templates"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/dav/requests"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/generator"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/preprocessor"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail/cache"
)
// Stater stats a file via the CS3 gateway.
type Stater interface {
Stat(ctx context.Context, ref *providerv1beta1.Reference, auth string) (*providerv1beta1.StatResponse, error)
}
// FileDownloader opens a read stream on a file in storage. The returned reader
// must be consumed (and closed by the caller) exactly once; it is the raw
// response body and is never buffered or decoded here.
type FileDownloader interface {
DownloadStream(ctx context.Context, ref *providerv1beta1.Reference, auth string) (io.ReadCloser, error)
}
// SpaceLookup resolves a path-only reference (ResourceId == nil) into a full
// reference by looking up the storage space that owns the absolute path via the
// CS3 gateway's ListStorageSpaces. This mirrors reva's ocdav spacelookup and is
// used for path-only requests (user home paths, /webdav/..., public links),
// which carry an absolute path but no space root.
type SpaceLookup interface {
Resolve(ctx context.Context, ref *providerv1beta1.Reference, auth string) (*providerv1beta1.Reference, error)
}
// UserResolver resolves users for path-only thumbnail requests. WhoAmI maps an
// auth token to its user (used for /webdav/... where the URL carries no
// username); GetUserByClaim maps a username from /dav/files/{user}/... to its
// user, looked up as the requester, whose tenant the user provider searches.
type UserResolver interface {
WhoAmI(ctx context.Context, auth string) (*userv1beta1.User, error)
GetUserByClaim(ctx context.Context, claim, value, auth string) (*userv1beta1.User, error)
}
// ErrFileProcessing is returned when the file is still being processed by the
// storage backend (e.g. a virus scan or conversion in flight). Callers should
// surface this as HTTP 425 Too Early with a Retry-After header.
var ErrFileProcessing = fmt.Errorf("file is processing")
// ErrImageTooLarge is returned when the input image exceeds the configured
// maximum width/height. Callers should surface this as HTTP 403 Forbidden.
var ErrImageTooLarge = errors.New("thumbnails: image is too large")
// ErrNotAFile is returned when the requested resource is not a regular file
// (e.g. a folder). Callers should surface this as HTTP 400 Bad Request with an
// "Unsupported file type" message.
var ErrNotAFile = errors.New("thumbnails: resource is not a file")
// ErrPermissionDenied is returned when the requesting user lacks the download
// permission on the resource (e.g. a Secure Viewer share). Callers should
// surface this as HTTP 403 Forbidden.
var ErrPermissionDenied = errors.New("thumbnails: no download permission")
// ErrNotFound is returned when the requested file could not be located or has no
// usable checksum. Callers should surface this as HTTP 404 Not Found with a
// "File could not be located" message.
var ErrNotFound = errors.New("thumbnails: file could not be located")
// ErrNoStorageSpace is returned when no storage space owns the requested path,
// e.g. a path in another user's home or a non-existent path. Callers should
// surface this as HTTP 404 Not Found.
var ErrNoStorageSpace = errors.New("thumbnails: no storage space found for path")
// ErrUnsupportedFileType is returned when the file's mime type is not in the
// supported set (e.g. PDF, ODT, ZIP). Callers should surface this as HTTP 404
// Not Found with an "Unsupported file type" message, matching the legacy
// thumbnail service.
var ErrUnsupportedFileType = errors.New("unsupported file type")
// GeneratorBusyError is returned when the generator refuses a request because
// its concurrency limit is reached. RetryAfterSeconds carries its own hint.
type GeneratorBusyError struct {
RetryAfterSeconds int
}
func (e *GeneratorBusyError) Error() string {
return fmt.Sprintf("thumbnails: generator is busy, retry after %ds", e.RetryAfterSeconds)
}
// fileIsProcessing reports whether the resource carries the "processing" status
// in its opaque map, as set by the storage backend while a file is being handled.
func fileIsProcessing(info *providerv1beta1.ResourceInfo) bool {
return utils.ReadPlainFromOpaque(info.GetOpaque(), "status") == "processing"
}
// thumbnailStorageKey builds the cache key: hash[:2]/hash[2:4]/hash[4:]/wxh-<operation>.format.
// The <operation> segment (fit-in, fill, stretch, ...) distinguishes requests that
// share a size but produce different images, so they never share a cache entry.
// The key carries the snapped box, not the requested size, so every request that
// rounds to the same box shares one entry. Caches regenerate when the configured
// resolutions or operations change.
//
// The checksum is hashed rather than used as it comes: it is not this service's
// to trust, an OCM share takes it from a remote PROPFIND, and it ends up as a
// path on disk and as an object key. The source type is hashed with it, because
// the same bytes under two types are not the same thumbnail: the type picks the
// extractor, and what it finds depends on it.
func thumbnailStorageKey(checksum, mimeType string, w, h int, format, operation string) string {
sum := sha256.Sum256([]byte(checksum + "\x00" + mimeType))
hash := hex.EncodeToString(sum[:])
return hash[:2] + "/" + hash[2:4] + "/" + hash[4:] + "/" + fmt.Sprintf("%dx%d-%s.%s", w, h, operation, format)
}
// ThumbnailWorkflow owns the complete thumbnail generation pipeline:
// stat → validate → cache check → download → preprocess → generate → cache → respond.
type ThumbnailWorkflow struct {
generatorURL string
cache cache.ThumbnailCache
httpClient *http.Client
maxInputSize uint64
sizes *thumbnail.Sizes
typeMapping thumbnail.TypeMapping
sourceTypes thumbnail.SourceTypes
formats generator.Formats
webdavNS string
fontMapFile string
log log.Logger
stater Stater
fileDownloader FileDownloader
spaceLookup SpaceLookup
userResolver UserResolver
}
// NewWorkflow creates a ThumbnailWorkflow. generatorURL must be non-empty.
func NewWorkflow(opts ...Option) (*ThumbnailWorkflow, error) {
w := &ThumbnailWorkflow{
httpClient: &http.Client{},
log: log.NewLogger(log.Name("webdav")),
}
for _, opt := range opts {
opt(w)
}
if w.generatorURL == "" {
return nil, fmt.Errorf("generator URL is required")
}
if w.stater == nil {
return nil, fmt.Errorf("stater is required")
}
if w.fileDownloader == nil {
return nil, fmt.Errorf("file downloader is required")
}
return w, nil
}
// Option configures a ThumbnailWorkflow.
type Option func(*ThumbnailWorkflow)
// WithGeneratorURL sets the base URL of the thumbnail generator service.
func WithGeneratorURL(url string) Option {
return func(w *ThumbnailWorkflow) { w.generatorURL = url }
}
// WithCache sets the thumbnail cache.
func WithCache(c cache.ThumbnailCache) Option {
return func(w *ThumbnailWorkflow) { w.cache = c }
}
// WithHTTPClient overrides the default HTTP client.
func WithHTTPClient(client *http.Client) Option {
return func(w *ThumbnailWorkflow) { w.httpClient = client }
}
// WithMaxInputSize sets the maximum input file size in bytes (0 = no limit).
func WithMaxInputSize(size uint64) Option {
return func(w *ThumbnailWorkflow) { w.maxInputSize = size }
}
// WithSizes sets the ladder of edge lengths a generated thumbnail may have. The
// generator is a dumb executor, so webdav owns the configured ladder and decides
// the box.
func WithSizes(s *thumbnail.Sizes) Option {
return func(w *ThumbnailWorkflow) { w.sizes = s }
}
// WithWebdavNamespace sets the CS3 path layout for resolving user paths.
func WithWebdavNamespace(ns string) Option {
return func(w *ThumbnailWorkflow) { w.webdavNS = ns }
}
// WithSourceTypes sets the types a preview is attempted for.
func WithSourceTypes(t thumbnail.SourceTypes) Option {
return func(w *ThumbnailWorkflow) { w.sourceTypes = t }
}
// WithFormats sets the formats the generator writes, by type.
func WithFormats(f generator.Formats) Option {
return func(w *ThumbnailWorkflow) { w.formats = f }
}
// WithTypeMapping sets the type a thumbnail of a given image type is returned in.
func WithTypeMapping(m thumbnail.TypeMapping) Option {
return func(w *ThumbnailWorkflow) { w.typeMapping = m }
}
// WithFontMapFile sets the font map file used for text thumbnail rendering.
func WithFontMapFile(file string) Option {
return func(w *ThumbnailWorkflow) { w.fontMapFile = file }
}
// WithLogger sets the logger.
func WithLogger(l log.Logger) Option {
return func(w *ThumbnailWorkflow) { w.log = l }
}
// WithStater sets the file stater.
func WithStater(s Stater) Option {
return func(w *ThumbnailWorkflow) { w.stater = s }
}
// WithFileDownloader sets the file downloader.
func WithFileDownloader(d FileDownloader) Option {
return func(w *ThumbnailWorkflow) { w.fileDownloader = d }
}
// WithSpaceLookup sets the resolver used to look up the storage space that owns
// a path-only reference. When unset, path-only references are passed through as-is.
func WithSpaceLookup(l SpaceLookup) Option {
return func(w *ThumbnailWorkflow) { w.spaceLookup = l }
}
// WithUserResolver sets the resolver used to map an auth token to its user for
// path-only requests (/dav/files/{user}/..., /webdav/...). When unset, such
// requests cannot be absolutized and fall back to the raw path.
func WithUserResolver(r UserResolver) Option {
return func(w *ThumbnailWorkflow) { w.userResolver = r }
}
// Thumbnail is what a request produces: the image and the type it is returned in.
type Thumbnail struct {
Data []byte
ContentType string
}
// Execute handles a user thumbnail request (GET).
func (w *ThumbnailWorkflow) Execute(ctx context.Context, tr *requests.ThumbnailRequest, auth string, logger log.Logger) (Thumbnail, error) {
ref, err := w.resolveReference(ctx, tr, auth)
if err != nil {
return Thumbnail{}, err
}
return w.generate(ctx, ref, auth, tr, logger)
}
// Head performs a cache-only check for a public link thumbnail (HEAD).
// Returns nil error if the file exists and is supported, without triggering generation.
func (w *ThumbnailWorkflow) Head(ctx context.Context, tr *requests.ThumbnailRequest, auth string, logger log.Logger) error {
ref, err := w.resolveReference(ctx, tr, auth)
if err != nil {
return err
}
_, err = w.statSource(ctx, ref, auth, logger)
return err
}
// statSource stats the source and reports whether a thumbnail may be made of it
// at all, which is the same question for a HEAD and for a generation.
func (w *ThumbnailWorkflow) statSource(ctx context.Context, ref *providerv1beta1.Reference, auth string, logger log.Logger) (*providerv1beta1.ResourceInfo, error) {
statRsp, err := w.stater.Stat(ctx, ref, auth)
if err != nil {
logger.Debug().Err(err).Msg("could not stat file")
return nil, fmt.Errorf("stat: %w", err)
}
info := statRsp.GetInfo()
if fileIsProcessing(info) {
return nil, ErrFileProcessing
}
if !info.GetPermissionSet().GetInitiateFileDownload() {
return nil, ErrPermissionDenied
}
if !w.sourceTypes.Supports(info.GetMimeType()) {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType())
}
if w.maxInputSize > 0 && info.GetSize() > w.maxInputSize {
logger.Debug().Uint64("size", info.GetSize()).Uint64("max", w.maxInputSize).Msg("file too large for thumbnail")
return nil, ErrImageTooLarge
}
return info, nil
}
// resolveReference turns the request's reference into a fully-anchored CS3
// reference, mirroring reva's ocdav split of space-ID-based requests from
// path-only requests:
// - If Ref carries a ResourceId (dav/spaces/{id}/...), it is used directly,
// the URL already identifies the space root.
// - Otherwise (path-only request) the absolute CS3 path is resolved to the
// user's home-relative path and, when a SpaceLookup is configured, the
// owning storage space is looked up so the reference is anchored at the
// space root. This also covers public links (/public/<token>/...).
func (w *ThumbnailWorkflow) resolveReference(ctx context.Context, tr *requests.ThumbnailRequest, auth string) (*providerv1beta1.Reference, error) {
ref := tr.Ref
if ref == nil {
return nil, fmt.Errorf("request carries no reference")
}
if ref.GetResourceId() != nil {
// Space-ID based request: the URL carries the space root.
return ref, nil
}
// Path-only request: absolutize the path relative to a user's home (the
// legacy webdav behavior) before resolving the owning space. The user is the
// one named in the URL (/dav/files/{user}/...) when present, otherwise the
// token owner (/webdav/...).
absPath := w.absolutizeUserPath(ctx, ref.GetPath(), tr.Identifier, auth)
if w.spaceLookup == nil {
return &providerv1beta1.Reference{Path: absPath}, nil
}
resolved, err := w.spaceLookup.Resolve(ctx, &providerv1beta1.Reference{Path: absPath}, auth)
if err != nil {
if errors.Is(err, ErrNoStorageSpace) {
// No storage space owns the path (e.g. a file in another user's home
// or a non-existent path). Surface it as 404, matching the legacy
// thumbnail service.
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
}
return nil, fmt.Errorf("resolve space for path %q: %w", absPath, err)
}
return resolved, nil
}
// absolutizeUserPath maps a path-only reference to an absolute CS3 path under a
// user's home, matching the legacy webdav handler. The user is resolved from the
// username in the URL (identifier, via GetUserByClaim) when present, otherwise
// from the auth token (via WhoAmI). Public link paths (already absolute under
// /public) and any path that is not relative to the caller are returned
// unchanged.
func (w *ThumbnailWorkflow) absolutizeUserPath(ctx context.Context, p, identifier, auth string) string {
if strings.HasPrefix(p, "/") {
return p
}
if w.userResolver == nil {
// No resolver configured: we cannot absolutize the path.
w.log.Warn().Msg("no user resolver configured, using raw path for thumbnail")
return p
}
var (
user *userv1beta1.User
err error
)
if identifier != "" {
// /dav/files/{user}/...: honor the username from the URL.
user, err = w.userResolver.GetUserByClaim(ctx, "username", identifier, auth)
} else {
// /webdav/...: resolve the token owner.
user, err = w.userResolver.WhoAmI(ctx, auth)
}
if err != nil {
// Without a resolved user we cannot absolutize; fall back to the raw path.
w.log.Warn().Err(err).Msg("could not resolve user for thumbnail path")
return p
}
return filepath.Join(templates.WithUser(user, w.webdavNS), p)
}
func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.Reference, auth string, tr *requests.ThumbnailRequest, logger log.Logger) (Thumbnail, error) {
info, err := w.statSource(ctx, ref, auth, logger)
if err != nil {
return Thumbnail{}, err
}
checksum := info.GetChecksum().GetSum()
if checksum == "" {
// Without a checksum the cache key would be shared across all files, so
// the resource is treated as not found.
logger.Debug().Msg("resource info is missing a checksum")
return Thumbnail{}, ErrNotFound
}
mimeType := info.GetMimeType()
// The thumbnail is returned in the type the source type maps to, asked for by
// the name the generator's filter uses. A type nobody mapped, and one whose
// target the generator cannot write, becomes a JPEG.
outputType, outputFormat := "image/jpeg", "jpg"
if target := w.typeMapping.TargetFor(mimeType); target != "" {
if format := w.formats.For(target); format != "" {
outputType, outputFormat = target, format
}
}
// The operation is resolved up front so it can be part of the cache key: a
// fill and a fit-in request with the same dimensions produce different images
// and must not share a cache entry. noUpscale reports whether the generator
// must not enlarge the source (fit-in only).
operation, noUpscale := w.matchOperation(tr, mimeType)
// webdav is the sizing brain: it snaps each axis of the requested size onto
// the configured ladder. It never inspects the image bytes; the generator is
// a dumb executor that just fits within the given box.
box := image.Rect(0, 0, int(tr.Width), int(tr.Height))
if w.sizes != nil {
box = w.sizes.Snap(box)
}
// The cache key uses the snapped box, not what the client asked for, so
// requests that produce the same image share one entry and the number of
// entries per file stays bounded by the ladder. It needs nothing but the
// request, so a hit still returns without touching storage.
cacheKey := thumbnailStorageKey(checksum, mimeType, box.Dx(), box.Dy(), outputFormat, operation)
if w.cache != nil {
cached, err := w.cache.Get(cacheKey)
switch {
case err == nil:
return Thumbnail{Data: cached, ContentType: outputType}, nil
case !errors.Is(err, cache.ErrCacheMiss):
// a miss is the normal answer; anything else is the cache itself
// failing, and the thumbnail is made again without a word otherwise
logger.Error().Err(err).Str("key", cacheKey).Msg("could not read from the thumbnail cache")
}
}
// webdav snaps the requested size onto a configured resolution and lets the
// generator fill that box. Only fit-in suppresses upscaling (noUpscale); for
// fill/thumbnail/resize the generator may enlarge a small source (e.g. a text
// file rendered at a fixed canvas) to fill the snapped box, which is what
// imagor's default resize does.
genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputFormat, noUpscale)
// Produce the source image to send to the generator, never decoding an image
// in webdav: real images (incl. gif) are streamed straight through undecoded;
// non-image sources are converted to image bytes here so the generator always
// receives an image.
imgStream, srcType, cleanup, err := w.sourceImage(ctx, ref, auth, mimeType, tr.Filename, logger)
if err != nil {
logger.Error().Err(err).Msg("could not obtain source image for thumbnail")
return Thumbnail{}, err
}
defer cleanup()
thumbBytes, thumbType, err := w.postToGenerator(ctx, genURL, imgStream, tr.Filename, srcType)
if err != nil {
logger.Error().Err(err).Msg("could not generate thumbnail")
return Thumbnail{}, fmt.Errorf("generate: %w", err)
}
if thumbType != "" && thumbType != outputType {
// the answer is always the type we cache under, so the same request
// cannot report one type now and another one after a cache hit. A
// generator that writes something else than it was asked for means the
// configured formats do not match it, which an operator has to fix.
logger.Warn().Str("asked", outputType).Str("answered", thumbType).
Msg("generator wrote a type we did not ask for")
}
if w.cache != nil {
if err := w.cache.Put(cacheKey, thumbBytes); err != nil {
logger.Debug().Err(err).Msg("could not cache thumbnail")
}
}
return Thumbnail{Data: thumbBytes, ContentType: outputType}, nil
}
// sourceImage returns a reader over the source image to send to the generator,
// plus a cleanup func to release any resources (download body, temp buffer). It
// never decodes an image in webdav:
// - real images (image/*, incl. gif) are streamed straight through from storage
// undecoded; the generator handles decoding and multi-frame gifs itself.
// - text/plain is rendered to an image here (the only true conversion).
// - audio and geogebra sources have their embedded image extracted to bytes.
func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta1.Reference, auth, mimeType, filename string, logger log.Logger) (io.Reader, string, func(), error) {
m, _, _ := mime.ParseMediaType(mimeType)
if strings.HasPrefix(m, "image/") {
body, err := w.fileDownloader.DownloadStream(ctx, ref, auth)
if err != nil {
return nil, "", nil, fmt.Errorf("download: %w", err)
}
return body, m, func() { _ = body.Close() }, nil
}
body, err := w.fileDownloader.DownloadStream(ctx, ref, auth)
if err != nil {
return nil, "", nil, fmt.Errorf("download: %w", err)
}
defer body.Close()
fileBytes, err := io.ReadAll(body)
if err != nil {
return nil, "", nil, fmt.Errorf("read source: %w", err)
}
converter := preprocessor.ForType(mimeType, preprocessor.Options{FontMapFile: w.fontMapFile, MaxImageSize: w.maxInputSize})
if converter == nil {
logger.Debug().Str("mimetype", mimeType).Msg("no converter for this type")
return nil, "", nil, fmt.Errorf("%w: no converter for %s", ErrNotFound, mimeType)
}
img, err := converter.Convert(bytes.NewReader(fileBytes))
if err != nil || img == nil {
logger.Debug().Err(err).Msg("could not convert file to image")
return nil, "", nil, fmt.Errorf("%w: could not get image", ErrNotFound)
}
return bytes.NewReader(img.Data), img.ContentType, func() {}, nil
}
// matchOperation picks how to fit within the box webdav already snapped. A
// processor the client named wins, "thumbnail" and "fit" being legacy aliases for
// fill and fit-in; without one, an unconstrained axis, then the source type
// decide, and "scalingup" is ignored throughout. Only fit-in never enlarges its
// source, so it is the one operation that suppresses upscaling.
func (w *ThumbnailWorkflow) matchOperation(tr *requests.ThumbnailRequest, mimeType string) (operation string, noUpscale bool) {
proc := strings.ToLower(strings.TrimSpace(tr.Processor))
switch proc {
case "resize":
operation = generator.OpStretch
case "fill", "thumbnail":
operation = generator.OpFill
case "fit", "fit-in":
operation = generator.OpFitIn
default: // no processor: a free axis, then the source type decide
switch {
case tr.Width == 0 || tr.Height == 0:
operation = generator.OpFitIn
case strings.HasPrefix(mimeType, "image/gif"):
operation = generator.OpStretch
default:
operation = generator.OpFill
}
}
noUpscale = operation == generator.OpFitIn
return operation, noUpscale
}
// quoteEscaper matches the escaping mime/multipart applies to part filenames.
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
// postToGenerator streams the source image to the generator as a multipart
// POST. The body is produced by an io.Pipe so the (potentially large) image is
// never fully buffered in webdav: the writer goroutine copies the reader into
// the form part on demand. No Content-Length is set, so the request uses chunked
// transfer-encoding; both imagor and the thumbnailer parse the multipart body
// without needing it (imagor enforces its size limit after reading the part).
func (w *ThumbnailWorkflow) postToGenerator(ctx context.Context, url string, img io.Reader, filename, contentType string) ([]byte, string, error) {
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
errCh := make(chan error, 1)
go func() {
defer pw.Close()
// Not CreateFormFile: it types the part application/octet-stream, which
// imagor's upload loader rejects (UPLOAD_LOADER_ACCEPT defaults to image/*).
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
`form-data; name="image"; filename="`+quoteEscaper.Replace(filename)+`"`)
h.Set("Content-Type", contentType)
part, err := writer.CreatePart(h)
if err != nil {
errCh <- fmt.Errorf("create form file: %w", err)
return
}
if _, err := io.Copy(part, img); err != nil {
errCh <- fmt.Errorf("write form data: %w", err)
return
}
if err := writer.Close(); err != nil {
errCh <- fmt.Errorf("close multipart writer: %w", err)
return
}
errCh <- nil
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
if err != nil {
pr.Close()
return nil, "", fmt.Errorf("create generator request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
// Forward the request ID so the generator's logs can be correlated with this
// request. No auth token is sent: the generator is an unauthenticated
// internal service (bound to loopback by default).
if reqID := chimiddleware.GetReqID(ctx); reqID != "" {
req.Header.Set("X-Request-ID", reqID)
}
httpRsp, err := w.httpClient.Do(req)
if err != nil {
pr.Close()
return nil, "", fmt.Errorf("generator request: %w", err)
}
defer httpRsp.Body.Close()
// The status is read before the writer's error. A generator that refuses the
// request without reading it, which is what 429 and 413 do, breaks the write
// half way, and that broken pipe must not mask the answer the client needs.
if httpRsp.StatusCode < 200 || httpRsp.StatusCode >= 300 {
respBody, _ := io.ReadAll(httpRsp.Body)
// The generator reports ErrMaxResolutionExceeded (422) when the decoded
// input exceeds its configured max width or height. The DAV layer answers
// the too-large error with 403.
if httpRsp.StatusCode == http.StatusUnprocessableEntity {
return nil, "", ErrImageTooLarge
}
if httpRsp.StatusCode == http.StatusTooManyRequests {
retryAfter, _ := strconv.Atoi(httpRsp.Header.Get("Retry-After"))
return nil, "", &GeneratorBusyError{RetryAfterSeconds: retryAfter}
}
// The generator bounds the whole request body, this service only the file,
// so the multipart framing can push a file just under the limit over it.
// Either way the source is too large, which is a 403, not a server fault.
if httpRsp.StatusCode == http.StatusRequestEntityTooLarge {
return nil, "", ErrImageTooLarge
}
// A source the generator cannot read is a missing preview, not a server
// fault, so the client gets a 404 and stops asking. A 400 means this
// service built the request wrong and must not read as one.
if httpRsp.StatusCode == http.StatusNotAcceptable {
return nil, "", fmt.Errorf("%w: generator refused the source: %s", ErrUnsupportedFileType, strings.TrimSpace(string(respBody)))
}
return nil, "", fmt.Errorf("generator returned status %d: %s", httpRsp.StatusCode, string(respBody))
}
if writeErr := <-errCh; writeErr != nil {
io.Copy(io.Discard, httpRsp.Body)
return nil, "", writeErr
}
rspData, err := io.ReadAll(httpRsp.Body)
if err != nil {
return nil, "", fmt.Errorf("read generator response: %w", err)
}
return rspData, httpRsp.Header.Get("Content-Type"), nil
}
// gatewaySelector is the interface for selecting a gateway client.
type gatewaySelector interface {
Next(...pool.Option) (gatewayv1beta1.GatewayAPIClient, error)
}
// gatewayUserResolver implements UserResolver using the CS3 gateway's WhoAmI.
type gatewayUserResolver struct {
selector gatewaySelector
}
func (g *gatewayUserResolver) WhoAmI(ctx context.Context, auth string) (*userv1beta1.User, error) {
client, err := g.selector.Next()
if err != nil {
return nil, fmt.Errorf("get gateway client: %w", err)
}
userRes, err := client.WhoAmI(ctx, &gatewayv1beta1.WhoAmIRequest{Token: auth})
if err != nil {
return nil, fmt.Errorf("whoami: %w", err)
}
if userRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
return nil, fmt.Errorf("whoami: %s", userRes.GetStatus().GetMessage())
}
return userRes.GetUser(), nil
}
func (g *gatewayUserResolver) GetUserByClaim(ctx context.Context, claim, value, auth string) (*userv1beta1.User, error) {
client, err := g.selector.Next()
if err != nil {
return nil, fmt.Errorf("get gateway client: %w", err)
}
ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, auth)
userRes, err := client.GetUserByClaim(ctx, &userv1beta1.GetUserByClaimRequest{Claim: claim, Value: value})
if err != nil {
return nil, fmt.Errorf("get user by claim: %w", err)
}
if userRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
return nil, fmt.Errorf("get user by claim: %s", userRes.GetStatus().GetMessage())
}
return userRes.GetUser(), nil
}
// NewGatewayUserResolver creates a UserResolver backed by the CS3 gateway.
func NewGatewayUserResolver(selector gatewaySelector) UserResolver {
return &gatewayUserResolver{selector: selector}
}
// gatewayStater implements Stater using the CS3 gateway.
type gatewayStater struct {
selector gatewaySelector
}
func (g *gatewayStater) Stat(ctx context.Context, ref *providerv1beta1.Reference, auth string) (*providerv1beta1.StatResponse, error) {
client, err := g.selector.Next()
if err != nil {
return nil, fmt.Errorf("get gateway client: %w", err)
}
ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, auth)
rsp, err := client.Stat(ctx, &providerv1beta1.StatRequest{Ref: ref})
if err != nil {
return nil, fmt.Errorf("stat request: %w", err)
}
switch rsp.GetStatus().GetCode() {
case rpcv1beta1.Code_CODE_OK:
// fallthrough
case rpcv1beta1.Code_CODE_NOT_FOUND:
// The resource does not exist or is not visible to the caller (e.g. a
// file of another user). Surface it as 404, matching the legacy
// thumbnail service.
return nil, fmt.Errorf("%w: %s", ErrNotFound, rsp.GetStatus().GetMessage())
default:
return nil, fmt.Errorf("stat failed: %s", rsp.GetStatus().GetMessage())
}
if rsp.GetInfo().GetType() != providerv1beta1.ResourceType_RESOURCE_TYPE_FILE {
return nil, ErrNotAFile
}
return rsp, nil
}
// gatewayFileDownloader implements FileDownloader using the CS3 gateway.
type gatewayFileDownloader struct {
selector gatewaySelector
httpClient *http.Client
}
func (g *gatewayFileDownloader) DownloadStream(ctx context.Context, ref *providerv1beta1.Reference, auth string) (io.ReadCloser, error) {
client, err := g.selector.Next()
if err != nil {
return nil, fmt.Errorf("get gateway client: %w", err)
}
ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, auth)
rsp, err := client.InitiateFileDownload(ctx, &providerv1beta1.InitiateFileDownloadRequest{Ref: ref})
if err != nil {
return nil, fmt.Errorf("initiate download: %w", err)
}
ep, _ := extractProtocol(rsp.GetProtocols())
if ep == "" {
return nil, fmt.Errorf("no suitable download protocol")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, http.NoBody)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
// Authenticate the data request with the user's token; the dataprovider
// (exposed or behind the proxy) validates it directly.
req.Header.Set(revactx.TokenHeader, auth)
httpRsp, err := g.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download request: %w", err)
}
if httpRsp.StatusCode != http.StatusOK {
httpRsp.Body.Close()
return nil, fmt.Errorf("download failed with status %d", httpRsp.StatusCode)
}
// Return the live response body; the caller owns and closes it. The bytes are
// never buffered or decoded in webdav.
return httpRsp.Body, nil
}
func extractProtocol(protocols []*gatewayv1beta1.FileDownloadProtocol) (endpoint, token string) {
for _, p := range protocols {
if p.GetProtocol() == "spaces" || p.GetProtocol() == "simple" {
return p.GetDownloadEndpoint(), p.GetToken()
}
}
for _, p := range protocols {
if p.GetDownloadEndpoint() != "" && p.GetToken() != "" {
return p.GetDownloadEndpoint(), p.GetToken()
}
}
return "", ""
}
// NewGatewayStater creates a Stater backed by the CS3 gateway.
func NewGatewayStater(selector gatewaySelector) Stater {
return &gatewayStater{selector: selector}
}
// NewGatewayFileDownloader creates a FileDownloader backed by the CS3 gateway.
func NewGatewayFileDownloader(selector gatewaySelector, httpClient *http.Client) FileDownloader {
return &gatewayFileDownloader{selector: selector, httpClient: httpClient}
}
// gatewaySpaceLookup implements SpaceLookup using the CS3 gateway's
// ListStorageSpaces, mirroring reva's ocdav spacelookup.LookUpStorageSpaceForPath.
type gatewaySpaceLookup struct {
selector gatewaySelector
}
func (g *gatewaySpaceLookup) Resolve(ctx context.Context, ref *providerv1beta1.Reference, auth string) (*providerv1beta1.Reference, error) {
client, err := g.selector.Next()
if err != nil {
return nil, fmt.Errorf("get gateway client: %w", err)
}
ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, auth)
lSSReq := &providerv1beta1.ListStorageSpacesRequest{
Opaque: &typesv1beta1.Opaque{
Map: map[string]*typesv1beta1.OpaqueEntry{
"path": {Decoder: "plain", Value: []byte(ref.GetPath())},
"unique": {Decoder: "plain", Value: []byte("true")},
},
},
}
lSSRes, err := client.ListStorageSpaces(ctx, lSSReq)
if err != nil {
return nil, fmt.Errorf("list storage spaces: %w", err)
}
if lSSRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
return nil, fmt.Errorf("list storage spaces: %s", lSSRes.GetStatus().GetMessage())
}
switch len(lSSRes.GetStorageSpaces()) {
case 0:
return nil, fmt.Errorf("%w: %q", ErrNoStorageSpace, ref.GetPath())
case 1:
space := lSSRes.GetStorageSpaces()[0]
if space.GetRoot() == nil {
return nil, fmt.Errorf("storage space for path %q has no root", ref.GetPath())
}
return &providerv1beta1.Reference{
ResourceId: space.GetRoot(),
Path: utils.MakeRelativePath(strings.TrimPrefix(ref.GetPath(), g.spaceMountPath(space))),
}, nil
default:
return nil, fmt.Errorf("too many storage spaces returned for path %q", ref.GetPath())
}
}
// spaceMountPath extracts the mount path recorded in a storage space's opaque map,
// used to trim the absolute path down to a space-relative one. It returns "" when
// the space is not mounted at a known path (in which case the full path is kept).
func (g *gatewaySpaceLookup) spaceMountPath(space *providerv1beta1.StorageSpace) string {
if space.GetOpaque() == nil || space.GetOpaque().GetMap()["path"] == nil {
return ""
}
return string(space.GetOpaque().GetMap()["path"].GetValue())
}
// NewGatewaySpaceLookup creates a SpaceLookup backed by the CS3 gateway.
func NewGatewaySpaceLookup(selector gatewaySelector) SpaceLookup {
return &gatewaySpaceLookup{selector: selector}
}
// ErrPublicLinkPasswordRequired is returned when a password-protected public
// link is accessed without (or with the wrong) password. Callers should surface
// this as HTTP 404 Not Found so the resource's existence is not revealed.
var ErrPublicLinkPasswordRequired = errors.New("public link requires a password")
// ErrPublicLinkExpired is returned when a public link token has expired.
// Callers should surface this as HTTP 410 Gone.
var ErrPublicLinkExpired = errors.New("public link has expired")
// ResolvePublicLinkAuth authenticates a public link token via the gateway. It
// returns ErrPublicLinkPasswordRequired or ErrPublicLinkExpired (wrapped) when
// the gateway reports the corresponding gRPC status code, so callers can branch
// on the error type instead of matching error text.
func ResolvePublicLinkAuth(ctx context.Context, r *http.Request, publicLinkToken string, selector gatewaySelector) (string, error) {
gatewayClient, err := selector.Next()
if err != nil {
return "", fmt.Errorf("could not get gateway client: %w", err)
}
q := r.URL.Query()
var rsp *gatewayv1beta1.AuthenticateResponse
if q.Get("signature") != "" && q.Get("expiration") != "" {
sig := q.Get("signature")
exp := q.Get("expiration")
rsp, err = gatewayClient.Authenticate(ctx, &gatewayv1beta1.AuthenticateRequest{
Type: "publicshares",
ClientId: publicLinkToken,
ClientSecret: strings.Join([]string{"signature", sig, exp}, "|"),
})
} else {
rsp, err = gatewayClient.Authenticate(ctx, &gatewayv1beta1.AuthenticateRequest{
Type: "publicshares",
ClientId: publicLinkToken,
ClientSecret: "password|",
})
}
if err != nil {
return "", fmt.Errorf("could not authenticate public link: %w", err)
}
switch rsp.GetStatus().GetCode() {
case rpcv1beta1.Code_CODE_OK:
return rsp.GetToken(), nil
case rpcv1beta1.Code_CODE_PERMISSION_DENIED, rpcv1beta1.Code_CODE_UNAUTHENTICATED:
// reva reports a wrong or missing link password as InvalidCredentials,
// which the gateway passes through as UNAUTHENTICATED.
return "", fmt.Errorf("%w: %s", ErrPublicLinkPasswordRequired, rsp.GetStatus().GetMessage())
case rpcv1beta1.Code_CODE_FAILED_PRECONDITION:
return "", fmt.Errorf("%w: %s", ErrPublicLinkExpired, rsp.GetStatus().GetMessage())
default:
return "", fmt.Errorf("public link authentication failed: code=%s message=%s", rsp.GetStatus().GetCode(), rsp.GetStatus().GetMessage())
}
}