Implement cloud media UI and reliable automatic uploads
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
@@ -33,6 +34,7 @@ import (
|
||||
|
||||
const (
|
||||
elementNameSearchFiles = "search-files"
|
||||
defaultSearchPageSize = 200
|
||||
// TODO elementNameFilterFiles = "filter-files"
|
||||
)
|
||||
|
||||
@@ -71,9 +73,19 @@ func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := revactx.ContextSetToken(r.Context(), t)
|
||||
ctx = metadata.Set(ctx, revactx.TokenHeader, t)
|
||||
|
||||
pageSize, err := searchPageSize(
|
||||
rep.SearchFiles.Search.Limit,
|
||||
rep.SearchFiles.Search.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
renderError(w, r, errBadRequest(err.Error()))
|
||||
logger.Debug().Err(err).Msg("invalid search pagination")
|
||||
return
|
||||
}
|
||||
|
||||
req := &searchsvc.SearchRequest{
|
||||
Query: rep.SearchFiles.Search.Pattern,
|
||||
PageSize: int32(rep.SearchFiles.Search.Limit),
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
// Limit search to the according space when searching /dav/spaces/
|
||||
@@ -105,10 +117,63 @@ func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Error().Err(err).Msg("could not get search results")
|
||||
return
|
||||
}
|
||||
g.sendSearchResponse(rsp, w, r, user)
|
||||
applySearchPage(
|
||||
rsp,
|
||||
rep.SearchFiles.Search.Offset,
|
||||
rep.SearchFiles.Search.Limit,
|
||||
)
|
||||
g.sendSearchResponse(rsp, w, r, user, rep.SearchFiles.Search.Offset)
|
||||
}
|
||||
|
||||
func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request, user *userv1beta1.User) {
|
||||
// searchPageSize converts WebDAV offset/limit pagination into the larger first
|
||||
// page requested from the search service. The search service currently exposes
|
||||
// no numeric offset, so asking it for offset+limit and slicing below preserves
|
||||
// its existing relevance ordering without changing the internal search API.
|
||||
func searchPageSize(limit, offset int) (int32, error) {
|
||||
if offset < 0 {
|
||||
return 0, fmt.Errorf("search offset must not be negative")
|
||||
}
|
||||
if limit < -1 {
|
||||
return 0, fmt.Errorf("search limit must be -1 or greater")
|
||||
}
|
||||
if limit == -1 {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
effectiveLimit := limit
|
||||
if effectiveLimit == 0 {
|
||||
effectiveLimit = defaultSearchPageSize
|
||||
if offset == 0 {
|
||||
// Keep zero so the search service remains the source of truth for
|
||||
// its default page size when no offset was requested.
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
if offset > math.MaxInt32-effectiveLimit {
|
||||
return 0, fmt.Errorf("search offset and limit are too large")
|
||||
}
|
||||
|
||||
return int32(offset + effectiveLimit), nil
|
||||
}
|
||||
|
||||
func applySearchPage(rsp *searchsvc.SearchResponse, offset, limit int) {
|
||||
if rsp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
start := min(offset, len(rsp.Matches))
|
||||
end := len(rsp.Matches)
|
||||
if limit != -1 {
|
||||
effectiveLimit := limit
|
||||
if effectiveLimit == 0 {
|
||||
effectiveLimit = defaultSearchPageSize
|
||||
}
|
||||
end = min(start+effectiveLimit, end)
|
||||
}
|
||||
rsp.Matches = rsp.Matches[start:end]
|
||||
}
|
||||
|
||||
func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request, user *userv1beta1.User, offset int) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
responsesXML, err := multistatusResponse(r.Context(), g.config.QsferaPublicURL, rsp.Matches, user)
|
||||
if err != nil {
|
||||
@@ -119,7 +184,7 @@ func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.Respons
|
||||
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.Header().Set(net.HeaderContentRange, searchContentRange(offset, len(rsp.Matches), rsp.TotalMatches))
|
||||
}
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
if _, err := w.Write(responsesXML); err != nil {
|
||||
@@ -127,6 +192,10 @@ func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.Respons
|
||||
}
|
||||
}
|
||||
|
||||
func searchContentRange(offset, count int, total int32) string {
|
||||
return fmt.Sprintf("rows %d-%d/%d", offset, offset+count-1, total)
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
)
|
||||
|
||||
func TestSearchPageSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
limit int
|
||||
offset int
|
||||
want int32
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "existing default without offset", want: 0},
|
||||
{name: "explicit limit", limit: 25, want: 25},
|
||||
{name: "explicit limit with offset", limit: 25, offset: 50, want: 75},
|
||||
{name: "default limit with offset", offset: 10, want: 210},
|
||||
{name: "unlimited", limit: -1, offset: 10, want: -1},
|
||||
{name: "negative offset", limit: 25, offset: -1, wantErr: true},
|
||||
{name: "invalid negative limit", limit: -2, wantErr: true},
|
||||
{name: "overflow", limit: 1, offset: math.MaxInt32, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := searchPageSize(tt.limit, tt.offset)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("searchPageSize returned an error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("searchPageSize = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySearchPage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
offset int
|
||||
limit int
|
||||
want []string
|
||||
}{
|
||||
{name: "explicit page", count: 5, offset: 2, limit: 2, want: []string{"2", "3"}},
|
||||
{name: "unlimited after offset", count: 5, offset: 3, limit: -1, want: []string{"3", "4"}},
|
||||
{name: "offset beyond results", count: 3, offset: 10, limit: 2, want: []string{}},
|
||||
{name: "default page", count: 205, offset: 5, want: numberStrings(5, 205)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rsp := &searchsvc.SearchResponse{
|
||||
Matches: namedMatches(tt.count),
|
||||
TotalMatches: int32(tt.count + 10),
|
||||
}
|
||||
applySearchPage(rsp, tt.offset, tt.limit)
|
||||
|
||||
got := make([]string, len(rsp.Matches))
|
||||
for i := range rsp.Matches {
|
||||
got[i] = rsp.Matches[i].GetEntity().GetName()
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
|
||||
t.Fatalf("page = %v, want %v", got, tt.want)
|
||||
}
|
||||
if rsp.TotalMatches != int32(tt.count+10) {
|
||||
t.Fatalf("TotalMatches changed to %d", rsp.TotalMatches)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadReportParsesOffset(t *testing.T) {
|
||||
rep, err := readReport(strings.NewReader(`
|
||||
<oc:search-files xmlns:oc="http://owncloud.org/ns">
|
||||
<oc:search>
|
||||
<oc:pattern>mediatype:image</oc:pattern>
|
||||
<oc:limit>40</oc:limit>
|
||||
<oc:offset>80</oc:offset>
|
||||
</oc:search>
|
||||
</oc:search-files>`))
|
||||
if err != nil {
|
||||
t.Fatalf("readReport returned an error: %v", err)
|
||||
}
|
||||
if rep.SearchFiles == nil {
|
||||
t.Fatal("search-files was not parsed")
|
||||
}
|
||||
if got := rep.SearchFiles.Search.Offset; got != 80 {
|
||||
t.Fatalf("offset = %d, want 80", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchContentRange(t *testing.T) {
|
||||
if got, want := searchContentRange(80, 40, 137), "rows 80-119/137"; got != want {
|
||||
t.Fatalf("searchContentRange = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func namedMatches(count int) []*searchmsg.Match {
|
||||
matches := make([]*searchmsg.Match, count)
|
||||
for i, name := range numberStrings(0, count) {
|
||||
matches[i] = &searchmsg.Match{Entity: &searchmsg.Entity{Name: name}}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func numberStrings(start, end int) []string {
|
||||
values := make([]string, 0, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
values = append(values, fmt.Sprint(i))
|
||||
}
|
||||
return values
|
||||
}
|
||||
Reference in New Issue
Block a user