Implement cloud media UI and reliable automatic uploads
Android / test-and-build (push) Canceled after 0s
Server / deployment-config (push) Successful in 4m56s
Server / vulnerability-scan (push) Failing after 11m7s

This commit is contained in:
Курнат Андрей
2026-07-16 01:47:36 +03:00
parent e48e1e36a5
commit dd21285700
77 changed files with 3585 additions and 387 deletions
@@ -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
}