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