Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,30 @@
package svc
import (
"net/http"
"github.com/qsfera/server/services/webdav/pkg/metrics"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// ServeHTTP implements the Service interface.
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
i.next.ServeHTTP(w, r)
}
// Thumbnail implements the Service interface.
func (i instrument) Thumbnail(w http.ResponseWriter, r *http.Request) {
i.next.Thumbnail(w, r)
}
@@ -0,0 +1,30 @@
package svc
import (
"net/http"
"github.com/qsfera/server/pkg/log"
)
// NewLogging returns a service that logs messages.
func NewLogging(next Service, logger log.Logger) Service {
return logging{
next: next,
logger: logger,
}
}
type logging struct {
next Service
logger log.Logger
}
// ServeHTTP implements the Service interface.
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l.next.ServeHTTP(w, r)
}
// Dummy implements the Service interface.
func (l logging) Thumbnail(w http.ResponseWriter, r *http.Request) {
l.next.Thumbnail(w, r)
}
@@ -0,0 +1,59 @@
package svc
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/webdav/pkg/config"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Middleware provides a function to set the middleware option.
func Middleware(val ...func(http.Handler) http.Handler) Option {
return func(o *Options) {
o.Middleware = val
}
}
// TraceProvider provides a function to set the traceProvider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
@@ -0,0 +1,350 @@
package svc
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"slices"
"strconv"
"strings"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
merrors "go-micro.dev/v4/errors"
"go-micro.dev/v4/metadata"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/tags"
"github.com/opencloud-eu/reva/v2/pkg/utils"
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail"
"github.com/qsfera/server/services/webdav/pkg/constants"
"github.com/qsfera/server/services/webdav/pkg/net"
"github.com/qsfera/server/services/webdav/pkg/prop"
"github.com/qsfera/server/services/webdav/pkg/propfind"
)
const (
elementNameSearchFiles = "search-files"
// TODO elementNameFilterFiles = "filter-files"
)
// Search is the endpoint for retrieving search results for REPORT requests
func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
logger := g.log.SubloggerWithRequestID(r.Context())
t := r.Header.Get(revactx.TokenHeader)
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not get reva gatewayClient")
renderError(w, r, errInternalError("could not get reva gatewayClient"))
return
}
user, err := whoami(gatewayClient, r.Context(), t)
if err != nil {
logger.Error().Err(err).Msg("could not get user")
renderError(w, r, errInternalError("could not get user"))
return
}
rep, err := readReport(r.Body)
if err != nil {
renderError(w, r, errBadRequest(err.Error()))
logger.Debug().Err(err).Msg("error reading report")
return
}
if rep.SearchFiles == nil {
renderError(w, r, errBadRequest("missing search-files tag"))
logger.Debug().Err(err).Msg("error reading report")
return
}
ctx := revactx.ContextSetToken(r.Context(), t)
ctx = metadata.Set(ctx, revactx.TokenHeader, t)
req := &searchsvc.SearchRequest{
Query: rep.SearchFiles.Search.Pattern,
PageSize: int32(rep.SearchFiles.Search.Limit),
}
// Limit search to the according space when searching /dav/spaces/
if strings.HasPrefix(r.URL.Path, "/dav/spaces") {
space := strings.TrimPrefix(r.URL.Path, "/dav/spaces/")
rid, err := storagespace.ParseID(space)
if err != nil {
logger.Debug().Err(err).Msg("error parsing the space id for filtering")
} else {
req.Ref = &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: rid.StorageId,
SpaceId: rid.SpaceId,
OpaqueId: rid.SpaceId,
},
}
}
}
rsp, err := g.searchClient.Search(ctx, req)
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
logger.Error().Err(err).Msg("could not get search results")
return
}
g.sendSearchResponse(rsp, w, r, user)
}
func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request, user *userv1beta1.User) {
logger := g.log.SubloggerWithRequestID(r.Context())
responsesXML, err := multistatusResponse(r.Context(), g.config.QsferaPublicURL, rsp.Matches, user)
if err != nil {
logger.Error().Err(err).Msg("error formatting propfind")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set(net.HeaderDav, "1, 3, extended-mkcol")
w.Header().Set(net.HeaderContentType, "application/xml; charset=utf-8")
if len(rsp.Matches) > 0 {
w.Header().Set(net.HeaderContentRange, fmt.Sprintf("rows 0-%d/%d", len(rsp.Matches)-1, rsp.TotalMatches))
}
w.WriteHeader(http.StatusMultiStatus)
if _, err := w.Write(responsesXML); err != nil {
logger.Err(err).Msg("error writing response")
}
}
// multistatusResponse converts a list of matches into a multistatus response string
func multistatusResponse(ctx context.Context, publicURL string, matches []*searchmsg.Match, user *userv1beta1.User) ([]byte, error) {
responses := make([]*propfind.ResponseXML, 0, len(matches))
for i := range matches {
res, err := matchToPropResponse(ctx, publicURL, matches[i], user)
if err != nil {
return nil, err
}
responses = append(responses, res)
}
msr := propfind.NewMultiStatusResponseXML()
msr.Responses = responses
msg, err := xml.Marshal(msr)
if err != nil {
return nil, err
}
return msg, nil
}
func matchToPropResponse(ctx context.Context, publicURL string, match *searchmsg.Match, user *userv1beta1.User) (*propfind.ResponseXML, error) {
// unfortunately, search uses own versions of ResourceId and Ref. So we need to assert them here
var (
ref string
err error
)
// to copy PROPFIND behaviour, we need to deliver different ids
// for shares it needs to be sharestorageproviderid!shareid
// for other spaces it needs to be storageproviderid$spaceid
switch match.Entity.Ref.ResourceId.StorageId {
default:
ref, err = storagespace.FormatReference(&provider.Reference{
ResourceId: &provider.ResourceId{
StorageId: match.Entity.Ref.ResourceId.StorageId,
SpaceId: match.Entity.Ref.ResourceId.SpaceId,
},
Path: match.Entity.Ref.Path,
})
case utils.ShareStorageProviderID:
ref, err = storagespace.FormatReference(&provider.Reference{
ResourceId: &provider.ResourceId{
//StorageId: match.Entity.Ref.ResourceId.StorageId,
SpaceId: match.Entity.Ref.ResourceId.SpaceId,
OpaqueId: match.Entity.Ref.ResourceId.OpaqueId,
},
Path: match.Entity.Ref.Path,
})
}
if err != nil {
return nil, err
}
response := propfind.ResponseXML{
Href: net.EncodePath(path.Join("/remote.php/dav/spaces/", ref)),
Propstat: []propfind.PropstatXML{},
}
propstatOK := propfind.PropstatXML{
Status: "HTTP/1.1 200 OK",
Prop: []prop.PropertyXML{},
}
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:fileid", storagespace.FormatResourceID(&provider.ResourceId{
StorageId: match.Entity.Id.StorageId,
SpaceId: match.Entity.Id.SpaceId,
OpaqueId: match.Entity.Id.OpaqueId,
})))
if match.Entity.ParentId != nil {
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:file-parent", storagespace.FormatResourceID(&provider.ResourceId{
StorageId: match.Entity.ParentId.StorageId,
SpaceId: match.Entity.ParentId.SpaceId,
OpaqueId: match.Entity.ParentId.OpaqueId,
})))
}
if match.Entity.Ref.ResourceId.StorageId == utils.ShareStorageProviderID {
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:shareid", match.Entity.Ref.ResourceId.OpaqueId))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:shareroot", match.Entity.ShareRootName))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:remote-item-id", storagespace.FormatResourceID(&provider.ResourceId{
StorageId: match.Entity.GetRemoteItemId().GetStorageId(),
SpaceId: match.Entity.GetRemoteItemId().GetSpaceId(),
OpaqueId: match.Entity.GetRemoteItemId().GetOpaqueId(),
})))
}
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:name", match.Entity.Name))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getlastmodified", match.Entity.LastModifiedTime.AsTime().Format(constants.RFC1123)))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", match.Entity.Permissions))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType))
_, isSupportedMimeType := thumbnail.SupportedMimeTypes[match.Entity.MimeType]
if isSupportedMimeType {
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1"))
} else {
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "0"))
}
t := tags.New(match.Entity.Tags...)
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:tags", t.AsList()))
// those seem empty - bug?
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getetag", match.Entity.Etag))
size := strconv.FormatUint(match.Entity.Size, 10)
if match.Entity.Type == uint64(provider.ResourceType_RESOURCE_TYPE_CONTAINER) {
propstatOK.Prop = append(propstatOK.Prop, prop.Raw("d:resourcetype", "<d:collection/>"))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:size", size))
} else {
propstatOK.Prop = append(propstatOK.Prop,
prop.Escaped("d:resourcetype", ""),
prop.Escaped("d:getcontentlength", size),
)
}
score := strconv.FormatFloat(float64(match.Score), 'f', -1, 64)
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:score", score))
if privateURL, err := url.Parse(publicURL); err == nil && match.Entity.Id != nil {
privateURL.Path = path.Join(privateURL.Path, "f", storagespace.FormatResourceID(&provider.ResourceId{
StorageId: match.Entity.Id.StorageId,
SpaceId: match.Entity.Id.SpaceId,
OpaqueId: match.Entity.Id.OpaqueId,
}))
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:privatelink", privateURL.String()))
}
// enrich results with favorite flag for the user performing the search
if slices.Contains(match.Entity.Favorites, user.Id.OpaqueId) {
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:favorite", "1"))
}
if len(propstatOK.Prop) > 0 {
response.Propstat = append(response.Propstat, propstatOK)
}
return &response, nil
}
func hasPreview(md *provider.ResourceInfo, appendToOK func(p ...prop.PropertyXML)) {
_, match := thumbnail.SupportedMimeTypes[md.MimeType]
if match {
appendToOK(prop.Escaped("oc:has-preview", "1"))
} else {
appendToOK(prop.Escaped("oc:has-preview", "0"))
}
}
type report struct {
SearchFiles *reportSearchFiles
// FilterFiles TODO add this for tag based search
FilterFiles *reportFilterFiles `xml:"filter-files"`
}
type reportSearchFiles struct {
XMLName xml.Name `xml:"search-files"`
Lang string `xml:"xml:lang,attr,omitempty"`
Prop Props `xml:"DAV: prop"`
Search reportSearchFilesSearch `xml:"search"`
}
type reportSearchFilesSearch struct {
Pattern string `xml:"pattern"`
Limit int `xml:"limit"`
Offset int `xml:"offset"`
}
type reportFilterFiles struct {
XMLName xml.Name `xml:"filter-files"`
Lang string `xml:"xml:lang,attr,omitempty"`
Prop Props `xml:"DAV: prop"`
Rules reportFilterFilesRules `xml:"filter-rules"`
}
type reportFilterFilesRules struct {
Favorite bool `xml:"favorite"`
SystemTag int `xml:"systemtag"`
}
// Props represents properties related to a resource
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for propfind)
type Props []xml.Name
// XML holds the xml representation of a propfind
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propfind
type XML struct {
XMLName xml.Name `xml:"DAV: propfind"`
Allprop *struct{} `xml:"DAV: allprop"`
Propname *struct{} `xml:"DAV: propname"`
Prop Props `xml:"DAV: prop"`
Include Props `xml:"DAV: include"`
}
func readReport(r io.Reader) (rep *report, err error) {
decoder := xml.NewDecoder(r)
rep = &report{}
for {
t, err := decoder.Token()
if err == io.EOF {
// io.EOF is a successful end
return rep, nil
}
if err != nil {
return nil, err
}
if v, ok := t.(xml.StartElement); ok {
if v.Name.Local == elementNameSearchFiles {
var repSF reportSearchFiles
err = decoder.DecodeElement(&repSF, &v)
if err != nil {
return nil, err
}
rep.SearchFiles = &repSF
/*
} else if v.Name.Local == elementNameFilterFiles {
var repFF reportFilterFiles
err = decoder.DecodeElement(&repFF, &v)
if err != nil {
return nil, http.StatusBadRequest, err
}
rep.FilterFiles = &repFF
*/
}
}
}
}
@@ -0,0 +1,587 @@
package svc
import (
"context"
"encoding/xml"
"fmt"
"io"
"math/rand/v2"
"net/http"
"net/url"
"path"
"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"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
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/storage/utils/templates"
"github.com/riandyrn/otelchi"
merrors "go-micro.dev/v4/errors"
grpcmetadata "google.golang.org/grpc/metadata"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/tracing"
thumbnailsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/thumbnails/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
thumbnailssvc "github.com/qsfera/server/protogen/gen/qsfera/services/thumbnails/v0"
"github.com/qsfera/server/services/webdav/pkg/config"
"github.com/qsfera/server/services/webdav/pkg/constants"
"github.com/qsfera/server/services/webdav/pkg/dav/requests"
)
var (
codesEnum = map[int]string{
http.StatusBadRequest: "Sabre\\DAV\\Exception\\BadRequest",
http.StatusUnauthorized: "Sabre\\DAV\\Exception\\NotAuthenticated",
http.StatusNotFound: "Sabre\\DAV\\Exception\\NotFound",
http.StatusMethodNotAllowed: "Sabre\\DAV\\Exception\\MethodNotAllowed",
}
)
// register the REPORT method at init so it cannot race with concurrent route setup in other services.
func init() {
chi.RegisterMethod("REPORT")
}
// Service defines the extension handlers.
type Service interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
Thumbnail(w http.ResponseWriter, r *http.Request)
}
// NewService returns a service implementation for Service.
func NewService(opts ...Option) (Service, error) {
options := newOptions(opts...)
conf := options.Config
m := chi.NewMux()
m.Use(
otelchi.Middleware(
conf.Service.Name,
otelchi.WithChiRoutes(m),
otelchi.WithTracerProvider(options.TraceProvider),
otelchi.WithPropagators(tracing.GetPropagator()),
),
)
tm, err := pool.StringToTLSMode(conf.GRPCClientTLS.Mode)
if err != nil {
return nil, err
}
gatewaySelector, err := pool.GatewaySelector(conf.RevaGateway,
pool.WithTLSCACert(conf.GRPCClientTLS.CACert),
pool.WithTLSMode(tm),
pool.WithRegistry(registry.GetRegistry()),
pool.WithTracerProvider(options.TraceProvider),
)
if err != nil {
return nil, err
}
svc := Webdav{
config: conf,
log: options.Logger,
mux: m,
searchClient: searchsvc.NewSearchProviderService("qsfera.api.search", conf.GrpcClient),
thumbnailsClient: thumbnailssvc.NewThumbnailService("qsfera.api.thumbnails", conf.GrpcClient),
gatewaySelector: gatewaySelector,
}
if svc.config.DisablePreviews {
svc.thumbnailsClient = nil
}
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
if !svc.config.DisablePreviews {
r.Group(func(r chi.Router) {
r.Use(svc.DavUserContext())
r.Get("/remote.php/dav/spaces/{id}", svc.SpacesThumbnail)
r.Get("/remote.php/dav/spaces/{id}/*", svc.SpacesThumbnail)
r.Get("/dav/spaces/{id}", svc.SpacesThumbnail)
r.Get("/dav/spaces/{id}/*", svc.SpacesThumbnail)
r.MethodFunc("REPORT", "/remote.php/dav/spaces*", svc.Search)
r.MethodFunc("REPORT", "/dav/spaces*", svc.Search)
r.Get("/remote.php/dav/files/{id}", svc.Thumbnail)
r.Get("/remote.php/dav/files/{id}/*", svc.Thumbnail)
r.Get("/dav/files/{id}", svc.Thumbnail)
r.Get("/dav/files/{id}/*", svc.Thumbnail)
r.MethodFunc("REPORT", "/remote.php/dav/files*", svc.Search)
r.MethodFunc("REPORT", "/dav/files*", svc.Search)
})
r.Group(func(r chi.Router) {
r.Use(svc.DavPublicContext())
r.Head("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnailHead)
r.Head("/dav/public-files/{token}/*", svc.PublicThumbnailHead)
r.Get("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnail)
r.Get("/dav/public-files/{token}/*", svc.PublicThumbnail)
})
r.Group(func(r chi.Router) {
r.Use(svc.WebDAVContext())
r.Get("/remote.php/webdav/*", svc.Thumbnail)
r.Get("/webdav/*", svc.Thumbnail)
r.MethodFunc("REPORT", "/remote.php/webdav*", svc.Search)
r.MethodFunc("REPORT", "/webdav*", svc.Search)
})
}
})
_ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
return nil
})
return svc, nil
}
// Webdav implements the business logic for Service.
type Webdav struct {
config *config.Config
log log.Logger
mux *chi.Mux
searchClient searchsvc.SearchProviderService
thumbnailsClient thumbnailssvc.ThumbnailService
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
}
// ServeHTTP implements the Service interface.
func (g Webdav) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mux.ServeHTTP(w, r)
}
func (g Webdav) DavUserContext() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
filePath := r.URL.Path
id := chi.URLParam(r, "id")
id, err := url.QueryUnescape(id)
if err == nil && id != "" {
ctx = context.WithValue(ctx, constants.ContextKeyID, id)
}
if id != "" {
filePath = strings.TrimPrefix(filePath, path.Join("/remote.php/dav/spaces", id))
filePath = strings.TrimPrefix(filePath, path.Join("/dav/spaces", id))
filePath = strings.TrimPrefix(filePath, path.Join("/remote.php/dav/files", id))
filePath = strings.TrimPrefix(filePath, path.Join("/dav/files", id))
filePath = strings.TrimPrefix(filePath, "/")
}
ctx = context.WithValue(ctx, constants.ContextKeyPath, filePath)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func (g Webdav) DavPublicContext() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
filePath := r.URL.Path
if token := chi.URLParam(r, "token"); token != "" {
filePath = strings.TrimPrefix(filePath, path.Join("/remote.php/dav/public-files", token)+"/")
filePath = strings.TrimPrefix(filePath, path.Join("/dav/public-files", token)+"/")
}
ctx = context.WithValue(ctx, constants.ContextKeyPath, filePath)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func (g Webdav) WebDAVContext() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Path
filePath = strings.TrimPrefix(filePath, "/remote.php")
filePath = strings.TrimPrefix(filePath, "/webdav/")
ctx := context.WithValue(r.Context(), constants.ContextKeyPath, filePath)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// SpacesThumbnail is the endpoint for retrieving thumbnails inside of spaces.
func (g Webdav) SpacesThumbnail(w http.ResponseWriter, r *http.Request) {
logger := g.log.SubloggerWithRequestID(r.Context())
tr, err := requests.ParseThumbnailRequest(r)
if err != nil {
logger.Debug().Err(err).Msg("could not create Request")
renderError(w, r, errBadRequest(err.Error()))
return
}
t := r.Header.Get(revactx.TokenHeader)
fullPath := filepath.Join(tr.Identifier, tr.Filepath)
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_Cs3Source{
Cs3Source: &thumbnailsmsg.CS3Source{
Path: fullPath,
Authorization: t,
},
},
})
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusTooEarly:
// StatusTooEarly if file is processing
renderError(w, r, errTooEarly(e.Detail))
return
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusForbidden:
renderError(w, r, errPermissionDenied(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
logger.Debug().Err(err).Msg("could not get thumbnail")
return
}
g.sendThumbnailResponse(rsp, w, r)
}
func whoami(gatewayClient gatewayv1beta1.GatewayAPIClient, ctx context.Context, token string) (*userv1beta1.User, error) {
// look up user from token via WhoAmI
userRes, err := gatewayClient.WhoAmI(ctx, &gatewayv1beta1.WhoAmIRequest{
Token: token,
})
if err != nil {
return nil, err
}
if userRes.Status.Code != rpcv1beta1.Code_CODE_OK {
return nil, fmt.Errorf("could not get user: %s", userRes.GetStatus().GetMessage())
}
return userRes.GetUser(), nil
}
// Thumbnail implements the Service interface.
func (g Webdav) Thumbnail(w http.ResponseWriter, r *http.Request) {
logger := g.log.SubloggerWithRequestID(r.Context())
tr, err := requests.ParseThumbnailRequest(r)
if err != nil {
logger.Debug().Err(err).Msg("could not create Request")
renderError(w, r, errBadRequest(err.Error()))
return
}
t := r.Header.Get(revactx.TokenHeader)
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not get reva gatewayClient")
renderError(w, r, errInternalError("could not get reva gatewayClient"))
return
}
var user *userv1beta1.User
if tr.Identifier == "" {
user, err = whoami(gatewayClient, r.Context(), t)
if err != nil {
logger.Error().Err(err).Msg("could not get user")
renderError(w, r, errInternalError("could not get user"))
return
}
} else {
// look up user from URL via GetUserByClaim
ctx := grpcmetadata.AppendToOutgoingContext(r.Context(), revactx.TokenHeader, t)
userRes, err := gatewayClient.GetUserByClaim(ctx, &userv1beta1.GetUserByClaimRequest{
Claim: "username",
Value: tr.Identifier,
})
if err != nil {
logger.Error().Err(err).Msg("could not get user: transport error")
renderError(w, r, errInternalError("could not get user"))
return
}
if userRes.Status.Code != rpcv1beta1.Code_CODE_OK {
logger.Debug().Str("grpcmessage", userRes.GetStatus().GetMessage()).Msg("could not get user")
renderError(w, r, errInternalError("could not get user"))
return
}
user = userRes.GetUser()
}
fullPath := filepath.Join(templates.WithUser(user, g.config.WebdavNamespace), tr.Filepath)
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_Cs3Source{
Cs3Source: &thumbnailsmsg.CS3Source{
Path: fullPath,
Authorization: t,
},
},
})
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusTooEarly:
// StatusTooEarly if file is processing
renderError(w, r, errTooEarly(e.Detail))
return
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusForbidden:
renderError(w, r, errPermissionDenied(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
g.log.Error().Err(err).Msg("could not get thumbnail")
return
}
g.sendThumbnailResponse(rsp, w, r)
}
func (g Webdav) PublicThumbnail(w http.ResponseWriter, r *http.Request) {
logger := g.log.SubloggerWithRequestID(r.Context())
tr, err := requests.ParseThumbnailRequest(r)
if err != nil {
logger.Debug().Err(err).Msg("could not create Request")
renderError(w, r, errBadRequest(err.Error()))
return
}
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_WebdavSource{
WebdavSource: &thumbnailsmsg.WebdavSource{
Url: g.config.QsferaPublicURL + r.URL.RequestURI(),
IsPublicLink: true,
PublicLinkToken: tr.PublicLinkToken,
},
},
})
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
g.log.Error().Err(err).Msg("could not get thumbnail")
return
}
g.sendThumbnailResponse(rsp, w, r)
}
func (g Webdav) PublicThumbnailHead(w http.ResponseWriter, r *http.Request) {
logger := g.log.SubloggerWithRequestID(r.Context())
tr, err := requests.ParseThumbnailRequest(r)
if err != nil {
logger.Debug().Err(err).Msg("could not create Request")
renderError(w, r, errBadRequest(err.Error()))
return
}
_, err = g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
Filepath: strings.TrimLeft(tr.Filepath, "/"),
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
Width: tr.Width,
Height: tr.Height,
Processor: tr.Processor,
Source: &thumbnailssvc.GetThumbnailRequest_WebdavSource{
WebdavSource: &thumbnailsmsg.WebdavSource{
Url: g.config.QsferaPublicURL + r.URL.RequestURI(),
IsPublicLink: true,
PublicLinkToken: tr.PublicLinkToken,
},
},
})
if err != nil {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusNotFound:
// StatusNotFound is expected for unsupported files
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
return
case http.StatusBadRequest:
renderError(w, r, errBadRequest(e.Detail))
case http.StatusTooManyRequests:
addRetryAfterHeader(w)
renderError(w, r, errTooManyRequests(e.Detail))
default:
renderError(w, r, errInternalError(err.Error()))
}
logger.Debug().Err(err).Msg("could not get thumbnail")
return
}
w.WriteHeader(http.StatusOK)
}
func (g Webdav) sendThumbnailResponse(rsp *thumbnailssvc.GetThumbnailResponse, w http.ResponseWriter, r *http.Request) {
logger := g.log.SubloggerWithRequestID(r.Context())
client := &http.Client{
// Timeout: time.Second * 5,
}
dlReq, err := http.NewRequest(http.MethodGet, rsp.DataEndpoint, http.NoBody)
if err != nil {
renderError(w, r, errInternalError(err.Error()))
logger.Error().Err(err).Msg("could not create download thumbnail request")
return
}
dlReq.Header.Set("Transfer-Token", rsp.TransferToken)
dlRsp, err := client.Do(dlReq)
if err != nil {
renderError(w, r, errInternalError(err.Error()))
logger.Error().Err(err).Msg("could not download thumbnail: transport error")
return
}
defer dlRsp.Body.Close()
if dlRsp.StatusCode != http.StatusOK {
logger.Debug().
Str("transfer_token", rsp.GetTransferToken()).
Str("data_endpoint", rsp.GetDataEndpoint()).
Str("response_status", dlRsp.Status).
Msg("could not download thumbnail")
renderError(w, r, newErrResponse(dlRsp.StatusCode, "could not download thumbnail"))
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", dlRsp.Header.Get("Content-Type"))
_, err = io.Copy(w, dlRsp.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to write thumbnail to response writer")
}
}
func extensionToThumbnailType(ext string) thumbnailsmsg.ThumbnailType {
switch strings.ToUpper(ext) {
case "GIF":
return thumbnailsmsg.ThumbnailType_GIF
case "PNG":
return thumbnailsmsg.ThumbnailType_PNG
default:
return thumbnailsmsg.ThumbnailType_JPG
}
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
type errResponse struct {
HTTPStatusCode int `json:"-" xml:"-"`
XMLName xml.Name `xml:"d:error"`
Xmlnsd string `xml:"xmlns:d,attr"`
Xmlnss string `xml:"xmlns:s,attr"`
Exception string `xml:"s:exception"`
Message string `xml:"s:message"`
InnerXML []byte `xml:",innerxml"`
}
func newErrResponse(statusCode int, msg string) *errResponse {
rsp := &errResponse{
HTTPStatusCode: statusCode,
Xmlnsd: "DAV",
Xmlnss: "http://sabredav.org/ns",
Exception: codesEnum[statusCode],
}
if msg != "" {
rsp.Message = msg
}
return rsp
}
func errInternalError(msg string) *errResponse {
return newErrResponse(http.StatusInternalServerError, msg)
}
func errBadRequest(msg string) *errResponse {
return newErrResponse(http.StatusBadRequest, msg)
}
func errPermissionDenied(msg string) *errResponse {
return newErrResponse(http.StatusForbidden, msg)
}
func errNotFound(msg string) *errResponse {
return newErrResponse(http.StatusNotFound, msg)
}
func errTooEarly(msg string) *errResponse {
return newErrResponse(http.StatusTooEarly, msg)
}
func errTooManyRequests(msg string) *errResponse {
return newErrResponse(http.StatusTooManyRequests, msg)
}
func renderError(w http.ResponseWriter, r *http.Request, err *errResponse) {
render.Status(r, err.HTTPStatusCode)
render.XML(w, r, err)
}
func notFoundMsg(name string) string {
return "File with name " + name + " could not be located"
}
func addRetryAfterHeader(w http.ResponseWriter) {
after := rand.IntN(14) + 1
w.Header().Set("Retry-After", strconv.Itoa(after))
}