Files
QSfera/Server/services/webdav/pkg/service/v0/search.go
T
Курнат Андрей 2315f25754 Initial QSfera import
2026-06-07 10:20:04 +03:00

351 lines
12 KiB
Go

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
*/
}
}
}
}