Skip to content

feat(tus): return etag and permissions after the last TUS chunk#666

Closed
michaelstingl wants to merge 1 commit into
opencloud-eu:mainfrom
michaelstingl:issue-2409-tus-finalize-etag
Closed

feat(tus): return etag and permissions after the last TUS chunk#666
michaelstingl wants to merge 1 commit into
opencloud-eu:mainfrom
michaelstingl:issue-2409-tus-finalize-etag

Conversation

@michaelstingl

Copy link
Copy Markdown

Description

The response to the finalizing PATCH of a chunked TUS upload carried only the file id, so a client had to issue a follow-up PROPFIND to learn the new etag, an extra round-trip per upload. The creation-with-upload path returns the etag and permissions directly: the ocdav gateway stats the resource and sets OC-ETag/ETag/OC-Perm (next to OC-FileID, Last-Modified and Content-Type) in its finishUpload branch. The chunked path finalizes with a PATCH at the dataprovider, where setHeaders set only OC-FileID.

This registers tusd's PreFinishResponseCallback in the dataprovider tus handler (pkg/rhttp/datatx/manager/tus/tus.go) to stat the finalized resource and attach OC-ETag/ETag and OC-Perm. The chunked finalize now returns the same etag and permissions as the creation-with-upload path, so the two upload paths behave consistently. The alignment is deliberately limited to those headers: Last-Modified and Content-Type are not mirrored (clients do not need them at finalize, and Content-Type on the 204 finalize would carry no body), and the finalize stays a 204, not the gateway's 201.

The data path is authenticated by the reva transfer token, which carries no user identity, so a plain GetMD is permission-gated to NotFound. The callback restores the upload's executant into the context before the stat, the same way the decomposedfs upload session does in OcisSession.Context. If you would prefer the etag be surfaced through a small storage interface instead of reconstructing the executant in the datatx layer, I am happy to reshape it.

The permission derivation mirrors the gateway's creation-with-upload block verbatim, so the two stay in lockstep. The lookup is best-effort: on a stat error or a nil resource it logs at warn and sets no headers, leaving the PROPFIND fallback intact rather than failing a completed upload.

Related Issue

Motivation and Context

The desktop and Android clients already read the etag from the finalize response when present and PROPFIND otherwise, so the change is backward compatible: older servers keep working, updated ones drop the extra request. Both headers are set because the desktop fast-path needs the etag and the permissions together.

Under async postprocessing the etag at finalize is provisional, exactly as it already is for the creation-with-upload path. The existing UploadReady -> SSE postprocessing-finished flow and tree etag propagation communicate any later change to the client.

How Has This Been Tested?

  • test environment: reva main, built into a local opencloud single binary (7.1.0+dev) via a go.mod replace, Go 1.26
  • go build, gofmt, and the pinned golangci-lint v2.10.1 on the changed package: 0 issues
  • unit (ginkgo): pkg/rhttp/datatx/manager/tus/tus_internal_test.go (7 specs) covers executantFromUploadInfo (session-key mapping) and the callback with a fake storage.FS that records the reference and context user it was called with, asserting the reference is rebuilt from the session keys, the stat runs as the executant (whose identity flips the isShared marker), etag mirrored into both headers, best-effort no-op on stat error and on a nil resource, and etag omitted when absent
  • the fix, over HTTP: chunked upload (POST create + 2 PATCHes) against the patched binary, inspecting the finalize PATCH headers. Before: only OC-FileID. After: OC-ETag/ETag (equal to the etag a follow-up PROPFIND returns) and OC-Perm, with OC-FileID unchanged
  • confirmed identical with synchronous and OC_ASYNC_UPLOADS=true; the creation-with-upload path is unchanged
  • the api-integration/behat TUS suite runs after a maintainer approves fork CI

Screenshots (if appropriate):

n/a. Server-side response-header change.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Technical debt
  • Tests only (no source changes)

Checklist:

  • Code changes
  • Unit tests added (ginkgo, fake storage.FS; covers the callback success/best-effort/no-etag paths and the executant mapping)
  • Acceptance tests added (the behat suite has no "etag present on chunked finalize" scenario; can follow in the opencloud repo)
  • Documentation added (n/a: reva generates the changelog from the PR title + Type:* label via ready-release-go; no fragment needed)

🤖 drafted with Claude Code, reviewed before submitting.

The finalizing PATCH of a chunked TUS upload returned only the file id, so
clients had to issue a follow-up PROPFIND to learn the new etag. Register
tusd's PreFinishResponseCallback to stat the finalized resource and attach its
etag and WebDAV permissions, the same header set the ocdav gateway already
produces for the creation-with-upload path.

The data path is authenticated by the reva transfer token, which carries no
user identity, so the stat restores the upload's executant into the context
first, the same way the decomposedfs upload session does.

Fixes: opencloud-eu/opencloud#2409
@rhafer

rhafer commented Jun 8, 2026

Copy link
Copy Markdown
Member

The data path is authenticated by the reva transfer token, which carries no user identity, so a plain GetMD is permission-gated to NotFound. The callback restores the upload's executant into the context before the stat, the same way the decomposedfs upload session does in OcisSession.Context. If you would prefer the etag be surfaced through a small storage interface instead of reconstructing the executant in the datatx layer, I am happy to reshape it.

Would you mind sharing what you have in mind here? How would that "small storage interface" look like?

@rhafer rhafer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you would prefer the etag be surfaced through a small storage interface instead of reconstructing the executant in the datatx layer, I am happy to reshape it.

Somehow this sounds a bit cleaner. The current implementation relies on some keys being present in the uploads Fileinfo.MetaData and Fileinfo.Storage which AFAICT might be specific to the storagedriver. Even if we currently only have the decomposedfs/posixfs driver, I'd rather have the datatx layer not depend on that.

Also, please rebase on lastest main

Comment on lines +256 to +272
// derive the WebDAV permissions the same way the ocdav gateway does for the creation-with-upload path
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 := !net.IsCurrentUserOwnerOrManager(ctx, ri.GetOwner(), ri)
role := conversions.RoleFromResourcePermissions(ri.GetPermissionSet(), isPublic)
resp.Header[net.HeaderOCPermissions] = role.WebDAVPermissions(
ri.GetType() == provider.ResourceType_RESOURCE_TYPE_CONTAINER,
isShared,
false,
isPublic,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a verbatim copy of the code from ocdav/tus.go. Please avoid duplication and create a shared helper for this.

@michaelstingl

Copy link
Copy Markdown
Author

Reshaped along the lines you suggested, as a separate PR so both approaches stay visible side by side:

It moves the executant handling into the storage driver: a small UploadSessionResolver interface (one method) that decomposedfs implements, so the datatx layer no longer depends on the FileInfo.MetaData/FileInfo.Storage keys. The WebDAV-permission derivation is now shared with the gateway through net.WebDAVPermissions instead of copied (your tus.go comment); the propfind handler keeps its own share-types-aware variant. Rebased on current main.

This PR keeps the self-contained version for comparison. Happy to close whichever you prefer.

🤖 drafted with Claude Code, reviewed before submitting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No Etag after Last Chunk

2 participants