Skip to content

fix(dav): Do not respond version/trashbin download requests with 404 due to ChunkingV2Plugin#61680

Open
DerDreschner wants to merge 1 commit into
masterfrom
fix/prevent-404-on-versions
Open

fix(dav): Do not respond version/trashbin download requests with 404 due to ChunkingV2Plugin#61680
DerDreschner wants to merge 1 commit into
masterfrom
fix/prevent-404-on-versions

Conversation

@DerDreschner

@DerDreschner DerDreschner commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary.

This PR fixes an issue in the ChunkingV2Plugin that was introduced with #59780 . It's hard to spot, as it's being hidden in Nextcloud 30 and newer by using a different way of registering to the beforeMethod:GET sabre event.

Nextcloud 30 and newer uses the following way: $server->on('beforeMethod:GET', $this->beforeGet(...));
Nextcloud 29 and older uses the following way: $server->on('beforeMethod:GET', [$this, 'beforeGet']);

This slight change has an effect on the order of the plugins being executed. If the ChunkingV2Plugin is being run too soon - which is the case on Nextcloud 29 and older -, the file versions and trash bin items can't be downloaded due to a 404 being returned. This isn't the case once the PR is merged, as the ChunkingV2Plugin handles that case gracefully now.

This is a summary of the situation by AI:

ChunkingV2Plugin::beforeGet eagerly resolves the request path during beforeMethod:GET to block reading intermediate chunked uploads. App-provided DAV collections (versions, trashbin) are attached to the root lazily in a beforeMethod:* closure in Server.php, while uploads is registered eagerly. When beforeGet runs before that closure, getNodeForPath() throws NotFound and aborts the whole request, turning every GET under /dav/versions/ and /dav/trashbin/ into "404 File not found: versions in 'root'". PROPFIND is unaffected, since nothing resolves the path that early for it.

This does not currently surface on master only by accident of listener ordering: beforeGet and the collection closure share the default priority, and because beforeGet is registered as a first-class callable (a Closure), the wildcard closure happens to sort before it, so the collections are already attached by the time beforeGet resolves the path. That ordering is not guaranteed -- it depends on how equal-priority listeners are tie-broken -- so the unguarded resolution is a latent fault that any reordering can expose. On stable 28 it is already broken, because the backport registered the handler as an array callable, which tie-breaks the other way and runs beforeGet first.

Checklist

AI (if applicable)

  • The content of this PR was partly or fully generated using AI (ClaudeCode:claude-opus-4-8[1m])

@DerDreschner DerDreschner requested a review from a team as a code owner June 30, 2026 18:36
@DerDreschner DerDreschner requested review from Altahrim, come-nc, leftybournes, salmart-dev and susnux and removed request for a team June 30, 2026 18:36
@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable34

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable33

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable32

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable31

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable30

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable29

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable28

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable27

@DerDreschner

Copy link
Copy Markdown
Contributor Author

/backport to stable26

…due to ChunkingV2Plugin

ChunkingV2Plugin::beforeGet eagerly resolves the request path during
beforeMethod:GET to block reading intermediate chunked uploads. App-provided
DAV collections (versions, trashbin) are attached to the root lazily in a
beforeMethod:* closure in Server.php, while uploads is registered eagerly. When
beforeGet runs before that closure, getNodeForPath() throws NotFound and aborts
the whole request, turning every GET under /dav/versions/ and /dav/trashbin/
into "404 File not found: versions in 'root'". PROPFIND is unaffected, since
nothing resolves the path that early for it.

This does not currently surface on master only by accident of listener ordering:
beforeGet and the collection closure share the default priority, and because
beforeGet is registered as a first-class callable (a Closure), the wildcard
closure happens to sort before it, so the collections are already attached by the
time beforeGet resolves the path. That ordering is not guaranteed -- it depends on
how equal-priority listeners are tie-broken -- so the unguarded resolution is a
latent fault that any reordering can expose. On stable 28 it is already broken,
because the backport registered the handler as an array callable, which tie-breaks
the other way and runs beforeGet first.

Catch NotFound in beforeGet and bail out: a path that cannot be resolved is by
definition not an intermediate upload. This removes the dependency on listener
ordering entirely and makes the handler consistent with
beforePut()/beforeMove()/beforeDelete(), which already swallow NotFound for the
same reason.

Add a ChunkingV2Plugin unit test (the NotFound case is the regression guard) and
a file-versions integration scenario covering a real version download.

