Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dav/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<command>OCA\DAV\Command\RetentionCleanupCommand</command>
<command>OCA\DAV\Command\SendEventReminders</command>
<command>OCA\DAV\Command\SetAbsenceCommand</command>
<command>OCA\DAV\Command\ShowListeners</command>

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 command should be registered at runtime and only when the debug mode is enabled. It is not intended to be used by admins.

<command>OCA\DAV\Command\SyncBirthdayCalendar</command>
<command>OCA\DAV\Command\SyncSystemAddressBook</command>
</commands>
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,13 @@
'OCA\\DAV\\Command\\ListCalendarShares' => $baseDir . '/../lib/Command/ListCalendarShares.php',
'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php',
'OCA\\DAV\\Command\\ListSubscriptions' => $baseDir . '/../lib/Command/ListSubscriptions.php',
'OCA\\DAV\\Command\\ListenerIntrospector' => $baseDir . '/../lib/Command/ListenerIntrospector.php',
'OCA\\DAV\\Command\\MoveCalendar' => $baseDir . '/../lib/Command/MoveCalendar.php',
'OCA\\DAV\\Command\\RemoveInvalidShares' => $baseDir . '/../lib/Command/RemoveInvalidShares.php',
'OCA\\DAV\\Command\\RetentionCleanupCommand' => $baseDir . '/../lib/Command/RetentionCleanupCommand.php',
'OCA\\DAV\\Command\\SendEventReminders' => $baseDir . '/../lib/Command/SendEventReminders.php',
'OCA\\DAV\\Command\\SetAbsenceCommand' => $baseDir . '/../lib/Command/SetAbsenceCommand.php',
'OCA\\DAV\\Command\\ShowListeners' => $baseDir . '/../lib/Command/ShowListeners.php',
'OCA\\DAV\\Command\\SyncBirthdayCalendar' => $baseDir . '/../lib/Command/SyncBirthdayCalendar.php',
'OCA\\DAV\\Command\\SyncSystemAddressBook' => $baseDir . '/../lib/Command/SyncSystemAddressBook.php',
'OCA\\DAV\\Comments\\CommentNode' => $baseDir . '/../lib/Comments/CommentNode.php',
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,13 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Command\\ListCalendarShares' => __DIR__ . '/..' . '/../lib/Command/ListCalendarShares.php',
'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php',
'OCA\\DAV\\Command\\ListSubscriptions' => __DIR__ . '/..' . '/../lib/Command/ListSubscriptions.php',
'OCA\\DAV\\Command\\ListenerIntrospector' => __DIR__ . '/..' . '/../lib/Command/ListenerIntrospector.php',
'OCA\\DAV\\Command\\MoveCalendar' => __DIR__ . '/..' . '/../lib/Command/MoveCalendar.php',
'OCA\\DAV\\Command\\RemoveInvalidShares' => __DIR__ . '/..' . '/../lib/Command/RemoveInvalidShares.php',
'OCA\\DAV\\Command\\RetentionCleanupCommand' => __DIR__ . '/..' . '/../lib/Command/RetentionCleanupCommand.php',
'OCA\\DAV\\Command\\SendEventReminders' => __DIR__ . '/..' . '/../lib/Command/SendEventReminders.php',
'OCA\\DAV\\Command\\SetAbsenceCommand' => __DIR__ . '/..' . '/../lib/Command/SetAbsenceCommand.php',
'OCA\\DAV\\Command\\ShowListeners' => __DIR__ . '/..' . '/../lib/Command/ShowListeners.php',
'OCA\\DAV\\Command\\SyncBirthdayCalendar' => __DIR__ . '/..' . '/../lib/Command/SyncBirthdayCalendar.php',
'OCA\\DAV\\Command\\SyncSystemAddressBook' => __DIR__ . '/..' . '/../lib/Command/SyncSystemAddressBook.php',
'OCA\\DAV\\Comments\\CommentNode' => __DIR__ . '/..' . '/../lib/Comments/CommentNode.php',
Expand Down
283 changes: 283 additions & 0 deletions apps/dav/lib/Command/ListenerIntrospector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Command;

use Sabre\DAV\Server as SabreServer;

/**
* Reads the listener registrations out of a Sabre server via reflection so
* they can be displayed for debugging (see the dav:show-listeners command).
*
* This intentionally reaches into sabre/event's WildcardEmitterTrait internals
* and into the query-monitoring side maps of OCA\DAV\Connector\Sabre\Server;
* when a property is missing (e.g. after a library upgrade) the affected
* information is silently omitted rather than failing.
*/
class ListenerIntrospector {
/** Sabre's default listener priority (see Sabre\Event\EmitterInterface::on). */
private const DEFAULT_PRIORITY = 100;

/**
* Reads the Sabre event emitter's internal listener maps via reflection and
* returns one entry per registration, sorted by event name then priority.
*
* @return list<array{event: string, priority: int, listener: string}>
*/
public function collectListeners(SabreServer $server): array {
$reflection = new \ReflectionObject($server);

// Query-monitoring wrappers keep the original callbacks in a side map on
// the Nextcloud server subclass. Read them so wrapped listeners can be
// resolved back to the real plugin method rather than the wrapper.
$wrappedListeners = $this->readListenerMap($reflection, $server, 'wrappedListeners');
$originalListeners = $this->readListenerMap($reflection, $server, 'originalListeners');

$rows = [];
foreach (['listeners' => false, 'wildcardListeners' => true] as $property => $isWildcard) {
if (!$reflection->hasProperty($property)) {
continue;
}
$value = $reflection->getProperty($property)->getValue($server);
foreach ($value as $eventName => $registrations) {
foreach ($registrations as $registration) {
// Each registration is a [priority, callable] pair.
[$priority, $callBack] = $registration;
// Array keys degrade numeric strings to int, so cast for strict_types.
[$callBack, $monitored] = $this->unwrapCallback((string)$eventName, $callBack, $wrappedListeners, $originalListeners);

$rows[] = [
'event' => $eventName . ($isWildcard ? '*' : ''),
'priority' => (int)$priority,
'listener' => $this->describeCallable($callBack) . ($monitored ? ' (query-monitored)' : ''),
];
}
}
}

usort($rows, static function (array $a, array $b): int {
return [$a['event'], $a['priority']] <=> [$b['event'], $b['priority']];
});

return $rows;
}

/**
* Resolves the firing order for a single concrete event name into printable
* rows (priority, registration origin and resolved listener), in the exact
* order Sabre fires them.
*
* @return list<array{order: int, priority: int, registeredOn: string, listener: string}>
*/
public function resolveFiringOrder(SabreServer $server, string $eventName): array {
$reflection = new \ReflectionObject($server);
$wrappedListeners = $this->readListenerMap($reflection, $server, 'wrappedListeners');
$originalListeners = $this->readListenerMap($reflection, $server, 'originalListeners');

$rows = [];
foreach ($this->resolveFiringSequence($server, $eventName) as $i => $registration) {
[$priority, $callBack, $registeredOn] = $registration;
[$callBack, $monitored] = $this->unwrapCallback($eventName, $callBack, $wrappedListeners, $originalListeners);
$rows[] = [
'order' => $i + 1,
'priority' => (int)$priority,
'registeredOn' => $registeredOn,
'listener' => $this->describeCallable($callBack) . ($monitored ? ' (query-monitored)' : ''),
];
}

return $rows;
}

/**
* Returns the [priority, callable] registrations for a concrete event name in
* the exact order Sabre fires them.
*
* The order is taken directly from $server->listeners($eventName) — the very
* list Sabre\DAV\Server::emit() iterates at request time — rather than
* re-sorted here. This matters because WildcardEmitterTrait::listeners() sorts
* with array_multisort($priorities, SORT_NUMERIC, $callbacks), so equal
* priorities are tie-broken by comparing the callbacks themselves; a plain
* stable priority sort would not reproduce that. The priority is then looked
* up per callback from the raw registration maps for display only.
Comment on lines +102 to +108

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.

Not sure if this comment should be kept, since it was fixed upstream.

*
* @return list<array{0: int, 1: callable, 2: string}> [priority, callable, event name the listener was registered on]
*/
public function resolveFiringSequence(SabreServer $server, string $eventName): array {
$ordered = $server->listeners($eventName);
$registrations = $this->rawRegistrationsFor($server, $eventName);

$result = [];
foreach ($ordered as $callBack) {
$priority = self::DEFAULT_PRIORITY;
$registeredOn = $eventName;
foreach ($registrations as $key => [$prio, $cb, $origin]) {
if ($cb === $callBack) {
$priority = $prio;
$registeredOn = $origin;
// Consume the match so duplicate registrations of the same
// callback map to distinct priorities.
unset($registrations[$key]);
break;
}
}
$result[] = [(int)$priority, $callBack, $registeredOn];
}

return $result;
}

/**
* Finds the WebDAV server's deferred "beforeMethod:*" handlers (defined in
* $sourceFile) and returns them so they can be invoked to register the
* plugins they add lazily.
*
* @return list<\Closure>
*/
public function findDeferredHandlers(SabreServer $server, string $sourceFile): array {
$reflection = new \ReflectionObject($server);
if (!$reflection->hasProperty('wildcardListeners')) {
return [];
}

// WildcardEmitterTrait stores "beforeMethod:*" under the key without the
// trailing wildcard character.
$wildcard = $reflection->getProperty('wildcardListeners')->getValue($server);
$handlers = [];
foreach ($wildcard['beforeMethod:'] ?? [] as [, $callBack]) {
if (!$callBack instanceof \Closure) {
continue;
}
$function = new \ReflectionFunction($callBack);
if ($function->getFileName() === $sourceFile) {
$handlers[] = $callBack;
}
}

return $handlers;
}

/**
* Collects the raw registrations that apply to a concrete event name: the
* exact listeners plus every wildcard whose (star-stripped) key is a prefix
* of the event name, each annotated with the event name it was registered
Comment on lines +167 to +169

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 sounds really strange. Can you please check in the sabre code, that this is actually true? I kind of doubt it, because it would not really work well in reality.

* on. Order/priority here is not authoritative — it is only used as a
* lookup table in resolveFiringSequence().
*
* @return list<array{0: int, 1: callable, 2: string}> [priority, callable, event name the listener was registered on]
*/
private function rawRegistrationsFor(SabreServer $server, string $eventName): array {
$reflection = new \ReflectionObject($server);
$exactAll = $reflection->hasProperty('listeners')
? $reflection->getProperty('listeners')->getValue($server)
: [];
$wildcardAll = $reflection->hasProperty('wildcardListeners')
? $reflection->getProperty('wildcardListeners')->getValue($server)
: [];

$merged = [];
foreach ($exactAll[$eventName] ?? [] as [$priority, $callBack]) {
$merged[] = [$priority, $callBack, $eventName];
}
foreach ($wildcardAll as $wcEvent => $wcListeners) {
if (str_starts_with($eventName, (string)$wcEvent)) {
foreach ($wcListeners as [$priority, $callBack]) {
$merged[] = [$priority, $callBack, $wcEvent . '*'];
}
}
}

return $merged;
}

/**
* Resolves a possibly query-monitored callback back to the original listener.
*
* @param array<string, list<callable>> $wrappedListeners
* @param array<string, list<callable>> $originalListeners
* @return array{0: callable, 1: bool} the real callback and whether it was wrapped
*/
private function unwrapCallback(string $eventName, callable $callBack, array $wrappedListeners, array $originalListeners): array {
if (isset($wrappedListeners[$eventName])) {
$index = array_search($callBack, $wrappedListeners[$eventName], true);
if ($index !== false && isset($originalListeners[$eventName][$index])) {
return [$originalListeners[$eventName][$index], true];
}
}

return [$callBack, false];
}

/**
* @return array<string, list<callable>>
*/
private function readListenerMap(\ReflectionObject $reflection, SabreServer $server, string $property): array {
if (!$reflection->hasProperty($property)) {
return [];
}
$value = $reflection->getProperty($property)->getValue($server);
return \is_array($value) ? $value : [];
}

/**
* Produces a human-readable description of a callable so it can be traced
* back to the plugin that registered it.
*/
private function describeCallable(callable $callBack): string {
if (\is_string($callBack)) {
return $callBack;
}

if (\is_array($callBack)) {
$class = \is_object($callBack[0]) ? $callBack[0]::class : $callBack[0];
return $class . '::' . $callBack[1];
}

if ($callBack instanceof \Closure) {
$function = new \ReflectionFunction($callBack);
$name = $function->getName();
$scope = $function->getClosureScopeClass()?->getName();
$file = $function->getFileName();
$line = $function->getStartLine();
$location = ($file !== false && $line !== false) ? $file . ':' . $line : null;

$suffix = $location !== null ? ' (' . $location . ')' : '';

// A first-class callable (e.g. "$this->afterDownload(...)") reports a
// bare function/method reference rather than a "{closure}" marker.
// Wrap it so it is clearly marked as a closure and not confused with
// a plain method callable.
if (!str_contains($name, '{closure')) {
$target = ($scope !== null && !str_contains($name, '::')) ? $scope . '::' . $name : $name;
return '{closure:' . $target . '}';
}

// PHP 8.4+ returns a descriptive name that already starts with
// "{closure:" and includes the enclosing method and line, e.g.
// "{closure:OCA\DAV\Server::__construct():289}". Drop the duplicate
// trailing line and append the file so the full location is visible.
if (str_starts_with($name, '{closure:')) {
return (preg_replace('/:\d+}$/', '}', $name) ?? $name) . $suffix;
}

// Older PHP returns "{closure}", prefixed with the namespace when
// defined inside one (e.g. "OCA\DAV\{closure}"); replace it with the
// class the closure was defined in (usually the plugin).
if ($scope !== null) {
return '{closure:' . $scope . '}' . $suffix;
}

return '{closure}' . $suffix;
}

// After ruling out strings, arrays and closures, the only remaining
// callable form is an invokable object.
return $callBack::class . '::__invoke';
}
}
Loading
Loading