Skip to content

Commit cc4db88

Browse files
committed
feat(dav): Add occ dav:show-listeners to allow better insides into DAV stack
Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
1 parent 507c0a7 commit cc4db88

7 files changed

Lines changed: 927 additions & 0 deletions

File tree

apps/dav/appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
<command>OCA\DAV\Command\RetentionCleanupCommand</command>
8181
<command>OCA\DAV\Command\SendEventReminders</command>
8282
<command>OCA\DAV\Command\SetAbsenceCommand</command>
83+
<command>OCA\DAV\Command\ShowListeners</command>
8384
<command>OCA\DAV\Command\SyncBirthdayCalendar</command>
8485
<command>OCA\DAV\Command\SyncSystemAddressBook</command>
8586
</commands>

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,13 @@
197197
'OCA\\DAV\\Command\\ListCalendarShares' => $baseDir . '/../lib/Command/ListCalendarShares.php',
198198
'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php',
199199
'OCA\\DAV\\Command\\ListSubscriptions' => $baseDir . '/../lib/Command/ListSubscriptions.php',
200+
'OCA\\DAV\\Command\\ListenerIntrospector' => $baseDir . '/../lib/Command/ListenerIntrospector.php',
200201
'OCA\\DAV\\Command\\MoveCalendar' => $baseDir . '/../lib/Command/MoveCalendar.php',
201202
'OCA\\DAV\\Command\\RemoveInvalidShares' => $baseDir . '/../lib/Command/RemoveInvalidShares.php',
202203
'OCA\\DAV\\Command\\RetentionCleanupCommand' => $baseDir . '/../lib/Command/RetentionCleanupCommand.php',
203204
'OCA\\DAV\\Command\\SendEventReminders' => $baseDir . '/../lib/Command/SendEventReminders.php',
204205
'OCA\\DAV\\Command\\SetAbsenceCommand' => $baseDir . '/../lib/Command/SetAbsenceCommand.php',
206+
'OCA\\DAV\\Command\\ShowListeners' => $baseDir . '/../lib/Command/ShowListeners.php',
205207
'OCA\\DAV\\Command\\SyncBirthdayCalendar' => $baseDir . '/../lib/Command/SyncBirthdayCalendar.php',
206208
'OCA\\DAV\\Command\\SyncSystemAddressBook' => $baseDir . '/../lib/Command/SyncSystemAddressBook.php',
207209
'OCA\\DAV\\Comments\\CommentNode' => $baseDir . '/../lib/Comments/CommentNode.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,13 @@ class ComposerStaticInitDAV
212212
'OCA\\DAV\\Command\\ListCalendarShares' => __DIR__ . '/..' . '/../lib/Command/ListCalendarShares.php',
213213
'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php',
214214
'OCA\\DAV\\Command\\ListSubscriptions' => __DIR__ . '/..' . '/../lib/Command/ListSubscriptions.php',
215+
'OCA\\DAV\\Command\\ListenerIntrospector' => __DIR__ . '/..' . '/../lib/Command/ListenerIntrospector.php',
215216
'OCA\\DAV\\Command\\MoveCalendar' => __DIR__ . '/..' . '/../lib/Command/MoveCalendar.php',
216217
'OCA\\DAV\\Command\\RemoveInvalidShares' => __DIR__ . '/..' . '/../lib/Command/RemoveInvalidShares.php',
217218
'OCA\\DAV\\Command\\RetentionCleanupCommand' => __DIR__ . '/..' . '/../lib/Command/RetentionCleanupCommand.php',
218219
'OCA\\DAV\\Command\\SendEventReminders' => __DIR__ . '/..' . '/../lib/Command/SendEventReminders.php',
219220
'OCA\\DAV\\Command\\SetAbsenceCommand' => __DIR__ . '/..' . '/../lib/Command/SetAbsenceCommand.php',
221+
'OCA\\DAV\\Command\\ShowListeners' => __DIR__ . '/..' . '/../lib/Command/ShowListeners.php',
220222
'OCA\\DAV\\Command\\SyncBirthdayCalendar' => __DIR__ . '/..' . '/../lib/Command/SyncBirthdayCalendar.php',
221223
'OCA\\DAV\\Command\\SyncSystemAddressBook' => __DIR__ . '/..' . '/../lib/Command/SyncSystemAddressBook.php',
222224
'OCA\\DAV\\Comments\\CommentNode' => __DIR__ . '/..' . '/../lib/Comments/CommentNode.php',
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\DAV\Command;
11+
12+
use Sabre\DAV\Server as SabreServer;
13+
14+
/**
15+
* Reads the listener registrations out of a Sabre server via reflection so
16+
* they can be displayed for debugging (see the dav:show-listeners command).
17+
*
18+
* This intentionally reaches into sabre/event's WildcardEmitterTrait internals
19+
* and into the query-monitoring side maps of OCA\DAV\Connector\Sabre\Server;
20+
* when a property is missing (e.g. after a library upgrade) the affected
21+
* information is silently omitted rather than failing.
22+
*/
23+
class ListenerIntrospector {
24+
/** Sabre's default listener priority (see Sabre\Event\EmitterInterface::on). */
25+
private const DEFAULT_PRIORITY = 100;
26+
27+
/**
28+
* Reads the Sabre event emitter's internal listener maps via reflection and
29+
* returns one entry per registration, sorted by event name then priority.
30+
*
31+
* @return list<array{event: string, priority: int, listener: string}>
32+
*/
33+
public function collectListeners(SabreServer $server): array {
34+
$reflection = new \ReflectionObject($server);
35+
36+
// Query-monitoring wrappers keep the original callbacks in a side map on
37+
// the Nextcloud server subclass. Read them so wrapped listeners can be
38+
// resolved back to the real plugin method rather than the wrapper.
39+
$wrappedListeners = $this->readListenerMap($reflection, $server, 'wrappedListeners');
40+
$originalListeners = $this->readListenerMap($reflection, $server, 'originalListeners');
41+
42+
$rows = [];
43+
foreach (['listeners' => false, 'wildcardListeners' => true] as $property => $isWildcard) {
44+
if (!$reflection->hasProperty($property)) {
45+
continue;
46+
}
47+
$value = $reflection->getProperty($property)->getValue($server);
48+
foreach ($value as $eventName => $registrations) {
49+
foreach ($registrations as $registration) {
50+
// Each registration is a [priority, callable] pair.
51+
[$priority, $callBack] = $registration;
52+
// Array keys degrade numeric strings to int, so cast for strict_types.
53+
[$callBack, $monitored] = $this->unwrapCallback((string)$eventName, $callBack, $wrappedListeners, $originalListeners);
54+
55+
$rows[] = [
56+
'event' => $eventName . ($isWildcard ? '*' : ''),
57+
'priority' => (int)$priority,
58+
'listener' => $this->describeCallable($callBack) . ($monitored ? ' (query-monitored)' : ''),
59+
];
60+
}
61+
}
62+
}
63+
64+
usort($rows, static function (array $a, array $b): int {
65+
return [$a['event'], $a['priority']] <=> [$b['event'], $b['priority']];
66+
});
67+
68+
return $rows;
69+
}
70+
71+
/**
72+
* Resolves the firing order for a single concrete event name into printable
73+
* rows (priority, registration origin and resolved listener), in the exact
74+
* order Sabre fires them.
75+
*
76+
* @return list<array{order: int, priority: int, registeredOn: string, listener: string}>
77+
*/
78+
public function resolveFiringOrder(SabreServer $server, string $eventName): array {
79+
$reflection = new \ReflectionObject($server);
80+
$wrappedListeners = $this->readListenerMap($reflection, $server, 'wrappedListeners');
81+
$originalListeners = $this->readListenerMap($reflection, $server, 'originalListeners');
82+
83+
$rows = [];
84+
foreach ($this->resolveFiringSequence($server, $eventName) as $i => $registration) {
85+
[$priority, $callBack, $registeredOn] = $registration;
86+
[$callBack, $monitored] = $this->unwrapCallback($eventName, $callBack, $wrappedListeners, $originalListeners);
87+
$rows[] = [
88+
'order' => $i + 1,
89+
'priority' => (int)$priority,
90+
'registeredOn' => $registeredOn,
91+
'listener' => $this->describeCallable($callBack) . ($monitored ? ' (query-monitored)' : ''),
92+
];
93+
}
94+
95+
return $rows;
96+
}
97+
98+
/**
99+
* Returns the [priority, callable] registrations for a concrete event name in
100+
* the exact order Sabre fires them.
101+
*
102+
* The order is taken directly from $server->listeners($eventName) — the very
103+
* list Sabre\DAV\Server::emit() iterates at request time — rather than
104+
* re-sorted here. This matters because WildcardEmitterTrait::listeners() sorts
105+
* with array_multisort($priorities, SORT_NUMERIC, $callbacks), so equal
106+
* priorities are tie-broken by comparing the callbacks themselves; a plain
107+
* stable priority sort would not reproduce that. The priority is then looked
108+
* up per callback from the raw registration maps for display only.
109+
*
110+
* @return list<array{0: int, 1: callable, 2: string}> [priority, callable, event name the listener was registered on]
111+
*/
112+
public function resolveFiringSequence(SabreServer $server, string $eventName): array {
113+
$ordered = $server->listeners($eventName);
114+
$registrations = $this->rawRegistrationsFor($server, $eventName);
115+
116+
$result = [];
117+
foreach ($ordered as $callBack) {
118+
$priority = self::DEFAULT_PRIORITY;
119+
$registeredOn = $eventName;
120+
foreach ($registrations as $key => [$prio, $cb, $origin]) {
121+
if ($cb === $callBack) {
122+
$priority = $prio;
123+
$registeredOn = $origin;
124+
// Consume the match so duplicate registrations of the same
125+
// callback map to distinct priorities.
126+
unset($registrations[$key]);
127+
break;
128+
}
129+
}
130+
$result[] = [(int)$priority, $callBack, $registeredOn];
131+
}
132+
133+
return $result;
134+
}
135+
136+
/**
137+
* Finds the WebDAV server's deferred "beforeMethod:*" handlers (defined in
138+
* $sourceFile) and returns them so they can be invoked to register the
139+
* plugins they add lazily.
140+
*
141+
* @return list<\Closure>
142+
*/
143+
public function findDeferredHandlers(SabreServer $server, string $sourceFile): array {
144+
$reflection = new \ReflectionObject($server);
145+
if (!$reflection->hasProperty('wildcardListeners')) {
146+
return [];
147+
}
148+
149+
// WildcardEmitterTrait stores "beforeMethod:*" under the key without the
150+
// trailing wildcard character.
151+
$wildcard = $reflection->getProperty('wildcardListeners')->getValue($server);
152+
$handlers = [];
153+
foreach ($wildcard['beforeMethod:'] ?? [] as [, $callBack]) {
154+
if (!$callBack instanceof \Closure) {
155+
continue;
156+
}
157+
$function = new \ReflectionFunction($callBack);
158+
if ($function->getFileName() === $sourceFile) {
159+
$handlers[] = $callBack;
160+
}
161+
}
162+
163+
return $handlers;
164+
}
165+
166+
/**
167+
* Collects the raw registrations that apply to a concrete event name: the
168+
* exact listeners plus every wildcard whose (star-stripped) key is a prefix
169+
* of the event name, each annotated with the event name it was registered
170+
* on. Order/priority here is not authoritative — it is only used as a
171+
* lookup table in resolveFiringSequence().
172+
*
173+
* @return list<array{0: int, 1: callable, 2: string}> [priority, callable, event name the listener was registered on]
174+
*/
175+
private function rawRegistrationsFor(SabreServer $server, string $eventName): array {
176+
$reflection = new \ReflectionObject($server);
177+
$exactAll = $reflection->hasProperty('listeners')
178+
? $reflection->getProperty('listeners')->getValue($server)
179+
: [];
180+
$wildcardAll = $reflection->hasProperty('wildcardListeners')
181+
? $reflection->getProperty('wildcardListeners')->getValue($server)
182+
: [];
183+
184+
$merged = [];
185+
foreach ($exactAll[$eventName] ?? [] as [$priority, $callBack]) {
186+
$merged[] = [$priority, $callBack, $eventName];
187+
}
188+
foreach ($wildcardAll as $wcEvent => $wcListeners) {
189+
if (str_starts_with($eventName, (string)$wcEvent)) {
190+
foreach ($wcListeners as [$priority, $callBack]) {
191+
$merged[] = [$priority, $callBack, $wcEvent . '*'];
192+
}
193+
}
194+
}
195+
196+
return $merged;
197+
}
198+
199+
/**
200+
* Resolves a possibly query-monitored callback back to the original listener.
201+
*
202+
* @param array<string, list<callable>> $wrappedListeners
203+
* @param array<string, list<callable>> $originalListeners
204+
* @return array{0: callable, 1: bool} the real callback and whether it was wrapped
205+
*/
206+
private function unwrapCallback(string $eventName, callable $callBack, array $wrappedListeners, array $originalListeners): array {
207+
if (isset($wrappedListeners[$eventName])) {
208+
$index = array_search($callBack, $wrappedListeners[$eventName], true);
209+
if ($index !== false && isset($originalListeners[$eventName][$index])) {
210+
return [$originalListeners[$eventName][$index], true];
211+
}
212+
}
213+
214+
return [$callBack, false];
215+
}
216+
217+
/**
218+
* @return array<string, list<callable>>
219+
*/
220+
private function readListenerMap(\ReflectionObject $reflection, SabreServer $server, string $property): array {
221+
if (!$reflection->hasProperty($property)) {
222+
return [];
223+
}
224+
$value = $reflection->getProperty($property)->getValue($server);
225+
return \is_array($value) ? $value : [];
226+
}
227+
228+
/**
229+
* Produces a human-readable description of a callable so it can be traced
230+
* back to the plugin that registered it.
231+
*/
232+
private function describeCallable(callable $callBack): string {
233+
if (\is_string($callBack)) {
234+
return $callBack;
235+
}
236+
237+
if (\is_array($callBack)) {
238+
$class = \is_object($callBack[0]) ? $callBack[0]::class : $callBack[0];
239+
return $class . '::' . $callBack[1];
240+
}
241+
242+
if ($callBack instanceof \Closure) {
243+
$function = new \ReflectionFunction($callBack);
244+
$name = $function->getName();
245+
$scope = $function->getClosureScopeClass()?->getName();
246+
$file = $function->getFileName();
247+
$line = $function->getStartLine();
248+
$location = ($file !== false && $line !== false) ? $file . ':' . $line : null;
249+
250+
$suffix = $location !== null ? ' (' . $location . ')' : '';
251+
252+
// A first-class callable (e.g. "$this->afterDownload(...)") reports a
253+
// bare function/method reference rather than a "{closure}" marker.
254+
// Wrap it so it is clearly marked as a closure and not confused with
255+
// a plain method callable.
256+
if (!str_contains($name, '{closure')) {
257+
$target = ($scope !== null && !str_contains($name, '::')) ? $scope . '::' . $name : $name;
258+
return '{closure:' . $target . '}';
259+
}
260+
261+
// PHP 8.4+ returns a descriptive name that already starts with
262+
// "{closure:" and includes the enclosing method and line, e.g.
263+
// "{closure:OCA\DAV\Server::__construct():289}". Drop the duplicate
264+
// trailing line and append the file so the full location is visible.
265+
if (str_starts_with($name, '{closure:')) {
266+
return (preg_replace('/:\d+}$/', '}', $name) ?? $name) . $suffix;
267+
}
268+
269+
// Older PHP returns "{closure}", prefixed with the namespace when
270+
// defined inside one (e.g. "OCA\DAV\{closure}"); replace it with the
271+
// class the closure was defined in (usually the plugin).
272+
if ($scope !== null) {
273+
return '{closure:' . $scope . '}' . $suffix;
274+
}
275+
276+
return '{closure}' . $suffix;
277+
}
278+
279+
// After ruling out strings, arrays and closures, the only remaining
280+
// callable form is an invokable object.
281+
return $callBack::class . '::__invoke';
282+
}
283+
}

0 commit comments

Comments
 (0)