Assisted-by: ClaudeCode:claude-opus-4-8[1m]
Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
@DerDreschner DerDreschner force-pushed the fix/prevent-404-on-versions branch from 7434491 to 0136d82 Compare June 30, 2026 19:07
@DerDreschner

Copy link
Copy Markdown
Contributor Author

Failing Cypress run is unrelated to this change

@provokateurin

Copy link
Copy Markdown
Member

This slight change has an effect on the order of the plugins being executed

How so? I don't understand.

@DerDreschner

Copy link
Copy Markdown
Contributor Author

How so? I don't understand.

It's PHP internal stuff about what wins when an array and a closure have the same priority.

This is an easy description of the issue made by Claude, the array_multisort is what Sabre uses to make the sorting:

Why the registration style changes the order

array_multisort($listenersPriority, SORT_NUMERIC, $listeners) sorts by priority first. But priorities usually tie (everyone defaults to 100), so array_multisort breaks the tie by comparing the second array — the callables themselves. And PHP compares a Closure differently than an [$obj, 'method'] array:

Comparison <=> result
[$obj,'m'] vs Closure -1 → array always sorts before
Closure vs [$obj,'m'] +1 → object always sorts after

That type rule is deterministic: an array callable is always "less than" a closure. So the interesting case is the realistic one — in NC30 only ChunkingV2Plugin switched to $this->beforeGet(...) (a Closure) while every sibling GET listener stayed an array. The demo output:

=== MIXED  ->  only ChunkingV2Plugin is a Closure, the rest are arrays ===
after sort:
   #1  ->  Versions/Core GET handler   (serves the file)
   #2  ->  SomeOtherPlugin::beforeGet
   #3  ->  ChunkingV2Plugin::beforeGet (throws MethodNotAllowed for uploads)

vs. the all-array (NC29) form, where the same listeners keep their intended registration order:

=== Nextcloud 29-  ->  [$this,'beforeGet']  (array callables) ===
after sort:
   #1  ->  ChunkingV2Plugin::beforeGet
   #2  ->  Versions/Core GET handler
   #3  ->  SomeOtherPlugin::beforeGet

The single closure gets shoved to a different slot purely because object always sorts after array. Since Sabre's sort isn't a proper stable/consistent ordering when the keys tie, the "shape" of the callable silently decides who runs first — and that reorders the GET pipeline, which is what produces the DAV-versions 404.

@DerDreschner

DerDreschner commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Here is a demo that Claude made to illustrate the issue: https://onlinephp.io/c/3690dc

=== Nextcloud 29-  ->  [$this, 'beforeGet']  (array callables) ===
before sort:
   prio=100  type=array   
   prio=100  type=array   
   prio=100  type=array   
after sort (this is the order Sabre will actually call them):
   #1  ->  ChunkingV2Plugin::beforeGet (throws MethodNotAllowed for uploads)
   #2  ->  Versions/Core GET handler (serves the file -> the one we must reach)
   #3  ->  SomeOtherPlugin::beforeGet

=== Nextcloud 30+  ->  $this->beforeGet(...)  (Closure objects) ===
before sort:
   prio=100  type=Closure 
   prio=100  type=Closure 
   prio=100  type=Closure 
after sort (this is the order Sabre will actually call them):
   #1  ->  ChunkingV2Plugin::beforeGet (throws MethodNotAllowed for uploads)
   #2  ->  Versions/Core GET handler (serves the file -> the one we must reach)
   #3  ->  SomeOtherPlugin::beforeGet

=== MIXED  ->  only ChunkingV2Plugin is a Closure, the rest are arrays ===
before sort:
   prio=100  type=array   
   prio=100  type=Closure 
   prio=100  type=array   
after sort (this is the order Sabre will actually call them):
   #1  ->  Versions/Core GET handler (serves the file -> the one we must reach)
   #2  ->  SomeOtherPlugin::beforeGet
   #3  ->  ChunkingV2Plugin::beforeGet (throws MethodNotAllowed for uploads)

=== Why: how PHP compares a Closure vs an array callable ===
   [obj, "method"]  <=>  Closure   =>   -1  (array sorts before object)
   Closure          <=>  [obj,...]  =>   1  (object sorts after array)

   => An [$obj,'method'] callable and a $obj->method(...) Closure are NOT interchangeable
      as far as array_multisort's tie-break is concerned, so equal-priority GET
      listeners end up in a different order -> the wrong handler answers -> 404.

@DerDreschner DerDreschner self-assigned this Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants