Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions internal/http/services/owncloud/ocdav/net/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright 2018-2026 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.

package net

import (
"context"
"encoding/json"

link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"

"github.com/opencloud-eu/reva/v2/pkg/conversions"
)

// WebDAVPermissions derives the WebDAV permissions string (the OC-Perm header value) for a
// resource. It is shared by the ocdav gateway's creation-with-upload response and the
// dataprovider's chunked TUS finalize response, so the two report the same permissions.
func WebDAVPermissions(ctx context.Context, ri *provider.ResourceInfo) string {
isPublic := false
if o := ri.GetOpaque(); o != nil && o.Map != nil {
if e := o.Map["link-share"]; e != nil && e.Decoder == "json" {
ls := &link.PublicShare{}
_ = json.Unmarshal(e.Value, ls)
isPublic = ls != nil
}
}
isShared := !IsCurrentUserOwnerOrManager(ctx, ri.GetOwner(), ri)
role := conversions.RoleFromResourcePermissions(ri.GetPermissionSet(), isPublic)
return role.WebDAVPermissions(
ri.GetType() == provider.ResourceType_RESOURCE_TYPE_CONTAINER,
isShared,
false,
isPublic,
)
}
80 changes: 80 additions & 0 deletions internal/http/services/owncloud/ocdav/net/permissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2018-2026 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.

package net_test

import (
"context"
"encoding/json"

userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typespb "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/net"
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("WebDAVPermissions", func() {
var (
owner = &userpb.User{Id: &userpb.UserId{OpaqueId: "owner"}, Username: "owner"}
other = &userpb.User{Id: &userpb.UserId{OpaqueId: "other"}, Username: "other"}

// a resource the user may edit, owned by "owner"
resource = func(t provider.ResourceType, opaque *typespb.Opaque) *provider.ResourceInfo {
return &provider.ResourceInfo{
Type: t,
Owner: owner.Id,
Opaque: opaque,
PermissionSet: &provider.ResourcePermissions{
Stat: true, ListContainer: true, InitiateFileDownload: true,
InitiateFileUpload: true, Move: true, Delete: true,
},
}
}

ownerCtx = ctxpkg.ContextSetUser(context.Background(), owner)
otherCtx = ctxpkg.ContextSetUser(context.Background(), other)
)

It("omits the shared marker when the current user owns the resource", func() {
Expect(net.WebDAVPermissions(ownerCtx, resource(provider.ResourceType_RESOURCE_TYPE_FILE, nil))).ToNot(HavePrefix("S"))
})

It("leads with the shared marker when the resource is not owned by the current user", func() {
Expect(net.WebDAVPermissions(otherCtx, resource(provider.ResourceType_RESOURCE_TYPE_FILE, nil))).To(HavePrefix("S"))
})

It("returns a permission string for a container", func() {
perms := net.WebDAVPermissions(otherCtx, resource(provider.ResourceType_RESOURCE_TYPE_CONTAINER, nil))
Expect(perms).ToNot(BeEmpty())
Expect(perms).To(HavePrefix("S"))
})

It("handles a public-link resource via the link-share opaque", func() {
b, err := json.Marshal(&link.PublicShare{Id: &link.PublicShareId{OpaqueId: "share-1"}})
Expect(err).ToNot(HaveOccurred())
opaque := &typespb.Opaque{Map: map[string]*typespb.OpaqueEntry{
"link-share": {Decoder: "json", Value: b},
}}
Expect(net.WebDAVPermissions(ownerCtx, resource(provider.ResourceType_RESOURCE_TYPE_FILE, opaque))).ToNot(BeEmpty())
})
})
21 changes: 1 addition & 20 deletions internal/http/services/owncloud/ocdav/tus.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ package ocdav

import (
"context"
"encoding/json"
"io"
"net/http"
"path"
Expand All @@ -30,14 +29,12 @@ import (
"time"

rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typespb "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/errors"
"github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/net"
"github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/spacelookup"
"github.com/opencloud-eu/reva/v2/pkg/appctx"
"github.com/opencloud-eu/reva/v2/pkg/conversions"
"github.com/opencloud-eu/reva/v2/pkg/rhttp"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
Expand Down Expand Up @@ -374,23 +371,7 @@ func (s *svc) handleTusPost(ctx context.Context, w http.ResponseWriter, r *http.
return
}

// get WebDav permissions for file
isPublic := false
if info.Opaque != nil && info.Opaque.Map != nil {
if info.Opaque.Map["link-share"] != nil && info.Opaque.Map["link-share"].Decoder == "json" {
ls := &link.PublicShare{}
_ = json.Unmarshal(info.Opaque.Map["link-share"].Value, ls)
isPublic = ls != nil
}
}
isShared := !net.IsCurrentUserOwnerOrManager(ctx, info.Owner, info)
role := conversions.RoleFromResourcePermissions(info.PermissionSet, isPublic)
permissions := role.WebDAVPermissions(
info.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER,
isShared,
false,
isPublic,
)
permissions := net.WebDAVPermissions(ctx, info)

w.Header().Set(net.HeaderContentType, info.MimeType)
w.Header().Set(net.HeaderOCFileID, storagespace.FormatResourceID(info.Id))
Expand Down
39 changes: 39 additions & 0 deletions pkg/rhttp/datatx/manager/tus/tus.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ func (m *manager) Handler(fs storage.FS) (http.Handler, error) {
StoreComposer: composer,
NotifyCompleteUploads: true,
Logger: slog.New(tusdLogger{log: m.log}),
// Add the finalized resource's etag and permissions to the last chunk's response
// (see preFinishResponseCallback). https://github.com/opencloud-eu/opencloud/issues/2409
PreFinishResponseCallback: m.preFinishResponseCallback(fs),
}

if m.conf.CorsEnabled {
Expand Down Expand Up @@ -207,6 +210,42 @@ func (m *manager) Handler(fs storage.FS) (http.Handler, error) {
return h, nil
}

// preFinishResponseCallback returns the tusd callback that runs when an upload completes. Before
// the final response is written, it looks up the finalized resource and attaches its etag and
// WebDAV permissions to the response headers, so clients do not need a follow-up PROPFIND after
// the last chunk. It mirrors the etag and permissions the ocdav gateway already produces for the
// creation-with-upload path. The storage driver resolves the resource as the upload's executant
// (the data path's transfer token carries no user identity), so this layer does not reconstruct
// the user. It is best effort: it logs any lookup failure and still completes the upload (the
// client falls back to a PROPFIND). https://github.com/opencloud-eu/opencloud/issues/2409
func (m *manager) preFinishResponseCallback(fs storage.FS) func(tusd.HookEvent) (tusd.HTTPResponse, error) {
resolver, ok := fs.(storage.UploadSessionResolver)
return func(hook tusd.HookEvent) (tusd.HTTPResponse, error) {
resp := tusd.HTTPResponse{Header: tusd.HTTPHeader{}}
if !ok {
// the storage driver cannot resolve the upload; the client falls back to a PROPFIND
return resp, nil
}

ri, ctx, err := resolver.ResolveUpload(hook.Context, hook.Upload)
if err != nil || ri == nil {
// The stat can fail for a finished upload (for example an expired upload token or a
// removed node); the client recovers with a PROPFIND, so this is a warning, not an error.
m.log.Warn().Err(err).Str("uploadid", hook.Upload.ID).Msg("could not stat finished upload, not setting etag/permission headers")
return resp, nil
}

if etag := ri.GetEtag(); etag != "" {
resp.Header[net.HeaderOCETag] = etag
resp.Header[net.HeaderETag] = etag
}

resp.Header[net.HeaderOCPermissions] = net.WebDAVPermissions(ctx, ri)

return resp, nil
}
}

func setHeaders(fs storage.FS, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := path.Base(r.URL.Path)
Expand Down
139 changes: 139 additions & 0 deletions pkg/rhttp/datatx/manager/tus/tus_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0

package tus

import (
"context"
"errors"

userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/rs/zerolog"
tusd "github.com/tus/tusd/v2/pkg/handler"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/opencloud-eu/reva/v2/internal/http/services/owncloud/ocdav/net"
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storage"
)

// fakeResolver implements storage.UploadSessionResolver. Embedding storage.FS satisfies the
// rest of that interface (those methods are never called by the callback under test). It
// records the upload info it was called with and returns a preconfigured resource, context
// and error, so the tests can drive the callback without a real storage backend.
type fakeResolver struct {
storage.FS
ri *provider.ResourceInfo
ctx context.Context
err error
gotInfo tusd.FileInfo
}

func (f *fakeResolver) ResolveUpload(ctx context.Context, info tusd.FileInfo) (*provider.ResourceInfo, context.Context, error) {
f.gotInfo = info
rctx := f.ctx
if rctx == nil {
rctx = ctx
}
return f.ri, rctx, f.err
}

// plainFS implements only storage.FS, not the resolver, so the callback takes its no-op path.
type plainFS struct{ storage.FS }

// testManager builds a manager with a no-op logger, the way New sets one in production, so the
// callback's warn path does not dereference a nil logger.
func testManager() *manager {
l := zerolog.Nop()
return &manager{log: &l}
}

var _ = Describe("preFinishResponseCallback", func() {
executant := &userpb.UserId{Idp: "https://idp.example", OpaqueId: "executant-1"}
asExecutant := ctxpkg.ContextSetUser(context.Background(), &userpb.User{Id: executant})

// a resource the executant may edit; owned by someone else unless overridden
editable := func(etag, ownerID string) *provider.ResourceInfo {
return &provider.ResourceInfo{
Etag: etag,
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Owner: &userpb.UserId{Idp: "https://idp.example", OpaqueId: ownerID},
PermissionSet: &provider.ResourcePermissions{
Stat: true, ListContainer: true, InitiateFileDownload: true,
InitiateFileUpload: true, Move: true, Delete: true,
},
}
}

hook := tusd.HookEvent{
Context: context.Background(),
Upload: tusd.FileInfo{ID: "upload-1", Storage: map[string]string{"NodeId": "node-1"}},
}

It("resolves the finished upload and sets etag and permissions", func() {
fs := &fakeResolver{ri: editable(`"abc123"`, "other-owner"), ctx: asExecutant}

resp, err := testManager().preFinishResponseCallback(fs)(hook)

Expect(err).ToNot(HaveOccurred())
// the in-hand tus info is handed to the resolver, not looked up by id in the session store
Expect(fs.gotInfo.ID).To(Equal("upload-1"))
// etag mirrored into both headers
Expect(resp.Header[net.HeaderOCETag]).To(Equal(`"abc123"`))
Expect(resp.Header[net.HeaderETag]).To(Equal(`"abc123"`))
// resolved as a user who does not own the resource -> shared -> permissions lead with S
Expect(resp.Header[net.HeaderOCPermissions]).To(HavePrefix("S"))
})

It("omits the shared marker when the resource is owned by the resolving user", func() {
fs := &fakeResolver{ri: editable(`"abc123"`, "executant-1"), ctx: asExecutant}

resp, err := testManager().preFinishResponseCallback(fs)(hook)

Expect(err).ToNot(HaveOccurred())
Expect(resp.Header[net.HeaderOCPermissions]).ToNot(BeEmpty())
Expect(resp.Header[net.HeaderOCPermissions]).ToNot(HavePrefix("S"))
})

It("is best-effort: no headers and no error when the resolve fails", func() {
fs := &fakeResolver{err: errors.New("not found"), ctx: asExecutant}

resp, err := testManager().preFinishResponseCallback(fs)(hook)

Expect(err).ToNot(HaveOccurred())
Expect(resp.Header).ToNot(HaveKey(net.HeaderOCETag))
Expect(resp.Header).ToNot(HaveKey(net.HeaderETag))
Expect(resp.Header).ToNot(HaveKey(net.HeaderOCPermissions))
})

It("is best-effort: no headers and no error when the resolve returns no resource", func() {
fs := &fakeResolver{ri: nil, ctx: asExecutant}

resp, err := testManager().preFinishResponseCallback(fs)(hook)

Expect(err).ToNot(HaveOccurred())
Expect(resp.Header).ToNot(HaveKey(net.HeaderOCPermissions))
Expect(resp.Header).ToNot(HaveKey(net.HeaderOCETag))
})

It("omits the etag header when the resource has none", func() {
fs := &fakeResolver{ri: editable("", "other-owner"), ctx: asExecutant}

resp, err := testManager().preFinishResponseCallback(fs)(hook)

Expect(err).ToNot(HaveOccurred())
Expect(resp.Header).ToNot(HaveKey(net.HeaderOCETag))
Expect(resp.Header).To(HaveKey(net.HeaderOCPermissions))
})

It("does nothing when the storage driver cannot resolve uploads", func() {
resp, err := testManager().preFinishResponseCallback(plainFS{})(hook)

Expect(err).ToNot(HaveOccurred())
Expect(resp.Header).ToNot(HaveKey(net.HeaderOCETag))
Expect(resp.Header).ToNot(HaveKey(net.HeaderOCPermissions))
})
})
16 changes: 16 additions & 0 deletions pkg/rhttp/datatx/manager/tus/tus_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0

package tus

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestTus(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Tus Suite")
}
Loading