Skip to content

Commit 2639d38

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 2639d38

6 files changed

Lines changed: 922 additions & 0 deletions

File tree

‎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\\ShowListenersCommand' => $baseDir . '/../lib/Command/ShowListenersCommand.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\\ShowListenersCommand' => __DIR__ . '/..' . '/../lib/Command/ShowListenersCommand.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: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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. The priority is
104+
* then looked up per callback from the raw registration maps for display only.
105+
*
106+
* @return list<array{0: int, 1: callable, 2: string}> [priority, callable, event name the listener was registered on]
107+
*/
108+
public function resolveFiringSequence(SabreServer $server, string $eventName): array {
109+
$ordered = $server->listeners($eventName);
110+
$registrations = $this->rawRegistrationsFor($server, $eventName);
111+
112+
$result = [];
113+
foreach ($ordered as $callBack) {
114+
$priority = self::DEFAULT_PRIORITY;
115+
$registeredOn = $eventName;
116+
foreach ($registrations as $key => [$prio, $cb, $origin]) {
117+
if ($cb === $callBack) {
118+
$priority = $prio;
119+
$registeredOn = $origin;
120+
// Consume the match so duplicate registrations of the same
121+
// callback map to distinct priorities.
122+
unset($registrations[$key]);
123+
break;
124+
}
125+
}
126+
$result[] = [(int)$priority, $callBack, $registeredOn];
127+
}
128+
129+
return $result;
130+
}
131+
132+
/**
133+
* Finds the WebDAV server's deferred "beforeMethod:*" handlers (defined in
134+
* $sourceFile) and returns them so they can be invoked to register the
135+
* plugins they add lazily.
136+
*
137+
* @return list<\Closure>
138+
*/
139+
public function findDeferredHandlers(SabreServer $server, string $sourceFile): array {
140+
$reflection = new \ReflectionObject($server);
141+
if (!$reflection->hasProperty('wildcardListeners')) {
142+
return [];
143+
}
144+
145+
// WildcardEmitterTrait stores "beforeMethod:*" under the key without the
146+
// trailing wildcard character.
147+
$wildcard = $reflection->getProperty('wildcardListeners')->getValue($server);
148+
$handlers = [];
149+
foreach ($wildcard['beforeMethod:'] ?? [] as [, $callBack]) {
150+
if (!$callBack instanceof \Closure) {
151+
continue;
152+
}
153+
$function = new \ReflectionFunction($callBack);
154+
if ($function->getFileName() === $sourceFile) {
155+
$handlers[] = $callBack;
156+
}
157+
}
158+
159+
return $handlers;
160+
}
161+
162+
/**
163+
* Collects the raw registrations that apply to a concrete event name: the
164+
* exact listeners plus every wildcard whose (star-stripped) key is a prefix
165+
* of the event name, each annotated with the event name it was registered
166+
* on. Order/priority here is not authoritative — it is only used as a
167+
* lookup table in resolveFiringSequence().
168+
*
169+
* @return list<array{0: int, 1: callable, 2: string}> [priority, callable, event name the listener was registered on]
170+
*/
171+
private function rawRegistrationsFor(SabreServer $server, string $eventName): array {
172+
$reflection = new \ReflectionObject($server);
173+
$exactAll = $reflection->hasProperty('listeners')
174+
? $reflection->getProperty('listeners')->getValue($server)
175+
: [];
176+
$wildcardAll = $reflection->hasProperty('wildcardListeners')
177+
? $reflection->getProperty('wildcardListeners')->getValue($server)
178+
: [];
179+
180+
$merged = [];
181+
foreach ($exactAll[$eventName] ?? [] as [$priority, $callBack]) {
182+
$merged[] = [$priority, $callBack, $eventName];
183+
}
184+
foreach ($wildcardAll as $wcEvent => $wcListeners) {
185+
if (str_starts_with($eventName, (string)$wcEvent)) {
186+
foreach ($wcListeners as [$priority, $callBack]) {
187+
$merged[] = [$priority, $callBack, $wcEvent . '*'];
188+
}
189+
}
190+
}
191+
192+
return $merged;
193+
}
194+
195+
/**
196+
* Resolves a possibly query-monitored callback back to the original listener.
197+
*
198+
* @param array<string, list<callable>> $wrappedListeners
199+
* @param array<string, list<callable>> $originalListeners
200+
* @return array{0: callable, 1: bool} the real callback and whether it was wrapped
201+
*/
202+
private function unwrapCallback(string $eventName, callable $callBack, array $wrappedListeners, array $originalListeners): array {
203+
if (isset($wrappedListeners[$eventName])) {
204+
$index = array_search($callBack, $wrappedListeners[$eventName], true);
205+
if ($index !== false && isset($originalListeners[$eventName][$index])) {
206+
return [$originalListeners[$eventName][$index], true];
207+
}
208+
}
209+
210+
return [$callBack, false];
211+
}
212+
213+
/**
214+
* @return array<string, list<callable>>
215+
*/
216+
private function readListenerMap(\ReflectionObject $reflection, SabreServer $server, string $property): array {
217+
if (!$reflection->hasProperty($property)) {
218+
return [];
219+
}
220+
$value = $reflection->getProperty($property)->getValue($server);
221+
return \is_array($value) ? $value : [];
222+
}
223+
224+
/**
225+
* Produces a human-readable description of a callable so it can be traced
226+
* back to the plugin that registered it.
227+
*/
228+
private function describeCallable(callable $callBack): string {
229+
if (\is_string($callBack)) {
230+
return $callBack;
231+
}
232+
233+
if (\is_array($callBack)) {
234+
$class = \is_object($callBack[0]) ? $callBack[0]::class : $callBack[0];
235+
return $class . '::' . $callBack[1];
236+
}
237+
238+
if ($callBack instanceof \Closure) {
239+
$function = new \ReflectionFunction($callBack);
240+
$name = $function->getName();
241+
$scope = $function->getClosureScopeClass()?->getName();
242+
$file = $function->getFileName();
243+
$line = $function->getStartLine();
244+
$location = ($file !== false && $line !== false) ? $file . ':' . $line : null;
245+
246+
$suffix = $location !== null ? ' (' . $location . ')' : '';
247+
248+
// A first-class callable (e.g. "$this->afterDownload(...)") reports a
249+
// bare function/method reference rather than a "{closure}" marker.
250+
// Wrap it so it is clearly marked as a closure and not confused with
251+
// a plain method callable.
252+
if (!str_contains($name, '{closure')) {
253+
$target = ($scope !== null && !str_contains($name, '::')) ? $scope . '::' . $name : $name;
254+
return '{closure:' . $target . '}';
255+
}
256+
257+
// PHP 8.4+ returns a descriptive name that already starts with
258+
// "{closure:" and includes the enclosing method and line, e.g.
259+
// "{closure:OCA\DAV\Server::__construct():289}". Drop the duplicate
260+
// trailing line and append the file so the full location is visible.
261+
if (str_starts_with($name, '{closure:')) {
262+
return (preg_replace('/:\d+}$/', '}', $name) ?? $name) . $suffix;
263+
}
264+
265+
// Older PHP returns "{closure}", prefixed with the namespace when
266+
// defined inside one (e.g. "OCA\DAV\{closure}"); replace it with the
267+
// class the closure was defined in (usually the plugin).
268+
if ($scope !== null) {
269+
return '{closure:' . $scope . '}' . $suffix;
270+
}
271+
272+
return '{closure}' . $suffix;
273+
}
274+
275+
// After ruling out strings, arrays and closures, the only remaining
276+
// callable form is an invokable object.
277+
return $callBack::class . '::__invoke';
278+
}
279+
}

0 commit comments

Comments
 (0)